shapeup-sdlc 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -2
- package/commands/ship.md +5 -0
- package/hooks/gate-intake.mjs +1 -1
- package/kernel/compile.mjs +51 -2
- package/kernel/init/run.mjs +122 -6
- package/kernel/lib/breadboard.mjs +165 -0
- package/kernel/lib/paths.mjs +3 -1
- package/kernel/probe/eval.mjs +83 -12
- package/kernel/probe/resume.mjs +15 -2
- package/kernel/probe/t0.mjs +26 -3
- package/kernel/reduce/ingest.mjs +15 -0
- package/kernel/verify/spec.mjs +190 -2
- package/package.json +1 -1
- package/skills/ba-pitch-analyzer/SKILL.md +11 -5
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +2 -1
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +12 -2
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +9 -0
- package/skills/orient/SKILL.md +7 -4
- package/skills/scope-architect/SKILL.md +4 -0
- package/skills/solution-architect/SKILL.md +4 -1
- package/skills/spec-evaluator/SKILL.md +1 -1
- package/skills/tech-lead/SKILL.md +6 -6
- package/skills/tech-lead/references/gates.md +26 -12
- package/skills/tech-lead/references/protocol.md +13 -8
- package/skills/tech-lead/schemas/domain.schema.json +29 -3
- package/skills/tech-lead/workflows/shapeup-run.js +27 -10
package/kernel/probe/eval.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// probe eval — "what did round N's EVAL WorkResult actually say?"
|
|
2
2
|
//
|
|
3
3
|
// CONTRACT. A bounded, read-only query over the evaluate WorkResult ingest already wrote. Prints
|
|
4
|
-
// `{ok, overall, bug_count, report_path}` on stdout; exits 0 when
|
|
5
|
-
//
|
|
4
|
+
// `{ok, overall, bug_count, report_path, round, status, reason}` on stdout; exits 0 when the round
|
|
5
|
+
// holds a verdict the run may act on, 1 when it does not — nothing ran, ingest hasn't landed, the
|
|
6
|
+
// evaluator refused the round, or the verdict is structurally invalid; `reason` says which — and 2
|
|
7
|
+
// on a bad argv. Writes nothing.
|
|
6
8
|
//
|
|
7
9
|
// WHY THIS EXISTS. `shapeup-run.js` cannot read a file itself (a Workflow script has no filesystem
|
|
8
10
|
// of its own — see this repo's own note on why it may not call `Date.now()`), so every fact it
|
|
@@ -23,11 +25,65 @@
|
|
|
23
25
|
// shared state; the `.md` report is prose for a human. Reading the prose to re-derive a verdict a
|
|
24
26
|
// schema already carries structurally is the paraphrase channel this repo's hooks exist to close
|
|
25
27
|
// everywhere else.
|
|
28
|
+
//
|
|
29
|
+
// WHY "NO VERDICT" CARRIES A REASON. An evaluator that refuses a round — a structural precondition
|
|
30
|
+
// it cannot meet — still writes `evaluate-r<N>.json`, with `status: failed`, no verdict, and the
|
|
31
|
+
// cause as its first deviation. A bare `ok: false` reached the operator as a sub-agent that died
|
|
32
|
+
// after retries, while the one sentence naming the actual cause sat in a file nobody was pointed at.
|
|
26
33
|
|
|
27
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
34
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
28
35
|
import { join, resolve } from "node:path";
|
|
29
36
|
import { runArgs } from "../lib/argv.mjs";
|
|
30
|
-
import { resultsDir } from "../lib/paths.mjs";
|
|
37
|
+
import { resultsDir, scopesDir } from "../lib/paths.mjs";
|
|
38
|
+
|
|
39
|
+
/** Longest `reason` reported. A deviation is prose written by a worker and can run to paragraphs. */
|
|
40
|
+
const REASON_MAX = 400;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bound a reason to {@link REASON_MAX} characters.
|
|
44
|
+
* @param {string} s - The reason.
|
|
45
|
+
* @returns {string} `s`, or its first REASON_MAX − 1 characters and an ellipsis.
|
|
46
|
+
*/
|
|
47
|
+
const clip = (s) => (s.length > REASON_MAX ? `${s.slice(0, REASON_MAX - 1)}…` : s);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a feature's spec is SCOPED — has scope contracts, the case in which every verdict must
|
|
51
|
+
* cite the T0 artifacts it re-hashed.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} cwd - Project root.
|
|
54
|
+
* @param {string} slug - Feature slug.
|
|
55
|
+
* @returns {boolean} True when `scopes/` holds at least one contract (`.md`, or a legacy `.json`).
|
|
56
|
+
*/
|
|
57
|
+
export function isScoped(cwd, slug) {
|
|
58
|
+
try { return readdirSync(scopesDir(cwd, slug)).some((f) => /\.(md|json)$/.test(f)); }
|
|
59
|
+
catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why a verdict cannot stand as its round's judgement on T0 grounds, or null when it can.
|
|
64
|
+
*
|
|
65
|
+
* A PASS or FAIL on a scoped spec that cites no T0 artifact is structurally invalid — the
|
|
66
|
+
* evaluator's own contract says so, because T0 is the machine fact a generator cannot fabricate.
|
|
67
|
+
* That rule used to live only in the contract, so a verdict citing nothing was ingested, ledgered
|
|
68
|
+
* and branched on like any other. It is checked here so the round loop, the resume derivation, the
|
|
69
|
+
* hill and ingest all refuse the same verdict for the same reason.
|
|
70
|
+
*
|
|
71
|
+
* PRESENCE, NOT HASHES. The evaluator re-hashes what it cites; a slip transcribing a digest is not
|
|
72
|
+
* evidence the verdict is wrong, and refusing a round over one would cost a whole re-evaluation.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} cwd - Project root.
|
|
75
|
+
* @param {string} slug - Feature slug.
|
|
76
|
+
* @param {object} verdict - The WorkResult's `verdict` block.
|
|
77
|
+
* @returns {(string|null)} The problem, phrased for an operator; null for a cited verdict, an
|
|
78
|
+
* unscoped spec, or a block with no PASS/FAIL in it (there is no judgement to invalidate).
|
|
79
|
+
*/
|
|
80
|
+
export function citationProblem(cwd, slug, verdict) {
|
|
81
|
+
if (verdict?.overall !== "PASS" && verdict?.overall !== "FAIL") return null;
|
|
82
|
+
if (Array.isArray(verdict.t0_citations) && verdict.t0_citations.length) return null;
|
|
83
|
+
if (!isScoped(cwd, slug)) return null;
|
|
84
|
+
return `the ${verdict.overall} verdict cites no T0 artifact, and a verdict on a scoped spec must ` +
|
|
85
|
+
"cite the T0 verdict it re-hashed (the order lists them under payload.t0_artifacts)";
|
|
86
|
+
}
|
|
31
87
|
|
|
32
88
|
/**
|
|
33
89
|
* Read one round's EVAL verdict straight from the WorkResult `reduce ingest` wrote.
|
|
@@ -35,20 +91,35 @@ import { resultsDir } from "../lib/paths.mjs";
|
|
|
35
91
|
* @param {string} cwd - Project root.
|
|
36
92
|
* @param {string} slug - Feature slug.
|
|
37
93
|
* @param {number} round - The EVAL round (`evaluate-r<N>.json`).
|
|
38
|
-
* @returns {{found: boolean, overall: (string|null),
|
|
39
|
-
* report_path: (string|null)}} `found
|
|
94
|
+
* @returns {{found: boolean, overall: (string|null), status: (string|null), reason: (string|null),
|
|
95
|
+
* bug_count: (number|null), report_path: (string|null)}} `found` is true only for a PASS/FAIL the
|
|
96
|
+
* round may act on; otherwise `reason` says why not — a fact, not a guess.
|
|
40
97
|
*/
|
|
41
98
|
export function evalVerdict(cwd, slug, round) {
|
|
42
99
|
const path = join(resultsDir(cwd, slug), `evaluate-r${round}.json`);
|
|
43
|
-
|
|
100
|
+
const unfit = (reason, status = null, overall = null) =>
|
|
101
|
+
({ found: false, overall, status, reason, bug_count: null, report_path: null });
|
|
102
|
+
if (!existsSync(path)) return unfit("no evaluate result for this round yet");
|
|
44
103
|
let doc;
|
|
45
104
|
try { doc = JSON.parse(readFileSync(path, "utf8")); }
|
|
46
|
-
catch { return
|
|
105
|
+
catch { return unfit("the evaluate result is not readable JSON"); }
|
|
106
|
+
const status = typeof doc?.status === "string" ? doc.status : null;
|
|
47
107
|
const v = doc?.verdict || {};
|
|
48
108
|
const overall = v.overall === "PASS" || v.overall === "FAIL" ? v.overall : null;
|
|
109
|
+
if (!overall) {
|
|
110
|
+
// A worker that refused to grade says why in its FIRST deviation — the only channel it has.
|
|
111
|
+
const first = Array.isArray(doc?.deviations) && typeof doc.deviations[0] === "string" ? doc.deviations[0] : "";
|
|
112
|
+
return unfit(clip(first
|
|
113
|
+
? `the evaluator returned ${status || "no status"}: ${first}`
|
|
114
|
+
: `status ${status || "unknown"} with no PASS/FAIL verdict`), status);
|
|
115
|
+
}
|
|
116
|
+
const problem = citationProblem(cwd, slug, v);
|
|
117
|
+
if (problem) return unfit(problem, status, overall);
|
|
49
118
|
return {
|
|
50
|
-
found:
|
|
119
|
+
found: true,
|
|
51
120
|
overall,
|
|
121
|
+
status,
|
|
122
|
+
reason: null,
|
|
52
123
|
bug_count: Array.isArray(v.bugs) ? v.bugs.length : null,
|
|
53
124
|
report_path: typeof v.report_path === "string" ? v.report_path : null,
|
|
54
125
|
};
|
|
@@ -66,12 +137,12 @@ export const ARGV_SPEC = {
|
|
|
66
137
|
* Report round N's EVAL verdict, mechanically, from the WorkResult on disk.
|
|
67
138
|
*
|
|
68
139
|
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
69
|
-
* @returns {void} Exits 0 when a verdict
|
|
140
|
+
* @returns {void} Exits 0 when the round holds a verdict the run may act on, 1 when it does not.
|
|
70
141
|
*/
|
|
71
142
|
export function cli(rawArgv) {
|
|
72
143
|
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
73
144
|
const cwd = resolve(args.cwd || process.cwd());
|
|
74
|
-
const { found, overall, bug_count, report_path } = evalVerdict(cwd, args.slug, args.round);
|
|
75
|
-
console.log(JSON.stringify({ ok: found, overall, bug_count, report_path, round: args.round }));
|
|
145
|
+
const { found, overall, status, reason, bug_count, report_path } = evalVerdict(cwd, args.slug, args.round);
|
|
146
|
+
console.log(JSON.stringify({ ok: found, overall, bug_count, report_path, round: args.round, status, reason }));
|
|
76
147
|
process.exit(found ? 0 : 1);
|
|
77
148
|
}
|
package/kernel/probe/resume.mjs
CHANGED
|
@@ -63,8 +63,9 @@ import { splitFrontmatter } from "../lib/contract.mjs";
|
|
|
63
63
|
import { globToRegExp } from "../verify/spec.mjs";
|
|
64
64
|
import {
|
|
65
65
|
intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
|
|
66
|
-
orientDir, activeOrder, usecasesDir,
|
|
66
|
+
orientDir, activeOrder, usecasesDir, breadboard, receipt, readReceipt,
|
|
67
67
|
} from "../lib/paths.mjs";
|
|
68
|
+
import { evalVerdict } from "./eval.mjs";
|
|
68
69
|
|
|
69
70
|
/** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
|
|
70
71
|
* not a write — the whole point of this file is that a write nobody validates is a write nobody
|
|
@@ -377,6 +378,12 @@ export function deriveResumeState(cwd, slug) {
|
|
|
377
378
|
|
|
378
379
|
const facts = {
|
|
379
380
|
intake_path: intake(cwd, slug),
|
|
381
|
+
// The pitch's other half, staged by `init run` beside the intake. Null when the pitch had no
|
|
382
|
+
// separate breadboard — the planning dispatches then carry no `breadboard` key at all.
|
|
383
|
+
breadboard_path: existsSync(breadboard(cwd, slug)) ? breadboard(cwd, slug) : null,
|
|
384
|
+
// How it was found (flag | sibling | shaping-dir | shared-root | embedded), from the receipt;
|
|
385
|
+
// null when there was none or the receipt cannot be read.
|
|
386
|
+
breadboard_source: readReceipt(receipt(cwd, slug))?.breadboard?.source ?? null,
|
|
380
387
|
spec_folder: hr.spec_folder || null,
|
|
381
388
|
status: hr.status || null,
|
|
382
389
|
lens: hr.lens || null,
|
|
@@ -405,9 +412,15 @@ export function deriveResumeState(cwd, slug) {
|
|
|
405
412
|
// that permits the overlap is the same one that makes it invisible to the disjointness lint.
|
|
406
413
|
scope_exclusions: scopeExclusions(cwd, slug, scope_files),
|
|
407
414
|
pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
|
|
415
|
+
// A round is DONE when it was graded, not when its result file exists. An evaluator that
|
|
416
|
+
// refused the round — no PASS/FAIL, or a scoped verdict citing no T0 artifact — still writes
|
|
417
|
+
// `evaluate-r<N>.json`; counted, the relaunch opened round N+1 over a round nobody judged, with
|
|
418
|
+
// no bugs to route, and rebuilt every scope. Left open, it re-enters round N, skips the scopes
|
|
419
|
+
// already green there, and evaluates again.
|
|
408
420
|
eval_rounds_done: resultFiles
|
|
409
421
|
.filter((f) => /^evaluate-r\d+\.json$/.test(f))
|
|
410
|
-
.map((f) => Number(f.match(/\d+/)[0]))
|
|
422
|
+
.map((f) => Number(f.match(/\d+/)[0]))
|
|
423
|
+
.filter((n) => evalVerdict(cwd, slug, n).found),
|
|
411
424
|
};
|
|
412
425
|
return { ...facts, next_phase: nextPhase(facts) };
|
|
413
426
|
}
|
package/kernel/probe/t0.mjs
CHANGED
|
@@ -18,13 +18,36 @@ import { join, resolve } from "node:path";
|
|
|
18
18
|
import { runArgs } from "../lib/argv.mjs";
|
|
19
19
|
import { verdictsDir } from "../lib/paths.mjs";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Verdict filenames, newest first by their NUMERIC address.
|
|
23
|
+
*
|
|
24
|
+
* A string sort files `r1-a1-t10.json` before `r1-a1-t9.json`, and the trial ordinal is shared by
|
|
25
|
+
* every scope verified at one (round, attempt) — ten scopes on their first attempt are enough to
|
|
26
|
+
* make an older verdict read as the newest. Names that carry no address sort last.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} names - Filenames from the verdicts directory.
|
|
29
|
+
* @returns {string[]} A new array, newest first: round, then attempt, then trial, descending.
|
|
30
|
+
*/
|
|
31
|
+
export function newestFirst(names) {
|
|
32
|
+
const key = (f) => {
|
|
33
|
+
const m = f.match(/^r(\d+)-a(\d+)(?:-t(\d+))?\.json$/);
|
|
34
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : [-1, -1, -1];
|
|
35
|
+
};
|
|
36
|
+
return [...names].sort((a, b) => {
|
|
37
|
+
const ka = key(a), kb = key(b);
|
|
38
|
+
for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i];
|
|
39
|
+
return b.localeCompare(a);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
/**
|
|
22
44
|
* The newest green T0 verdict for one scope in one round.
|
|
23
45
|
*
|
|
24
46
|
* @param {string} cwd - Project root.
|
|
25
47
|
* @param {string} slug - Feature slug.
|
|
26
48
|
* @param {string} scopeId - Scope contract id.
|
|
27
|
-
* @param {number} round - Build round.
|
|
49
|
+
* @param {number} [round] - Build round. Omitted, the newest green verdict of ANY round — what an
|
|
50
|
+
* evaluation with no round (a standalone single pass) has to cite.
|
|
28
51
|
* @returns {{green: boolean, path: (string|null)}} `path` is the artifact a later EVAL can cite.
|
|
29
52
|
*/
|
|
30
53
|
export function greenVerdict(cwd, slug, scopeId, round) {
|
|
@@ -32,11 +55,11 @@ export function greenVerdict(cwd, slug, scopeId, round) {
|
|
|
32
55
|
if (!existsSync(dir)) return { green: false, path: null };
|
|
33
56
|
// Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
|
|
34
57
|
// (round, attempt) address, and the LAST verdict is the one that stands.
|
|
35
|
-
for (const f of readdirSync(dir).filter((x) => x.endsWith(".json"))
|
|
58
|
+
for (const f of newestFirst(readdirSync(dir).filter((x) => x.endsWith(".json")))) {
|
|
36
59
|
const p = join(dir, f);
|
|
37
60
|
try {
|
|
38
61
|
const b = JSON.parse(readFileSync(p, "utf8"));
|
|
39
|
-
if (b.scope_id === scopeId && b.round === round && b.overall === "green") return { green: true, path: p };
|
|
62
|
+
if (b.scope_id === scopeId && (round == null || b.round === round) && b.overall === "green") return { green: true, path: p };
|
|
40
63
|
} catch { /* a torn artifact proves nothing; keep looking */ }
|
|
41
64
|
}
|
|
42
65
|
return { green: false, path: null };
|
package/kernel/reduce/ingest.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
|
|
|
28
28
|
import { validate } from "../verify/envelope.mjs";
|
|
29
29
|
import { runArgs } from "../lib/argv.mjs";
|
|
30
30
|
import { tasksDir, localRoot, dispatchReceipts, legLedger, readRunId } from "../lib/paths.mjs";
|
|
31
|
+
import { citationProblem } from "../probe/eval.mjs";
|
|
31
32
|
|
|
32
33
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
33
34
|
const RESULT_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../../skills/tech-lead/schemas/work-result.schema.json"), "utf8"));
|
|
@@ -596,6 +597,20 @@ export async function cli(rawArgv) {
|
|
|
596
597
|
process.exit(1);
|
|
597
598
|
}
|
|
598
599
|
|
|
600
|
+
// --- T0 citation gate -----------------------------------------------------------------------
|
|
601
|
+
// A PASS or FAIL on a scoped spec that cites no T0 artifact is not a judgement this run may act
|
|
602
|
+
// on (see `citationProblem`). `probe eval` refuses it to the round loop; refusing it here as well
|
|
603
|
+
// keeps the verdict ledger from recording a verdict the loop will never branch on.
|
|
604
|
+
if (result.verdict) {
|
|
605
|
+
const problem = citationProblem(cwd, String(result.order_id).split("/")[0], result.verdict);
|
|
606
|
+
if (problem) {
|
|
607
|
+
console.error(`ingest-result: result refused — ${problem}.`);
|
|
608
|
+
console.error(` The round stays open: re-dispatch the evaluator against its order, which lists`);
|
|
609
|
+
console.error(` the T0 artifacts to cite. Nothing was written.`);
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
599
614
|
// Resolved for EVERY order, not only the gated ones: the attesting receipt is this leg's start,
|
|
600
615
|
// and a standalone or `--no-receipt-check` ingest still deserves a truthful timing row rather
|
|
601
616
|
// than one silently falling back to the order's re-writable `compiled_at`.
|
package/kernel/verify/spec.mjs
CHANGED
|
@@ -42,6 +42,16 @@
|
|
|
42
42
|
// Edge-cases heading with real content under it) but no usecases/UC-*.md declares
|
|
43
43
|
// a single [INV-NN] anywhere — a criteria-count check can't tell a healthy small
|
|
44
44
|
// tree from one that silently derived nothing from the pitch
|
|
45
|
+
// BREADBOARD-PLACE (red) a breadboard Place that owns UI affordances has no ux-behavior.md
|
|
46
|
+
// `## Screen: … (P#)` section and is not under `## Deferred Places`
|
|
47
|
+
// BREADBOARD-UI (red) a UI affordance (U#) not cited inside the screen section of any Place the
|
|
48
|
+
// breadboard puts it in. CITING IS NOT PLACING: a U# specified under another Place's
|
|
49
|
+
// screen passes a presence check and is exactly the defect this rule exists for
|
|
50
|
+
// BREADBOARD-TRACE (warn) N#/S# cited nowhere in the spec, V# slices no scope board records,
|
|
51
|
+
// U# the spec places that no manifest entry names as its `source`
|
|
52
|
+
// BREADBOARD-UNPARSED (warn) a staged breadboard this reader finds no ids in — a layout it
|
|
53
|
+
// cannot read must never become a hard stop
|
|
54
|
+
// All four are silent when the run has no breadboard (staged, or inline in the intake).
|
|
45
55
|
//
|
|
46
56
|
// Zero dependencies (glob matcher inlined from hooks/sandbox-guard.mjs). Judgment stays in the skill
|
|
47
57
|
// (gap severity, lens choice); this script only reports facts.
|
|
@@ -56,6 +66,8 @@ import { runArgs } from "../lib/argv.mjs";
|
|
|
56
66
|
import { LOCAL } from "../lib/paths.mjs";
|
|
57
67
|
import { specDir, scopesDir, tasksDir, intake, sharedRoot, requirements } from "../lib/paths.mjs";
|
|
58
68
|
import { readAllContracts, unreadableReason, ucId, scopePartitionConflicts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
69
|
+
import { breadboard as stagedBreadboard } from "../lib/paths.mjs";
|
|
70
|
+
import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
|
|
59
71
|
|
|
60
72
|
// Inlined from hooks/sandbox-guard.mjs so this skill ships self-contained (a skill's scripts
|
|
61
73
|
// must not reach outside its own folder — channels that copy only skills/ would dangle).
|
|
@@ -494,12 +506,169 @@ export function lintStructure({ specDir, tasks, intakeContent = "" }) {
|
|
|
494
506
|
return findings;
|
|
495
507
|
}
|
|
496
508
|
|
|
509
|
+
/** Every Place id inside a heading's parentheses — `## Screen: Sheet (P2)`, `(P1, P3)`. */
|
|
510
|
+
const PLACE_IN_PARENS = /\(([^)]*)\)/g;
|
|
511
|
+
const PLACE_ID = /\bP\d+(?:\.\d+)*\b/g;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Cut ux-behavior.md into the screen sections a breadboard's Places are checked against.
|
|
515
|
+
*
|
|
516
|
+
* A section runs from its `Screen:` heading to the next heading at the same level or higher, so
|
|
517
|
+
* a screen's `### States` table and behavior rules belong to it. Its Places are the P# ids in the
|
|
518
|
+
* heading's parentheses; a screen with none is kept (its citations are still "somewhere") but can
|
|
519
|
+
* place nothing.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} uxText - ux-behavior.md, verbatim ("" when absent).
|
|
522
|
+
* @returns {{screens: {heading: string, places: string[], body: string}[], deferred: Set<string>}}
|
|
523
|
+
* The screen sections, and the Place ids listed first-cell under `## Deferred Places`.
|
|
524
|
+
*/
|
|
525
|
+
export function uxScreens(uxText) {
|
|
526
|
+
const lines = String(uxText ?? "").split(/\r?\n/);
|
|
527
|
+
const screens = [];
|
|
528
|
+
const deferred = new Set();
|
|
529
|
+
let cur = null; // { level, heading, places, body[] } for a screen, or { level, deferred: true }
|
|
530
|
+
for (const line of lines) {
|
|
531
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
532
|
+
if (h) {
|
|
533
|
+
const level = h[1].length;
|
|
534
|
+
if (cur && level <= cur.level) cur = null;
|
|
535
|
+
if (!cur) {
|
|
536
|
+
const text = h[2].trim();
|
|
537
|
+
if (/^screen\s*:/i.test(text)) {
|
|
538
|
+
const places = [...text.matchAll(PLACE_IN_PARENS)].flatMap((m) => m[1].match(PLACE_ID) ?? []);
|
|
539
|
+
cur = { level, heading: text, places, body: [] };
|
|
540
|
+
screens.push(cur);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (/^deferred places\b/i.test(text)) { cur = { level, deferred: true }; continue; }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!cur) continue;
|
|
547
|
+
if (cur.deferred) {
|
|
548
|
+
const row = line.match(/^\s*\|\s*([^|]*)\|/);
|
|
549
|
+
const id = row ? row[1].replace(/[*`\[\]]/g, "").match(/^\s*(P\d+(?:\.\d+)*)\b/) : null;
|
|
550
|
+
if (id) deferred.add(id[1]);
|
|
551
|
+
} else cur.body.push(line);
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
screens: screens.map((s) => ({ heading: s.heading, places: s.places, body: s.body.join("\n") })),
|
|
555
|
+
deferred,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Lint the spec against the pitch's breadboard: every Place with UI affordances has a screen, and
|
|
561
|
+
* every UI affordance is specified on a screen of a Place the breadboard puts it in.
|
|
562
|
+
*
|
|
563
|
+
* PLACEMENT, NOT CITATION. The loss this exists for did not drop a new sheet's affordances — they
|
|
564
|
+
* were all in the spec, inside the composer's state table. What was lost was the Place. A rule that
|
|
565
|
+
* only asks "is U2 cited?" passes that spec; this one asks "is U2 cited under P2?". It checks WHICH
|
|
566
|
+
* screen, never where on the screen: layout inside a Place stays the designer's.
|
|
567
|
+
*
|
|
568
|
+
* Absent breadboard ⇒ zero findings, the same "absent artifact ⇒ arm skipped" rule INV-FLOOR and
|
|
569
|
+
* SCOPE-COVERS follow — every pre-breadboard spec and every run without one is untouched.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} input - What to lint (destructured).
|
|
572
|
+
* @param {string} input.uxText - ux-behavior.md, verbatim ("" when absent).
|
|
573
|
+
* @param {string} input.specText - Every markdown file under the spec tree, concatenated.
|
|
574
|
+
* @param {Array<object>} [input.scopes] - Parsed scope contracts ([] before MAP SCOPES).
|
|
575
|
+
* @param {(string|null)} [input.scopeSummaryText] - scope-summary.md, or null when absent.
|
|
576
|
+
* @param {(string|null)} [input.scopeBoardText] - scope-board.md, or null when absent — the scope
|
|
577
|
+
* architect's own write surface, where it records which scopes deliver each slice.
|
|
578
|
+
* @param {(string|null)} input.bbText - The breadboard, or null when the run has none.
|
|
579
|
+
* @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean or
|
|
580
|
+
* when there is no breadboard.
|
|
581
|
+
*/
|
|
582
|
+
export function lintBreadboard({ uxText = "", specText = "", scopes = [], scopeSummaryText = null, scopeBoardText = null, bbText = null }) {
|
|
583
|
+
if (!bbText) return [];
|
|
584
|
+
const findings = [];
|
|
585
|
+
const bb = parseBreadboard(bbText);
|
|
586
|
+
const counts = idCounts(bb);
|
|
587
|
+
if (Object.values(counts).every((n) => n === 0)) {
|
|
588
|
+
findings.push({ rule: "BREADBOARD-UNPARSED", level: "warn", detail: "the run has a breadboard but no P#/U#/N#/S#/V# ids could be read from its tables — placement was not checked. A `#` or `ID` first column holding the id, and a `Place` column on affordance rows, is the layout this reads" });
|
|
589
|
+
return findings;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const { screens, deferred } = uxScreens(uxText);
|
|
593
|
+
/**
|
|
594
|
+
* A Place as a finding names it — id and breadboard name.
|
|
595
|
+
* @param {string} p - Place id.
|
|
596
|
+
* @returns {string} e.g. `P2 Payment Sheet`.
|
|
597
|
+
*/
|
|
598
|
+
const name = (p) => {
|
|
599
|
+
const n = bb.places.find((x) => x.id === p)?.name;
|
|
600
|
+
return n ? `${p} ${n}` : p;
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* Does the text cite this exact id — `U2` but not `U21` or `U2a`?
|
|
604
|
+
* @param {string} text - Markdown to search.
|
|
605
|
+
* @param {string} id - A breadboard id.
|
|
606
|
+
* @returns {boolean} True when the id appears as a whole word.
|
|
607
|
+
*/
|
|
608
|
+
const cites = (text, id) => new RegExp(`\\b${id.replace(/\./g, "\\.")}\\b`).test(text);
|
|
609
|
+
|
|
610
|
+
// BREADBOARD-PLACE — a Place with something to place needs a screen of its own.
|
|
611
|
+
const owners = new Map(); // Place → the U# it owns
|
|
612
|
+
for (const u of bb.ui) for (const p of u.places) (owners.get(p) ?? owners.set(p, []).get(p)).push(u.id);
|
|
613
|
+
for (const [p, us] of owners) {
|
|
614
|
+
if (deferred.has(p)) continue;
|
|
615
|
+
if (screens.some((s) => s.places.includes(p))) continue;
|
|
616
|
+
findings.push({ rule: "BREADBOARD-PLACE", level: "red", detail: `${name(p)} owns ${us.join(", ")} but ux-behavior.md has no "## Screen: … (${p})" section — add the screen or defer the Place under "## Deferred Places"; never fold it into another screen` });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// BREADBOARD-UI — each U# is specified on a screen of a Place the breadboard puts it in.
|
|
620
|
+
const unplaceable = [];
|
|
621
|
+
for (const u of bb.ui) {
|
|
622
|
+
if (!u.places.length) { unplaceable.push(u.id); continue; }
|
|
623
|
+
const live = u.places.filter((p) => !deferred.has(p));
|
|
624
|
+
if (!live.length) continue;
|
|
625
|
+
if (screens.some((s) => s.places.some((p) => live.includes(p)) && cites(s.body, u.id))) continue;
|
|
626
|
+
const elsewhere = [...new Set(screens.filter((s) => cites(s.body, u.id)).flatMap((s) => s.places.length ? s.places : [`"${s.heading}"`]))];
|
|
627
|
+
const where = elsewhere.length
|
|
628
|
+
? `is cited only under ${elsewhere.join(", ")}`
|
|
629
|
+
: cites(uxText, u.id) ? "is cited in ux-behavior.md but on no screen" : "is cited on no screen";
|
|
630
|
+
findings.push({ rule: "BREADBOARD-UI", level: "red", detail: `${u.id} (${live.map(name).join(" / ")}) ${where} — specify it in the screen section of its own Place` });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// BREADBOARD-TRACE — the rest of the breadboard, reported and never blocking.
|
|
634
|
+
const trace = [];
|
|
635
|
+
const lost = [...bb.code, ...bb.stores].map((x) => x.id).filter((id) => !cites(specText, id));
|
|
636
|
+
if (lost.length) trace.push(`N#/S# cited nowhere in the spec: ${lost.join(", ")}`);
|
|
637
|
+
const slicesText = [scopeSummaryText, scopeBoardText].filter((t) => t !== null && t !== undefined).join("\n");
|
|
638
|
+
if (scopeSummaryText !== null || scopeBoardText !== null) {
|
|
639
|
+
const unsliced = bb.slices.map((v) => v.id).filter((id) => !cites(slicesText, id));
|
|
640
|
+
if (unsliced.length) trace.push(`V# slices no scope board or scope summary records: ${unsliced.join(", ")}`);
|
|
641
|
+
}
|
|
642
|
+
if (scopes.length) {
|
|
643
|
+
const sourced = new Set(scopes.flatMap((s) => (s.affordance_manifest ?? []).map((a) => String(a?.source ?? "").trim())));
|
|
644
|
+
const unsourced = bb.ui.map((u) => u.id).filter((id) => cites(uxText, id) && !sourced.has(id));
|
|
645
|
+
if (unsourced.length) trace.push(`U# the spec places but no manifest entry names as its source: ${unsourced.join(", ")}`);
|
|
646
|
+
}
|
|
647
|
+
if (unplaceable.length) trace.push(`U# with no Place column to check placement against: ${unplaceable.join(", ")}`);
|
|
648
|
+
if (trace.length) findings.push({ rule: "BREADBOARD-TRACE", level: "warn", detail: trace.join("; ") });
|
|
649
|
+
return findings;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The breadboard a run's spec is linted against: the staged copy, else the intake when it carries
|
|
654
|
+
* one inline, else null — and null switches every BREADBOARD-* rule off.
|
|
655
|
+
* @param {string} cwd - Project root.
|
|
656
|
+
* @param {string} slug - Feature slug.
|
|
657
|
+
* @param {string} intakeContent - The run's intake, verbatim ("" when absent).
|
|
658
|
+
* @returns {(string|null)} The breadboard text, or null when the run has none.
|
|
659
|
+
*/
|
|
660
|
+
export function runBreadboard(cwd, slug, intakeContent) {
|
|
661
|
+
const p = stagedBreadboard(cwd, slug);
|
|
662
|
+
if (existsSync(p)) return readFileSync(p, "utf8");
|
|
663
|
+
return hasBreadboardTables(intakeContent) ? intakeContent : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
497
666
|
/**
|
|
498
667
|
* Run the full spec lint (scopes + structure) for a slug.
|
|
499
668
|
* @param {{cwd:string, slug:string}} opts - Working root and feature slug.
|
|
500
669
|
* @returns {{slug:string, scopes:number, tasks:number, red:number, warn:number,
|
|
501
|
-
* findings:Array<object>}} Counts and the combined findings from {@link lintScopes}
|
|
502
|
-
* {@link lintStructure}.
|
|
670
|
+
* findings:Array<object>}} Counts and the combined findings from {@link lintScopes},
|
|
671
|
+
* {@link lintStructure} and, when the run has a breadboard, {@link lintBreadboard}.
|
|
503
672
|
*/
|
|
504
673
|
export function lint({ cwd, slug }) {
|
|
505
674
|
const specRoot = specDir(cwd, slug);
|
|
@@ -525,6 +694,25 @@ export function lint({ cwd, slug }) {
|
|
|
525
694
|
...lintScopeAnchors({ scopes, specDir: specRoot, reqIds, tasks }),
|
|
526
695
|
...lintCommittedTier({ cwd, slug }),
|
|
527
696
|
...lintStructure({ specDir: specRoot, tasks, intakeContent }),
|
|
697
|
+
...(() => {
|
|
698
|
+
const bbText = runBreadboard(cwd, slug, intakeContent);
|
|
699
|
+
if (!bbText) return [];
|
|
700
|
+
/**
|
|
701
|
+
* A file's text, or null when it does not exist.
|
|
702
|
+
* @param {string} p - Absolute path.
|
|
703
|
+
* @returns {(string|null)} The contents, or null.
|
|
704
|
+
*/
|
|
705
|
+
const readOr = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
|
|
706
|
+
const specFiles = existsSync(specRoot) ? walkFiles(specRoot).filter((f) => f.endsWith(".md")) : [];
|
|
707
|
+
return lintBreadboard({
|
|
708
|
+
uxText: readOr(join(specRoot, "ux-behavior.md")) ?? "",
|
|
709
|
+
specText: specFiles.map((f) => readFileSync(join(specRoot, f), "utf8")).join("\n"),
|
|
710
|
+
scopes,
|
|
711
|
+
scopeSummaryText: readOr(join(specRoot, "scope-summary.md")),
|
|
712
|
+
scopeBoardText: readOr(join(sharedRoot(cwd, slug), "scope-board.md")),
|
|
713
|
+
bbText,
|
|
714
|
+
});
|
|
715
|
+
})(),
|
|
528
716
|
];
|
|
529
717
|
return {
|
|
530
718
|
slug,
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ Invoked as `--order <path>`. Fields you may rely on (absent = unknown; surface i
|
|
|
26
26
|
|---|---|
|
|
27
27
|
| `operation` | `analyze` (pitch → full spec tree + board) · `reconcile` (fold discovered-ledger items into the board + UC invariants) · `retrofit-surface` (append `## Test Surface` to a pre-surface spec) · `coverage` (extract atomic requirement clauses → the SHARED `requirements.md` registry) |
|
|
28
28
|
| `payload.pitch` | The pitch/PRD path (analyze) |
|
|
29
|
+
| `payload.breadboard` | The breadboard (analyze): its Places are your screens; its U# and N# are the affordances you place and cite. Absent = none separate; never inferred |
|
|
29
30
|
| `payload.requirements` | (coverage) the REQ source to extract atomic clauses from — pitch / a customer-requirements doc / the use-case bodies. Absent → default to the pitch and record the choice in `assumptions[]` |
|
|
30
31
|
| `payload.lens` | `lite` \| `standard` \| `cross-context`. Absent → judge it: LITE for ≤2-week appetite, no third-party, ≤3 user-facing actions; STANDARD for multi-team, third-party, or bigger appetite; genuinely unclear → one binary question, or `status: "escalated"` with the question in `deviations[]` |
|
|
31
32
|
| `payload.orient_dir` | The Scout's artifacts — `code-surface.md` IS your codebase map (do not re-scan), `discovered-seed.md` seeds task gen, `spike-*.md` feeds feasibility |
|
|
@@ -43,8 +44,11 @@ Phases, each with a checkpoint (pause only per `interaction`). Read the referenc
|
|
|
43
44
|
its phase; templates live in `assets/templates/`.
|
|
44
45
|
|
|
45
46
|
```
|
|
46
|
-
1 INGEST pitch +
|
|
47
|
-
|
|
47
|
+
1 INGEST pitch + breadboard (`payload.breadboard`, or tables inline in the pitch) +
|
|
48
|
+
orient artifacts + KB. Extract slug, appetite, in/out boundaries, rabbit
|
|
49
|
+
holes, third-party mentions. With a breadboard, list every Place (P#) and UI
|
|
50
|
+
affordance (U#) first — they are the screens and interactive elements Phase 3
|
|
51
|
+
must place. No files written yet.
|
|
48
52
|
1b FEASIBILITY (third-party/API/SDK/webhook mentioned) verification questions + fallback
|
|
49
53
|
scope per API-NN → api-feasibility.md
|
|
50
54
|
2 DDD bounded contexts, aggregates (new vs extended), value objects, domain events,
|
|
@@ -52,8 +56,9 @@ its phase; templates live in `assets/templates/`.
|
|
|
52
56
|
2b CONTRACTS (standard lens) typed Request/Response/Error per repository; two-pass rule:
|
|
53
57
|
unresolvable at spec time → `⏳ TBD — verify in the [UC-x] spike`, resolved
|
|
54
58
|
post-SPIKE with citation → contracts/ [references/contract-patterns.md]
|
|
55
|
-
3 UX per screen
|
|
56
|
-
|
|
59
|
+
3 UX per screen — with a breadboard, one screen per Place that owns UI affordances:
|
|
60
|
+
state table (idle→loading→error→success), error cases with message+action,
|
|
61
|
+
ASCII flows → ux-behavior.md [references/ux-behavior-patterns.md]
|
|
57
62
|
4 USE CASES one file per actor+action: typed Input/Output, numbered Steps, all error
|
|
58
63
|
cases with codes, ## System Flow (UI→API→UC→Repo→DB), ## Test Surface
|
|
59
64
|
(DERIVED ONLY from D1 Invariants · D2 Error Cases · D3 Contract shape ·
|
|
@@ -69,7 +74,8 @@ its phase; templates live in `assets/templates/`.
|
|
|
69
74
|
overflow is a fact you REPORT for the caller's HAMMER gate, never resolve)
|
|
70
75
|
node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify spec --slug <slug>
|
|
71
76
|
(structure, wikilinks, edge symmetry — fix reds, then re-run; you never
|
|
72
|
-
self-grade with a hand-walked checklist
|
|
77
|
+
self-grade with a hand-walked checklist. BREADBOARD-PLACE / BREADBOARD-UI:
|
|
78
|
+
add the screen or defer the Place; never fold it into another screen)
|
|
73
79
|
→ scope-summary.md + synthesis.md (traceability matrix, risk register,
|
|
74
80
|
dependency graph — the JUDGMENT layers over board-derive's numbers)
|
|
75
81
|
8 INDEX _index.md (pitch digest + document map) + feedback.md template
|
|
@@ -29,7 +29,8 @@ audit_rules_version: "2.5"
|
|
|
29
29
|
## Solution Elements
|
|
30
30
|
|
|
31
31
|
### Breadboarding
|
|
32
|
-
<!-- Text-based flow showing the key interaction path, no images needed
|
|
32
|
+
<!-- Text-based flow showing the key interaction path, no images needed. With a breadboard,
|
|
33
|
+
name Places and affordances by id: P1 Cart ──U1──► P2 Payment Sheet -->
|
|
33
34
|
```
|
|
34
35
|
[Screen A] ──action──► [Screen B] ──action──► [Outcome]
|
|
35
36
|
│
|
|
@@ -29,7 +29,7 @@ status: draft
|
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
-
## Screen: [ScreenName]
|
|
32
|
+
## Screen: [ScreenName] ([P#] — omit without a breadboard)
|
|
33
33
|
|
|
34
34
|
### States
|
|
35
35
|
|
|
@@ -54,7 +54,7 @@ status: draft
|
|
|
54
54
|
|
|
55
55
|
---
|
|
56
56
|
|
|
57
|
-
<!-- Repeat "Screen: [Name]" section for each screen -->
|
|
57
|
+
<!-- Repeat "Screen: [Name]" section for each screen — one per breadboard Place with UI affordances -->
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
@@ -63,3 +63,13 @@ status: draft
|
|
|
63
63
|
| Behavior | Mobile | Web |
|
|
64
64
|
|---|---|---|
|
|
65
65
|
| [behavior] | [mobile treatment] | [web treatment] |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Deferred Places
|
|
70
|
+
|
|
71
|
+
<!-- Breadboard Places with UI affordances this shape will not build. Each needs the PO's yes at GATE L1b. Omit the section when there are none. -->
|
|
72
|
+
|
|
73
|
+
| Place | Reason |
|
|
74
|
+
|---|---|
|
|
75
|
+
| [P#] [Place name] | [why this shape does not build it] |
|
|
@@ -126,6 +126,9 @@ Required sections: Screen Flow (ASCII diagram), one section per Screen with:
|
|
|
126
126
|
- **Visual & Layout Specs**: Flex/Grid structure, spacing, alignment rules, and desktop/mobile responsiveness
|
|
127
127
|
- **Design Tokens**: Specific CSS variables or Tailwind classes used for background, borders, fonts, and actions
|
|
128
128
|
- **States table**, **Behavior Rules list**, **Error States table**
|
|
129
|
+
- Screen headings carry the breadboard Place id when a breadboard exists — `## Screen: Payment Sheet (P2)`, one screen per Place that owns UI affordances, each U# cited inside its own Place's section
|
|
130
|
+
|
|
131
|
+
Plus **Deferred Places**, when any: a `Place | Reason` table of breadboard Places this shape will not build.
|
|
129
132
|
|
|
130
133
|
|
|
131
134
|
---
|
|
@@ -15,6 +15,15 @@ From the pitch breadboarding and fat marker sketches, identify:
|
|
|
15
15
|
|
|
16
16
|
Each decision point is typically a screen boundary.
|
|
17
17
|
|
|
18
|
+
> **With a breadboard, the screens are its Places.** Write one `## Screen:` section per Place that
|
|
19
|
+
> owns at least one UI affordance, with the Place id in the heading — `## Screen: Payment Sheet (P2)`.
|
|
20
|
+
> Cite each UI affordance's id (`U3`) in the state-table row or behavior rule that specifies it,
|
|
21
|
+
> inside the section of the Place the breadboard puts it in. A Place is a screen boundary: a sheet or
|
|
22
|
+
> modal the breadboard names as its own Place is never folded into its parent's state table, even
|
|
23
|
+
> when the pitch's prose describes it as part of the parent. A Place with no UI affordances (a
|
|
24
|
+
> backend, a store) needs no screen. A Place this shape will not build goes in `## Deferred Places`
|
|
25
|
+
> with the reason; it surfaces at GATE L1b, where the PO accepts or rejects the deferral.
|
|
26
|
+
|
|
18
27
|
---
|
|
19
28
|
|
|
20
29
|
## State Machine per Screen
|