Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "Construction skills for AI agents: document-based estimating and project controls workflows. No Buildr account required.",
"version": "0.8.0"
"version": "0.9.0"
},
"plugins": [
{
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Test proposal data validation
run: node --test evals/tasks/proposal-builder/validate.test.mjs
- name: Install Poppler for PDF page checks
run: sudo apt-get update -qq && sudo apt-get install -y -qq poppler-utils
- name: Test proposal compliance checks and page measurement
run: node --test evals/tasks/proposal-builder/proposal-core.test.mjs
- name: Test workforce planning calculations and plan build
run: node --test evals/tasks/workforce-planning/staffing-core.test.mjs
- name: Test bid leveling calculations and tab build
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ text and inputs as attachments; script or PDF workflows still require a runtime.
| Skill | Stage | What it does |
|---|---|---|
| [bid-leveling](skills/bid-leveling) | Preconstruction, Estimating | Level sub bids on bid day. See every scope gap, accept or enter plugs, and find out who is really low once the scope matches. |
| [proposal-builder](skills/proposal-builder) | Business development, Estimating | Build polished construction proposal PDFs with pdfcn, sourced scope, pricing and qualifications. |
| [proposal-builder](skills/proposal-builder) | Business development, Preconstruction | Answer the RFP, compliance first. Every requirement mapped to where you answer it, page limits and forms checked, and a polished PDF built from your own library. |
| [rfp-intake](skills/rfp-intake) | Business development, Preconstruction | Prep the go/no-go meeting. Check the must-pass gates, score the opportunity on your criteria, and get a cited brief with dates, addenda changes, risks, and questions. |
| [workforce-planning](skills/workforce-planning) | Workforce, Operations | Prep the staffing meeting: who is overloaded, which jobs have empty seats, who rolls off when, and what changes if you win the pursuits you are chasing. |

Expand Down
124 changes: 87 additions & 37 deletions catalog.json

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions evals/reviews/proposal-builder/REPORT.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,97 @@
# Proposal Builder verification

## Revision 2.0.0: RFP responses, compliance first

Previous revision: 1.0.0, a polished proposal PDF from a free-form brief. This
revision keeps pdfcn and Forme but changes the job: answer an owner's RFP after
a go decision, and prove it answers everything.

### What changed

- **Data.** `proposal.json` now carries:
- the RFP's requirements (with points), required forms, and page limit
- every source, with its kind and date
- facts that can differ between sources
- sections in the RFP's order, each listing the requirements it answers
- **Checks.** `templates/proposal-core.mjs` is dependency-free and shared by
the renderer and `scripts/check.mjs`. The check writes
`compliance-check.md`.
- **Blocking:**
- unanswered requirements
- missing forms
- counted pages over the limit, measured on the rendered PDF with Poppler
- an unpriced fee
- **Warnings:**
- requirements answered only by unconfirmed claims from prior proposals
- stale sources
- values that differ between sources, when the proposal uses the older one
- **Review findings from 1.0.0, fixed:**
- P1, $0 proposals: an absent fee is `unpriced`, and zero lump sums are
rejected.
- Sample provenance: every item cites a real page or library file.
- PDF title mojibake: metadata is made ASCII.
- Non-two-decimal currencies: now rejected instead of rounded.
- Setup: no longer checks paths with `/`, now uses `npm.cmd` on Windows,
and removes a half-built workspace on failure.
- Long-name chapter opener: aligned to the top.
- **Renderer.** `render.tsx` renders:
- the RFP's tab order
- project and team cards
- grouped narrative lists
- the fee table with percentage lines
- a forms checklist
- a compliance matrix appendix

Source lines print in `DRAFT`, and unconfirmed prior claims print in rust.

### Sample

Cedar Hollow County, Riverside Branch Library, a CM at Risk RFP with a 20-page
limit. Inputs:

- the RFP PDF
- Larkspur Builders' library (projects, people, safety, approach)
- two prior proposals as PDFs
- the user's fee and team email

Planted problems:

- Tab G (small and local business, 5 points) has no library content
- Form 2 isn't included
- the 2024 proposal carries a 2023 EMR and a GMP instead of the final cost
- a "within 3% of GMP" claim is lifted from the 2024 proposal

Rendered: 10 pages, 6 counted toward the limit. The check reports 2 blocking
items and 2 warnings.

### Verification

- `node --test evals/tasks/proposal-builder/proposal-core.test.mjs`: 12 tests,
in CI, which now installs Poppler. They include:
- the bundled compliance check reproduced byte for byte from the bundled PDF
- per-section page measurement
- fixing the planted gaps clears every blocking item
- fee rules, validation, and CLI exit codes
- Mutation check: 10 deliberate breaks each failed between 1 and 4 tests.
- A fresh `setup.mjs` workspace installed the pinned pdfcn revision and
rendered the sample.
- All pages were rasterized and inspected.
- One card alone on page 4 was fixed by tightening the card layout and
shortening the section lead.
- The PDF title reads cleanly in `pdfinfo`.
- `refresh-preview.py` reproduces the committed 225 KB page preview, which is
under the catalog's 256 KB inline limit.

### Not yet tested

A fresh agent building a proposal from a library and an RFP it hasn't seen,
especially whether it leaves a requirement unanswered rather than writing
around it.

---

# Revision 1.0.0

Baseline: skills main `8adb4d30a1ce3266f564cdd52f76c391eadd7403`.
The broad PDF skill is replaced, not maintained as a compatibility alias.
Pay App Review and RFI Drafter and their task/review directories are removed.
Expand Down
10 changes: 6 additions & 4 deletions evals/reviews/proposal-builder/refresh-preview.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Regenerate the catalog preview from the actual rendered sample PDF. Requires Poppler."""
"""Regenerate the catalog preview from the actual rendered sample PDF. Requires Poppler.

Sized to stay under the catalog's 256 KB inline limit so the marketing site can show it."""
import base64
from pathlib import Path
import subprocess
Expand All @@ -7,9 +9,9 @@
SAMPLES = ROOT / 'skills/proposal-builder/samples'
with tempfile.TemporaryDirectory() as tmp:
prefix = Path(tmp) / 'page'
subprocess.run(['pdftoppm', '-scale-to', '700', '-jpeg', '-jpegopt', 'quality=60', str(SAMPLES / 'output-proposal.pdf'), str(prefix)], check=True)
html = '<!doctype html><html lang="en"><meta charset="utf-8"><title>Cedar Hollow proposal — rendered PDF sample</title><style>body{margin:0;background:#e8ebe8;padding:16px}img{display:block;max-width:100%;width:696px;margin:0 auto 20px;box-shadow:0 2px 12px #0002}</style><body>'
subprocess.run(['pdftoppm', '-scale-to', '640', '-jpeg', '-jpegopt', 'quality=40', str(SAMPLES / 'output-proposal.pdf'), str(prefix)], check=True)
html = '<!doctype html><html lang="en"><meta charset="utf-8"><title>Riverside Branch Library proposal: rendered PDF pages</title><style>body{margin:0;background:#e8ebe8;padding:16px}img{display:block;max-width:100%;width:640px;margin:0 auto 20px;box-shadow:0 2px 12px #0002}</style><body>'
for i, path in enumerate(sorted(Path(tmp).glob('page-*.jpg'))):
data = base64.b64encode(path.read_bytes()).decode()
html += f'<img alt="Rendered proposal PDF page {i+1}" src="data:image/jpeg;base64,{data}">'
(SAMPLES / 'output-proposal.html').write_text(html + '</body></html>')
(SAMPLES / 'output-proposal.html').write_text(html + '</body></html>\n')
153 changes: 153 additions & 0 deletions evals/tasks/proposal-builder/proposal-core.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { analyze, money, monthsBetween, renderCheck, validate } from "../../../skills/proposal-builder/templates/proposal-core.mjs";
import { measurePages } from "../../../skills/proposal-builder/scripts/check.mjs";

const skill = fileURLToPath(new URL("../../../skills/proposal-builder/", import.meta.url));
const check = join(skill, "scripts/check.mjs");
const sample = () => JSON.parse(readFileSync(join(skill, "samples/proposal.json"), "utf8"));
const pdf = join(skill, "samples/output-proposal.pdf");
const poppler = spawnSync("pdfinfo", ["-v"]).status === 0;
const section = (p, id) => p.sections.find((s) => s.id === id);
const write = (p) => {
const file = join(mkdtempSync(join(tmpdir(), "proposal-")), "proposal.json");
writeFileSync(file, JSON.stringify(p));
return file;
};

// The sample with its planted problems fixed: Tab G answered, Form 2 included.
function ready() {
const p = sample();
p.sections.splice(6, 0, { id: "sbe", tab: "Tab G", kind: "narrative", title: "Small and local business participation", answers: ["G"], items: [{ text: "Outreach plan supplied by the user.", source: { id: "dana" } }] });
Object.assign(p.rfp.forms[1], { included: true, section: "forms" });
return p;
}

test("bundled compliance check matches a fresh run on the bundled PDF", { skip: !poppler && "Poppler not installed" }, () => {
const out = spawnSync(process.execPath, [check, join(skill, "samples/proposal.json"), "--pdf", pdf], { encoding: "utf8" });
assert.equal(out.status, 1, out.stderr);
assert.equal(out.stdout, readFileSync(join(skill, "samples/output-compliance-check.md"), "utf8"));
});

test("pages are measured per section from the rendered PDF", { skip: !poppler && "Poppler not installed" }, () => {
const p = sample();
const pages = measurePages(pdf, p.sections);
assert.equal(pages.total, 10);
assert.deepEqual(pages.bySection, { letter: 1, qualifications: 1, team: 1, precon: 1, construction: 1, safety: 1, fee: 1, forms: 1, matrix: 1 });
const a = analyze(p, pages);
assert.equal(a.pageCheck.counted, 6);
assert.equal(a.pageCheck.ok, true);
});

test("sample: Tab G and Form 2 block submission; old claims are flagged", () => {
const a = analyze(validate(sample()));
assert.deepEqual(a.gaps.map((x) => x.req.id), ["G"]);
assert.deepEqual(a.missingForms.map((x) => x.form.id), ["F2"]);
assert.equal(a.blocking, 2);
assert.equal(a.unconfirmed.length, 1);
assert.match(a.unconfirmed[0].item.text, /within 3% of the final GMP/);
assert.deepEqual(a.stale.map((x) => x.source.id), ["prior-2024"]);
assert.deepEqual(a.conflicts.map((c) => [c.key, c.usesOlder.length]), [["emr", 0], ["garfield-value", 0]]);
});

test("fixing the gaps clears every blocking item", () => {
const a = analyze(validate(ready()));
assert.equal(a.blocking, 0);
const out = spawnSync(process.execPath, [check, write(ready())], { encoding: "utf8" });
assert.equal(out.status, 0, out.stderr);
assert.match(out.stdout, /\*\*No blocking items\.\*\*/);
});

test("using the older of two conflicting values is flagged", () => {
const p = sample();
const garfield = section(p, "qualifications").items[1];
garfield.source = { id: "prior-2024", page: 1 };
garfield.confirmed = true;
const a = analyze(validate(p));
const c = a.conflicts.find((x) => x.key === "garfield-value");
assert.equal(c.usesOlder.length, 1);
assert.match(renderCheck(p, a), /The proposal uses the older value\*\* in Firm qualifications/);
});

test("a requirement answered only by unconfirmed prior claims is weak until confirmed", () => {
const p = ready();
section(p, "sbe").items = [{ text: "Outreach meeting before every package.", source: { id: "prior-2024", page: 2 } }];
let a = analyze(validate(p));
assert.equal(a.matrix.find((x) => x.req.id === "G").status, "unconfirmed");
section(p, "sbe").items[0].confirmed = true;
a = analyze(validate(p));
assert.equal(a.matrix.find((x) => x.req.id === "G").status, "answered");
});

test("page limits count only the sections the RFP counts", () => {
const p = validate(sample());
const bySection = Object.fromEntries(p.sections.map((s) => [s.id, 4]));
const a = analyze(p, { total: 37, bySection });
assert.equal(a.pageCheck.counted, 24); // six counted sections, four pages each
assert.equal(a.pageCheck.ok, false);
assert.equal(a.blocking, 3);
assert.match(renderCheck(p, a), /24 counted pages against a limit of 20/);
});

test("an absent fee is unpriced, never zero", () => {
const p = sample();
p.fee = { status: "unpriced", note: "Fee worksheet not received." };
const a = analyze(validate(p));
assert.equal(a.blocking, 3);
assert.match(renderCheck(p, a), /The fee is unpriced: Fee worksheet not received\./);
const zero = sample();
zero.fee.lines[0].cents = 0;
zero.fee.totalCents = 61200000;
assert.throws(() => validate(zero), /lump sum must be positive integer cents/);
const mixed = sample();
mixed.fee.status = "unpriced";
mixed.fee.note = "x";
assert.throws(() => validate(mixed), /must not carry lines or a total/);
});

test("fee totals reconcile, and options stay out", () => {
const p = sample();
p.fee.totalCents += 1;
assert.throws(() => validate(p), /must equal the lump-sum lines \(66000000\)/);
const opt = sample();
opt.fee.lines.push({ id: "alt", label: "Extended general conditions", basis: "lump", cents: 5000000, option: true, source: { id: "dana" } });
assert.doesNotThrow(() => validate(opt));
});

test("validation refuses unsourced or inconsistent data", () => {
const bad = (mutate, pattern) => {
const p = sample();
mutate(p);
assert.throws(() => validate(p), pattern);
};
bad((p) => delete section(p, "team").items[0].source, /needs a source/);
bad((p) => (section(p, "precon").items[1].source.page = 7), /page 7 is not in prior-2024 \(2 pages\)/);
bad((p) => (section(p, "safety").items[0].source = { id: "nope" }), /source "nope" is not in sources/);
bad((p) => (section(p, "safety").answers = ["Z"]), /answers "Z", which is not a requirement/);
bad((p) => (section(p, "safety").items[0].facts = ["garfield-value"]), /uses fact "garfield-value" from lib-safety/);
bad((p) => delete p.rfp.forms[0].section, /form F1: included, so section must name/);
bad((p) => (p.meta.currency = "JPY"), /does not use two decimal places/);
bad((p) => (p.sections[1].id = p.sections[0].id), /section id "letter" is repeated/);
bad((p) => (section(p, "team").items = []), /items must be a nonempty list/);
});

test("helpers", () => {
assert.equal(money(66000000), "$660,000.00");
assert.equal(money(-125050), "-$1,250.50");
assert.equal(monthsBetween("2024-03-12", "2026-10-06"), 30);
assert.equal(monthsBetween("2026-02-15", "2026-10-06"), 7);
assert.equal(monthsBetween("2026-01-31", "2026-02-28"), 0);
});

test("CLI exits 2 on invalid data", () => {
const p = sample();
p.meta.status = "FINAL";
const out = spawnSync(process.execPath, [check, write(p)], { encoding: "utf8" });
assert.equal(out.status, 2);
assert.match(out.stderr, /meta.status must be DRAFT or FOR REVIEW/);
});
38 changes: 0 additions & 38 deletions evals/tasks/proposal-builder/validate.test.mjs

This file was deleted.

Loading
Loading