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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapeup-sdlc-plugin",
|
|
3
3
|
"displayName": "ShapeUp SDLC Plugin",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.2.0",
|
|
5
5
|
"description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Liberty Nguyen",
|
package/AGENTS.md
CHANGED
|
@@ -18,7 +18,7 @@ Skills and commands are named short throughout this file; every one of them reso
|
|
|
18
18
|
1. Set Boundaries → `/shapeup shaping`
|
|
19
19
|
2. Find the Elements → `/shapeup breadboarding`
|
|
20
20
|
3. Risks & Rabbit Holes → `/shapeup spike`
|
|
21
|
-
(The completed pitch is formed by `shaping.md` + `breadboard.md`)
|
|
21
|
+
(The completed pitch is formed by `shaping.md` + `breadboard.md`. A run takes both: `/ship` finds the breadboard beside the pitch or in `shaping/`, takes one named with `--breadboard`, or reads it inline in a single pitch file, and hands it to every planning worker. Each breadboard Place with UI affordances becomes its own screen in the spec — spec-lint stops the run at L1b when one is missing or folded into another screen — and a Place the shape will not build is deferred there, with the PO's yes.)
|
|
22
22
|
|
|
23
23
|
### Phase 2 — Betting (PO governance, no skill)
|
|
24
24
|
Betting Table: PO decides; rejected pitches loop back to raw idea.
|
|
@@ -58,7 +58,7 @@ Everything discovered funnels into `.shapeup/<slug>/discovery/ledger.md` (Orient
|
|
|
58
58
|
- **Ledger = single source of truth** — every discovery flow writes only its own section.
|
|
59
59
|
- **QA is a level-up, not a gate** — `--no-qa` skips it; circuit breaker outranks the Hunter.
|
|
60
60
|
- **Role separation** — Evaluator grades, task-executor fixes, QA discovers.
|
|
61
|
-
- **Hill phase is mechanical ✦** — derived only from T0/T1/seesaw artifacts, never self-reported; the evaluator cites a T0 artifact it re-hashes itself.
|
|
61
|
+
- **Hill phase is mechanical ✦** — derived only from T0/T1/seesaw artifacts, never self-reported; the evaluator cites a T0 artifact it re-hashes itself, from the list its order carries. A scoped verdict citing none is refused: its round stays open and is evaluated again, never advanced.
|
|
62
62
|
- **Envelope port (v1.0)** — every dispatch is WorkOrder in / WorkResult out; shared state has exactly one writer (the ingest step); malformed envelopes are hook-denied. Workers: stateless, craft-only, pipeline-blind.
|
|
63
63
|
|
|
64
64
|
## Setup & Execution
|
package/commands/ship.md
CHANGED
|
@@ -71,6 +71,11 @@ Only run headless/auto if the user explicitly asks for it in their message:
|
|
|
71
71
|
tiny, it will say so and recommend the full lane.
|
|
72
72
|
|
|
73
73
|
Additional flags, pass through to `tech-lead` only when the user names them:
|
|
74
|
+
- `--breadboard <path>` → the pitch's breadboard, when it is not beside the pitch. A pitch shaped
|
|
75
|
+
with `/shapeup` is `shaping.md` + `breadboard.md`; the run finds a `breadboard.md` in the same
|
|
76
|
+
folder (or in `shaping/`) on its own, and reads one written inline in a single `pitch.md`. Name
|
|
77
|
+
it only when it lives somewhere else. Its Places become the spec's screens, and spec-lint stops
|
|
78
|
+
the run at GATE L1b when one is missing or folded into another screen.
|
|
74
79
|
- `--gate-answers <ci|guarded|interactive|path.json>` → the pre-recorded PO decisions this run
|
|
75
80
|
crosses its gates with. Gates still emit their blocks and still record a decision; the
|
|
76
81
|
decision's **source** becomes the answer set instead of a live human, and the ledger says so.
|
package/hooks/gate-intake.mjs
CHANGED
|
@@ -73,7 +73,7 @@ const hasResume = /--from\s+\S/.test(args); // resuming an existing run has its
|
|
|
73
73
|
// the spec.
|
|
74
74
|
// Adding a valued flag anywhere in the harness means adding it here, and structural test §39
|
|
75
75
|
// enforces exactly that against commands/ship.md.
|
|
76
|
-
const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|parallel-scopes|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order)\s+\S+/g;
|
|
76
|
+
const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|parallel-scopes|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order|breadboard)\s+\S+/g;
|
|
77
77
|
const BARE_FLAGS = /--[a-z0-9-]+/g;
|
|
78
78
|
const freeText = args.replace(VALUED_FLAGS, " ").replace(BARE_FLAGS, " ").trim();
|
|
79
79
|
|
package/kernel/compile.mjs
CHANGED
|
@@ -36,10 +36,11 @@ import { readRunId } from "./lib/paths.mjs";
|
|
|
36
36
|
// --spec-overridden directory, and the import is the convention-derived default.
|
|
37
37
|
import {
|
|
38
38
|
tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
|
|
39
|
-
relShared, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
|
|
39
|
+
relShared, relLocal, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
|
|
40
40
|
} from "./lib/paths.mjs";
|
|
41
|
-
import { readContract, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
|
|
41
|
+
import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
|
|
42
42
|
import { writeActiveOrder } from "./probe/resume.mjs";
|
|
43
|
+
import { greenVerdict } from "./probe/t0.mjs";
|
|
43
44
|
// The SAME matcher the sandbox hook enforces with. "Is this cited file inside this scope's
|
|
44
45
|
// substrate" has to mean exactly what the guard means, or a bug is addressed to a scope that is
|
|
45
46
|
// then denied the write that fixes it.
|
|
@@ -482,6 +483,43 @@ export function scopeSubstrates(cwd, slug) {
|
|
|
482
483
|
return out;
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
// --- the T0 artifacts the judge must cite ----------------------------------------------------
|
|
487
|
+
//
|
|
488
|
+
// WHY THE KERNEL DERIVES THEM. spec-evaluator treats a scoped spec whose order lists no T0 artifact
|
|
489
|
+
// as NOT gradeable and returns `failed` without grading a criterion. The precondition is right —
|
|
490
|
+
// its verdict must cite a T0 artifact it re-hashed itself — and nothing met it: the list was once
|
|
491
|
+
// assembled by an orchestrator courier from the paths each scope reported, and was lost when the
|
|
492
|
+
// orchestrator became a workflow script that passes only `{dimensions, run_cmd, round}`. Evaluators
|
|
493
|
+
// that went looking on disk graded anyway; one that followed its contract refused, and the run
|
|
494
|
+
// aborted at L3 over a round whose every scope was green.
|
|
495
|
+
//
|
|
496
|
+
// Derived here for the reason `bugs` is: this is the one line every lane compiles through, and the
|
|
497
|
+
// evidence is already on disk. A caller could not rebuild the list from filenames in any case —
|
|
498
|
+
// verdict files are addressed by round, attempt and trial, never by scope, so the scope lives only
|
|
499
|
+
// inside each body, which is what `probe t0` reads.
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The green T0 verdict each scope contract holds for a round — an evaluate order's `t0_artifacts`.
|
|
503
|
+
*
|
|
504
|
+
* @param {string} cwd - Project root.
|
|
505
|
+
* @param {string} slug - Feature slug.
|
|
506
|
+
* @param {number} [round] - The round being evaluated. Omitted, each scope's newest green verdict
|
|
507
|
+
* of any round — a standalone evaluation has no round.
|
|
508
|
+
* @returns {{artifacts: string[], missing: string[]}} Repo-relative verdict paths in scope-id
|
|
509
|
+
* order, one per scope that has one; and the scopes that have none. Both empty on an unscoped spec.
|
|
510
|
+
*/
|
|
511
|
+
export function t0ArtifactsFor(cwd, slug, round) {
|
|
512
|
+
const artifacts = [];
|
|
513
|
+
const missing = [];
|
|
514
|
+
for (const { contract, id } of readAllContracts(scopesDir(cwd, slug))) {
|
|
515
|
+
const scopeId = contract?.scope_id || id;
|
|
516
|
+
const { green, path } = greenVerdict(cwd, slug, scopeId, round);
|
|
517
|
+
if (green) artifacts.push(relLocal(slug, "t0", "verdicts", basename(path)));
|
|
518
|
+
else missing.push(scopeId);
|
|
519
|
+
}
|
|
520
|
+
return { artifacts, missing };
|
|
521
|
+
}
|
|
522
|
+
|
|
485
523
|
/**
|
|
486
524
|
* Assemble a WorkOrder envelope. Pure given its inputs — the CLI wrapper does the disk reads.
|
|
487
525
|
* @param {object} opts - The order inputs (destructured):
|
|
@@ -743,6 +781,17 @@ export async function cli(rawArgv) {
|
|
|
743
781
|
let payloadExtra = flag("payload") || {};
|
|
744
782
|
if (specDir && !payloadExtra.spec_folder) payloadExtra.spec_folder = specDir;
|
|
745
783
|
if (!payloadExtra.feature) payloadExtra.feature = slug;
|
|
784
|
+
// The judge's citations, for every lane (see t0ArtifactsFor). An explicit `--payload` list still
|
|
785
|
+
// wins, as it does for `bugs`: an operator naming the evidence outranks the derivation.
|
|
786
|
+
if (operation === "evaluate" && payloadExtra.t0_artifacts === undefined) {
|
|
787
|
+
const { artifacts, missing } = t0ArtifactsFor(cwd, slug, round);
|
|
788
|
+
if (artifacts.length) payloadExtra.t0_artifacts = artifacts;
|
|
789
|
+
// On stderr, never stdout: stdout is the order path the caller consumes.
|
|
790
|
+
if (missing.length) {
|
|
791
|
+
console.error(`compile-order: warning — no green T0 verdict${round ? ` in round ${round}` : ""} for ` +
|
|
792
|
+
`${missing.join(", ")}; the evaluator has nothing to cite for ${missing.length === 1 ? "that scope" : "those scopes"}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
746
795
|
|
|
747
796
|
const order = compileOrder({
|
|
748
797
|
slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory, bugs,
|
package/kernel/init/run.mjs
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
// --spec-folder SHARED spec deliverable path (default: shapeup/<slug>/spec/)
|
|
55
55
|
// --dimensions comma-separated eval dimensions (default: spec-conformance)
|
|
56
56
|
// --gate-answers path | preset name (see `harness gate`; recorded, not read)
|
|
57
|
+
// --breadboard path to the pitch's breadboard (default: found — see resolveBreadboard())
|
|
57
58
|
// --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
|
|
58
59
|
// --cwd project root (default: process.cwd())
|
|
59
60
|
// --force re-init over an existing run receipt
|
|
@@ -68,8 +69,8 @@
|
|
|
68
69
|
// that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
|
|
69
70
|
// one moment it mattered named a mechanism that does not parse.
|
|
70
71
|
|
|
71
|
-
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
|
|
72
|
-
import { join, dirname, resolve } from "node:path";
|
|
72
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync, statSync } from "node:fs";
|
|
73
|
+
import { join, dirname, resolve, relative, sep } from "node:path";
|
|
73
74
|
import { createHash } from "node:crypto";
|
|
74
75
|
import { decideLane, treeSize } from "./fit.mjs";
|
|
75
76
|
import { runArgs } from "../lib/argv.mjs";
|
|
@@ -78,8 +79,9 @@ import { deriveSnapshot } from "../reduce/snapshot.mjs";
|
|
|
78
79
|
import { mintRunId } from "../lib/paths.mjs";
|
|
79
80
|
import {
|
|
80
81
|
localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir,
|
|
81
|
-
workflowsStage, globWorkflowsStage,
|
|
82
|
+
workflowsStage, globWorkflowsStage, sharedRoot, shapingDir, breadboard as stagedBreadboard,
|
|
82
83
|
} from "../lib/paths.mjs";
|
|
84
|
+
import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
|
|
83
85
|
import { resolveWorkers } from "../verify/skills.mjs";
|
|
84
86
|
|
|
85
87
|
export const RECEIPT_VERSION = 1;
|
|
@@ -132,8 +134,19 @@ export function digest(text) {
|
|
|
132
134
|
/**
|
|
133
135
|
* Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
|
|
134
136
|
* Kept separate from I/O so the structural tests can assert its shape without a filesystem.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} o - Resolved inputs (destructured).
|
|
139
|
+
* @param {string} o.slug - Feature slug.
|
|
140
|
+
* @param {string} o.intake - The intake text, verbatim.
|
|
141
|
+
* @param {object} o.config - The pinned run config.
|
|
142
|
+
* @param {string} o.startedAt - ISO start time.
|
|
143
|
+
* @param {(object|null)} [o.plugin] - The plugin copy that answered.
|
|
144
|
+
* @param {(string|null)} [o.intakeSource] - Repo-relative `--intake-file` path, or "text" / "stdin".
|
|
145
|
+
* @param {({source: string, path: (string|null), text: string, translated?: boolean}|null)} [o.breadboard] -
|
|
146
|
+
* The resolved breadboard, `path` repo-relative (null when embedded in the intake), or null.
|
|
147
|
+
* @returns {object} The receipt.
|
|
135
148
|
*/
|
|
136
|
-
export function buildReceipt({ slug, intake, config, startedAt, plugin = null }) {
|
|
149
|
+
export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null, breadboard = null }) {
|
|
137
150
|
const intakeText = String(intake ?? "");
|
|
138
151
|
const intakeSha256 = digest(intakeText);
|
|
139
152
|
return {
|
|
@@ -156,6 +169,23 @@ export function buildReceipt({ slug, intake, config, startedAt, plugin = null })
|
|
|
156
169
|
intake_sha256: intakeSha256,
|
|
157
170
|
intake_chars: intakeText.length,
|
|
158
171
|
intake_lines: intakeText ? intakeText.split("\n").length : 0,
|
|
172
|
+
// WHERE THE INTAKE CAME FROM. The copy above is all the run reads, so without its origin nothing
|
|
173
|
+
// on disk says which file the pitch was — or that it had a second half beside it.
|
|
174
|
+
intake_source: intakeSource,
|
|
175
|
+
// THE PITCH'S OTHER HALF. A `/shapeup` pitch is shaping.md + breadboard.md, and a Place that only
|
|
176
|
+
// the breadboard names reaches no planning worker unless the run carries it. Recorded here, at
|
|
177
|
+
// t=0, rather than left to a worker to note: the kernel knows whether a breadboard exists before
|
|
178
|
+
// anything is dispatched, and a worker's prose rule for exactly this case did not hold.
|
|
179
|
+
breadboard: breadboard
|
|
180
|
+
? {
|
|
181
|
+
source: breadboard.source,
|
|
182
|
+
path: breadboard.path ?? null,
|
|
183
|
+
sha256: digest(breadboard.text),
|
|
184
|
+
chars: String(breadboard.text ?? "").length,
|
|
185
|
+
ids: idCounts(parseBreadboard(breadboard.text)),
|
|
186
|
+
...(typeof breadboard.translated === "boolean" ? { translated: breadboard.translated } : {}),
|
|
187
|
+
}
|
|
188
|
+
: null,
|
|
159
189
|
// The single fact that separates "the harness ran" from "the harness described itself".
|
|
160
190
|
// Written before any gate, so its ABSENCE at Stop is unambiguous.
|
|
161
191
|
started: true,
|
|
@@ -312,6 +342,70 @@ export function stageWorkflows(cwd, pluginRoot, { refresh = true } = {}) {
|
|
|
312
342
|
return { ok: true, dir, staged: names };
|
|
313
343
|
}
|
|
314
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Find the breadboard that belongs to this run's pitch. First hit wins:
|
|
347
|
+
*
|
|
348
|
+
* 1. `flag` — `--breadboard <path>`, resolved against cwd; a missing file is an error.
|
|
349
|
+
* 2. `sibling` — `breadboard.md` in the folder of `--intake-file` (not for text or stdin).
|
|
350
|
+
* 3. `shaping-dir` — `shapeup/<slug>/shaping/breadboard.md`, the documented location.
|
|
351
|
+
* 4. `shared-root` — `shapeup/<slug>/breadboard.md`, the flat layout real projects use.
|
|
352
|
+
* 5. `embedded` — the intake itself, when it carries Places and UI tables; nothing is staged.
|
|
353
|
+
*
|
|
354
|
+
* THE INTAKE'S OWN FOLDER COMES FIRST, not the documented location, because the only consumer that
|
|
355
|
+
* hit this keeps its pitch flat in `shapeup/<slug>/`: a lookup that reads only the shaping folder
|
|
356
|
+
* passes every fixture built to the documented layout and misses the real one.
|
|
357
|
+
*
|
|
358
|
+
* A discovered candidate that is the intake file itself, or the run's own staged copy, is skipped:
|
|
359
|
+
* the staged copy is this function's OUTPUT, and re-opening a run from its staged intake must not
|
|
360
|
+
* inherit the breadboard of the run it replaces.
|
|
361
|
+
*
|
|
362
|
+
* A TRANSLATED PITCH GETS ITS TRANSLATED BREADBOARD. When the intake is a translator output
|
|
363
|
+
* (`<name>.en.md`), each folder is tried for `breadboard.en.md` before `breadboard.md`, and the
|
|
364
|
+
* result says whether it is translated — an English spec planned from a source-language breadboard
|
|
365
|
+
* is a mismatch the caller reports, not one it may silently stage.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} o - Inputs (destructured).
|
|
368
|
+
* @param {string} o.cwd - Project root.
|
|
369
|
+
* @param {string} o.slug - Feature slug.
|
|
370
|
+
* @param {(string|null)} [o.intakeFile] - The `--intake-file` value as given ("-" = stdin).
|
|
371
|
+
* @param {(string|null)} [o.flag] - The `--breadboard` value as given.
|
|
372
|
+
* @param {string} [o.intake] - The intake text, for the embedded case.
|
|
373
|
+
* @returns {({source: string, path: (string|null), text: string, translated?: boolean}|null)} The
|
|
374
|
+
* breadboard, its absolute path (null when embedded), and its text — plus `translated` when the
|
|
375
|
+
* intake is a `.en.md` translation; or null when the pitch has none.
|
|
376
|
+
* @throws {Error} If `--breadboard` names a file that does not exist.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveBreadboard({ cwd, slug, intakeFile = null, flag = null, intake = "" }) {
|
|
379
|
+
const self = intakeFile && intakeFile !== "-" ? resolve(cwd, intakeFile) : null;
|
|
380
|
+
const english = Boolean(self && self.endsWith(".en.md"));
|
|
381
|
+
/** Tag a hit with whether it is translated — only meaningful when the intake is. */
|
|
382
|
+
const hit = (source, p, text) => ({ source, path: p, text, ...(english ? { translated: p === null || p.endsWith(".en.md") } : {}) });
|
|
383
|
+
if (flag) {
|
|
384
|
+
const p = resolve(cwd, flag);
|
|
385
|
+
if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`--breadboard not found: ${p}`);
|
|
386
|
+
return hit("flag", p, readFileSync(p, "utf8"));
|
|
387
|
+
}
|
|
388
|
+
const staged = stagedBreadboard(cwd, slug);
|
|
389
|
+
const names = english ? ["breadboard.en.md", "breadboard.md"] : ["breadboard.md"];
|
|
390
|
+
const folders = [
|
|
391
|
+
...(self ? [["sibling", dirname(self)]] : []),
|
|
392
|
+
["shaping-dir", shapingDir(cwd, slug)],
|
|
393
|
+
["shared-root", sharedRoot(cwd, slug)],
|
|
394
|
+
];
|
|
395
|
+
for (const [source, dir] of folders) {
|
|
396
|
+
for (const n of names) {
|
|
397
|
+
const p = join(dir, n);
|
|
398
|
+
if (p === self || p === staged) continue;
|
|
399
|
+
if (existsSync(p) && statSync(p).isFile()) return hit(source, p, readFileSync(p, "utf8"));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (hasBreadboardTables(intake)) return hit("embedded", null, String(intake));
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A path relative to the project root, `/`-joined on every platform — the form a receipt records. */
|
|
407
|
+
const repoRel = (cwd, p) => relative(cwd, p).split(sep).join("/");
|
|
408
|
+
|
|
315
409
|
// ---- CLI -------------------------------------------------------------------
|
|
316
410
|
|
|
317
411
|
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
@@ -319,7 +413,7 @@ export const ARGV_SPEC = {
|
|
|
319
413
|
usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
|
|
320
414
|
"[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
|
|
321
415
|
"[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
|
|
322
|
-
"[--gate-answers <preset|path>] " +
|
|
416
|
+
"[--gate-answers <preset|path>] [--breadboard <path>] " +
|
|
323
417
|
"[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
|
|
324
418
|
"[--plugin-root <dir>] [--force]",
|
|
325
419
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
@@ -335,6 +429,7 @@ export const ARGV_SPEC = {
|
|
|
335
429
|
"spec-folder": { type: "path" },
|
|
336
430
|
dimensions: { type: "str" },
|
|
337
431
|
"gate-answers": { type: "str" },
|
|
432
|
+
breadboard: { type: "path" },
|
|
338
433
|
lane: { type: "str" },
|
|
339
434
|
tiny: { type: "flag" },
|
|
340
435
|
"wall-clock-budget": { type: "int", min: 1 },
|
|
@@ -423,6 +518,16 @@ export function cli(rawArgv) {
|
|
|
423
518
|
let eval_dimensions;
|
|
424
519
|
try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
|
|
425
520
|
catch (e) { fail(2, e.message); }
|
|
521
|
+
// The pitch's second half, resolved before anything is written so a bad `--breadboard` is a usage
|
|
522
|
+
// error that ran nothing. Read-only here; staged below, once the run is actually opened.
|
|
523
|
+
let bb = null;
|
|
524
|
+
try { bb = resolveBreadboard({ cwd, slug, intakeFile, flag: args.breadboard ?? null, intake }); }
|
|
525
|
+
catch (e) { fail(2, e.message); }
|
|
526
|
+
if (bb?.translated === false) {
|
|
527
|
+
console.error(`⚠ init-run: the intake is a translation but its breadboard is not — staging ${repoRel(cwd, bb.path)} as found.`);
|
|
528
|
+
console.error(" Translate the breadboard too (translator), and name the .en.md with --breadboard, or the spec is planned from two languages.");
|
|
529
|
+
}
|
|
530
|
+
const intakeSource = args.intakeStdin || intakeFile === "-" ? "stdin" : intakeFile ? repoRel(cwd, resolve(cwd, intakeFile)) : "text";
|
|
426
531
|
|
|
427
532
|
const config = {
|
|
428
533
|
auto_level,
|
|
@@ -532,7 +637,10 @@ export function cli(rawArgv) {
|
|
|
532
637
|
}
|
|
533
638
|
|
|
534
639
|
const startedAt = new Date().toISOString();
|
|
535
|
-
const receipt = buildReceipt({
|
|
640
|
+
const receipt = buildReceipt({
|
|
641
|
+
slug, intake, config, startedAt, plugin, intakeSource,
|
|
642
|
+
breadboard: bb ? { ...bb, path: bb.path ? repoRel(cwd, bb.path) : null } : null,
|
|
643
|
+
});
|
|
536
644
|
|
|
537
645
|
mkdirSync(runRoot, { recursive: true });
|
|
538
646
|
mkdirSync(join(runRoot, "orders"), { recursive: true });
|
|
@@ -541,6 +649,10 @@ export function cli(rawArgv) {
|
|
|
541
649
|
|
|
542
650
|
// The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
|
|
543
651
|
writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
|
|
652
|
+
// The breadboard, verbatim — byte for byte, so the receipt's digest is the file's. A copy left by a
|
|
653
|
+
// run this one replaces goes first: a re-open without a breadboard must not inherit the last one.
|
|
654
|
+
rmSync(stagedBreadboard(cwd, slug), { force: true });
|
|
655
|
+
if (bb?.path) writeFileSync(stagedBreadboard(cwd, slug), bb.text, "utf8");
|
|
544
656
|
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
|
|
545
657
|
writeFileSync(join(runRoot, "harness-run.md"), runFrontmatter({ slug, config, startedAt }), "utf8");
|
|
546
658
|
|
|
@@ -558,6 +670,10 @@ export function cli(rawArgv) {
|
|
|
558
670
|
receipt: globLocal(slug, "receipt.json"),
|
|
559
671
|
intake_sha256: receipt.intake_sha256,
|
|
560
672
|
intake_chars: receipt.intake_chars,
|
|
673
|
+
// The staged breadboard the planning dispatches are handed, or null when the pitch has none
|
|
674
|
+
// separate (`breadboard_source` then says "embedded" or null).
|
|
675
|
+
breadboard: bb?.path ? globLocal(slug, "breadboard.md") : null,
|
|
676
|
+
breadboard_source: bb?.source ?? null,
|
|
561
677
|
config,
|
|
562
678
|
// What the launch names. Project-local by necessity, not by preference — see stageWorkflows().
|
|
563
679
|
workflow_script: staged.ok ? globWorkflowsStage("shapeup-run.js") : null,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// breadboard — read a `/shapeup` breadboard's element ids out of its markdown tables.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. A pitch is two files: `shaping.md` (problem, requirements, parts) and
|
|
4
|
+
// `breadboard.md` (Places, affordances, slices). The run used to take only the first, so a Place
|
|
5
|
+
// that existed only in the breadboard — a new sheet, a new modal — reached no planning worker, and
|
|
6
|
+
// the spec folded it into whichever screen the prose happened to mention. The ids read here are what
|
|
7
|
+
// the run pins at open (the receipt's counts) and what spec-lint checks placement against.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT READS, and nothing more. An element id is the FIRST cell of a markdown table row —
|
|
10
|
+
// `P1`, `P2.1`, `U3`, `N1b`, `S2`, `V4` — after stripping `**` and backticks, so both table layouts
|
|
11
|
+
// the breadboarding guide ships parse the same way: a `#` first column and an `ID` first column. A
|
|
12
|
+
// row's Places come from its `Place` column, every `P#` token in the cell (`P1 / P3`, `P2/P1`); a
|
|
13
|
+
// table with no Place column gives its rows no Places. Places may also be listed as prose under a
|
|
14
|
+
// `## Places` heading (`P1: Composer — …`), which is the guide's own output template.
|
|
15
|
+
//
|
|
16
|
+
// It skips fenced code blocks: a table inside a fence is an example of a breadboard, not this one.
|
|
17
|
+
//
|
|
18
|
+
// Tolerant by design. A layout this reader cannot parse yields zero ids, and every caller treats
|
|
19
|
+
// zero ids as "nothing to check" plus a warning — never as a hard stop. Zero dependencies.
|
|
20
|
+
|
|
21
|
+
/** A first-cell element id: a Place (`P1`, `P2.1`), or a UI/code affordance, store or slice. */
|
|
22
|
+
export const ID_PATTERN = /^(P\d+(?:\.\d+)*|[UNSV]\d+[a-z]?)$/;
|
|
23
|
+
|
|
24
|
+
/** Every Place reference inside a cell. Global — use with `match`, never `test`. */
|
|
25
|
+
const PLACE_REF = /\bP\d+(?:\.\d+)*\b/g;
|
|
26
|
+
|
|
27
|
+
/** A prose Place line under `## Places`: `P1: …`, `- P1 — …`, `**P2** – …`. */
|
|
28
|
+
const PROSE_PLACE = /^\s*(?:[-*+]\s+)?\**(P\d+(?:\.\d+)*)\**\s*[:—–-]\s*(.*)$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split one markdown table row into trimmed cells.
|
|
32
|
+
* @param {string} line - A line that starts with `|`.
|
|
33
|
+
* @returns {string[]} The cells between the outer pipes.
|
|
34
|
+
*/
|
|
35
|
+
function cells(line) {
|
|
36
|
+
const t = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
37
|
+
return t.split("|").map((c) => c.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strip the emphasis and code marks a breadboard author puts around an id.
|
|
42
|
+
* @param {string} cell - A raw table cell.
|
|
43
|
+
* @returns {string} The cell's text without `**` and backticks.
|
|
44
|
+
*/
|
|
45
|
+
function bare(cell) {
|
|
46
|
+
return String(cell ?? "").replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is this line a table's header separator (`|---|:--:|`)?
|
|
51
|
+
* @param {string} line - A table line.
|
|
52
|
+
* @returns {boolean} True for a separator row.
|
|
53
|
+
*/
|
|
54
|
+
function isSeparator(line) {
|
|
55
|
+
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a breadboard's element ids.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text - The breadboard markdown (or a pitch that carries one inline).
|
|
62
|
+
* @returns {{places: {id: string, name: string}[], ui: {id: string, places: string[]}[],
|
|
63
|
+
* code: {id: string, places: string[]}[], stores: {id: string, places: string[]}[],
|
|
64
|
+
* slices: {id: string}[]}} Every id found, de-duplicated by id; a repeated row merges its Places.
|
|
65
|
+
*/
|
|
66
|
+
export function parseBreadboard(text) {
|
|
67
|
+
const lines = String(text ?? "").split(/\r?\n/);
|
|
68
|
+
const places = new Map();
|
|
69
|
+
const byKind = { U: new Map(), N: new Map(), S: new Map(), V: new Map() };
|
|
70
|
+
|
|
71
|
+
const addPlace = (id, name) => {
|
|
72
|
+
if (!places.has(id)) places.set(id, { id, name: name || "" });
|
|
73
|
+
else if (!places.get(id).name && name) places.get(id).name = name;
|
|
74
|
+
};
|
|
75
|
+
const addElement = (id, refs) => {
|
|
76
|
+
const m = byKind[id[0]];
|
|
77
|
+
if (!m.has(id)) m.set(id, new Set());
|
|
78
|
+
for (const p of refs) m.get(id).add(p);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let inFence = false;
|
|
82
|
+
let inPlacesSection = false;
|
|
83
|
+
let header = null; // the current table's header cells, lower-cased
|
|
84
|
+
let prevWasTable = false;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; prevWasTable = false; header = null; continue; }
|
|
89
|
+
if (inFence) continue;
|
|
90
|
+
|
|
91
|
+
const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
|
|
92
|
+
if (heading) {
|
|
93
|
+
inPlacesSection = /^places\b/i.test(bare(heading[1]));
|
|
94
|
+
prevWasTable = false; header = null;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (/^\s*\|/.test(line)) {
|
|
99
|
+
if (!prevWasTable) {
|
|
100
|
+
// A table starts here. Its first line is the header when the next line is a separator.
|
|
101
|
+
header = isSeparator(lines[i + 1] ?? "") ? cells(line).map((c) => bare(c).toLowerCase()) : null;
|
|
102
|
+
prevWasTable = true;
|
|
103
|
+
if (header) continue;
|
|
104
|
+
}
|
|
105
|
+
if (isSeparator(line)) continue;
|
|
106
|
+
const row = cells(line);
|
|
107
|
+
const id = bare(row[0]);
|
|
108
|
+
if (!ID_PATTERN.test(id)) continue;
|
|
109
|
+
const placeCol = header ? header.indexOf("place") : -1;
|
|
110
|
+
if (id[0] === "P") {
|
|
111
|
+
const nameCol = placeCol > 0 ? placeCol : (header ? header.findIndex((h) => h === "name") : -1);
|
|
112
|
+
addPlace(id, bare(row[nameCol > 0 ? nameCol : 1] ?? ""));
|
|
113
|
+
} else {
|
|
114
|
+
const cell = placeCol > 0 ? bare(row[placeCol] ?? "") : "";
|
|
115
|
+
addElement(id, cell.match(PLACE_REF) ?? []);
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
prevWasTable = false; header = null;
|
|
120
|
+
|
|
121
|
+
if (inPlacesSection) {
|
|
122
|
+
const m = line.match(PROSE_PLACE);
|
|
123
|
+
if (m) addPlace(m[1], bare(m[2]).split(/\s+[—–-]\s+/)[0]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const list = (m) => [...m].map(([id, refs]) => ({ id, places: [...refs] }));
|
|
128
|
+
return {
|
|
129
|
+
places: [...places.values()],
|
|
130
|
+
ui: list(byKind.U),
|
|
131
|
+
code: list(byKind.N),
|
|
132
|
+
stores: list(byKind.S),
|
|
133
|
+
slices: [...byKind.V.keys()].map((id) => ({ id })),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Does this text carry a breadboard of its own — at least one Place and one UI affordance?
|
|
139
|
+
*
|
|
140
|
+
* The test for a pitch that embeds its breadboard inline rather than as a second file. Both are
|
|
141
|
+
* required: a shaping doc routinely has tables, and a Place with nothing to place is not a
|
|
142
|
+
* breadboard anything downstream can check.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} text - Markdown to inspect.
|
|
145
|
+
* @returns {boolean} True when `parseBreadboard` finds a P# and a U#.
|
|
146
|
+
*/
|
|
147
|
+
export function hasBreadboardTables(text) {
|
|
148
|
+
const bb = parseBreadboard(text);
|
|
149
|
+
return bb.places.length > 0 && bb.ui.length > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Count a parsed breadboard's ids by kind, the shape the run receipt records.
|
|
154
|
+
* @param {ReturnType<typeof parseBreadboard>} parsed - A `parseBreadboard` result.
|
|
155
|
+
* @returns {{P: number, U: number, N: number, S: number, V: number}} Ids per kind.
|
|
156
|
+
*/
|
|
157
|
+
export function idCounts(parsed) {
|
|
158
|
+
return {
|
|
159
|
+
P: parsed.places.length,
|
|
160
|
+
U: parsed.ui.length,
|
|
161
|
+
N: parsed.code.length,
|
|
162
|
+
S: parsed.stores.length,
|
|
163
|
+
V: parsed.slices.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/kernel/lib/paths.mjs
CHANGED
|
@@ -91,7 +91,7 @@ export const localRoot = (cwd, slug) => join(cwd, LOCAL, slug);
|
|
|
91
91
|
export const specDir = (cwd, slug) => join(sharedRoot(cwd, slug), "spec");
|
|
92
92
|
/** Use-case directory inside the spec tree. */
|
|
93
93
|
export const usecasesDir = (cwd, slug) => join(specDir(cwd, slug), "usecases");
|
|
94
|
-
/** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. */
|
|
94
|
+
/** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. `init run` looks here for a breadboard. */
|
|
95
95
|
export const shapingDir = (cwd, slug) => join(sharedRoot(cwd, slug), "shaping");
|
|
96
96
|
// The three contracts are markdown on disk and JSON on the wire (ADR-0001) — see
|
|
97
97
|
// `lib/contract.mjs`. `readContract()` accepts either extension, so a project mid-migration
|
|
@@ -138,6 +138,8 @@ export const RECEIPT_FILE = "receipt.json";
|
|
|
138
138
|
export const receipt = (cwd, slug) => join(localRoot(cwd, slug), RECEIPT_FILE);
|
|
139
139
|
/** The intake, verbatim, next to its digest in the receipt. */
|
|
140
140
|
export const intake = (cwd, slug) => join(localRoot(cwd, slug), "intake.md");
|
|
141
|
+
/** The breadboard the pitch was shaped with, verbatim, next to its digest in the receipt. */
|
|
142
|
+
export const breadboard = (cwd, slug) => join(localRoot(cwd, slug), "breadboard.md");
|
|
141
143
|
/** The run ledger — rounds, decisions, status frontmatter. */
|
|
142
144
|
export const harnessRun = (cwd, slug) => join(localRoot(cwd, slug), "harness-run.md");
|
|
143
145
|
/** File-derived mid-run digest, frozen by `reduce snapshot --write` as an audit anchor. */
|