shapeup-sdlc 1.7.0 → 3.0.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.
Files changed (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -1,280 +0,0 @@
1
- // paths — the single source of truth for where the harness writes.
2
- //
3
- // WHY THIS FILE EXISTS (measured by grepping the shipped tree, not theorized).
4
- //
5
- // The two storage roots were hard-coded across the tree in TWO syntaxes that no single search
6
- // finds:
7
- //
8
- // "docs/shapeup-sdlc/<slug>/scopes/..." spelled string literals
9
- // join(cwd, "docs", "shapeup-sdlc", slug, ...) segment-built sites
10
- //
11
- // A find/replace over the first set leaves the second silently pointing at the old root. That is
12
- // the failure mode this project keeps rediscovering: a change that appears complete, produces no
13
- // error, and is wrong — `lib/is-main.mjs` (a guard duplicated 18 times, inert under a symlink) and
14
- // `lib/argv.mjs` (`rNaN-a1.json` written with exit 0) are the same defect at different layers. The
15
- // remedy each time is to give the duplicated thing one home and add a test that no one may bypass
16
- // it — asserted by the structural suite.
17
- //
18
- // It also removes a live ambiguity. `gate-answers.mjs` resolved three candidate paths and
19
- // `sandbox-guard.mjs` built a fourth independently; the same filename meant "my personal lane" at
20
- // one path and "team policy" at another, auto-discovered with no flag (ADR-0001 §Context).
21
- //
22
- // TIER DISCIPLINE, restated here because this is where it becomes mechanical:
23
- // SHARED (committed) — prose a teammate reads: shaping, spec, contracts, requirements, report.
24
- // LOCAL (gitignored) — run state, envelopes, verification artifacts, machine policy.
25
- // See ADR-0001 (consumer file organization).
26
- //
27
- // Zero dependencies. Pure — every function takes `cwd` and returns a path; nothing here touches
28
- // the filesystem, so importing this module can never have a side effect.
29
-
30
- import { join } from "node:path";
31
-
32
- // ---------------------------------------------------------------------------
33
- // The two roots. Renaming a root is these two lines plus migration 0006.
34
- // ---------------------------------------------------------------------------
35
-
36
- // Both roots are POSIX strings, deliberately. They are used two ways — fed to `join()` (which
37
- // normalises separators on Windows) and interpolated into SUBSTRATE GLOBS, which are matched
38
- // against repo-relative POSIX paths by `sandbox-guard`. A backslash reaching a glob would silently
39
- // stop matching, so the canonical form is the one the globs need.
40
-
41
- /**
42
- * Committed tier — the authored deliverable a teammate gets on `git pull`.
43
- *
44
- * Was `docs/shapeup-sdlc`. Moved out of `docs/` because many projects publish that directory
45
- * through a static-site generator, which either publishes the spec tree by accident or fails the
46
- * site build on it (ADR-0001).
47
- */
48
- export const SHARED = "shapeup";
49
-
50
- /** Gitignored tier — run state for the machine that invoked the harness. Was `.shapeup-sdlc`. */
51
- export const LOCAL = ".shapeup";
52
-
53
- /**
54
- * The pre-ADR-0001 roots. Migration `0006` moves a project from these to the pair above;
55
- * `bin/init.mjs` writes ignore rules covering both so a half-migrated checkout cannot commit a
56
- * run trace. Nothing else should read these.
57
- */
58
- export const LEGACY = { shared: "docs/shapeup-sdlc", local: ".shapeup-sdlc" };
59
-
60
- // ---------------------------------------------------------------------------
61
- // Roots
62
- // ---------------------------------------------------------------------------
63
-
64
- /**
65
- * The committed tier root.
66
- * @param {string} cwd - Project root.
67
- * @returns {string} `<cwd>/shapeup`.
68
- */
69
- export const sharedDir = (cwd) => join(cwd, SHARED);
70
-
71
- /**
72
- * The gitignored tier root.
73
- * @param {string} cwd - Project root.
74
- * @returns {string} `<cwd>/.shapeup`.
75
- */
76
- export const localDir = (cwd) => join(cwd, LOCAL);
77
-
78
- /**
79
- * A feature's committed root.
80
- * @param {string} cwd - Project root.
81
- * @param {string} slug - Feature slug.
82
- * @returns {string} `<cwd>/shapeup/<slug>`.
83
- */
84
- export const sharedRoot = (cwd, slug) => join(cwd, SHARED, slug);
85
-
86
- /**
87
- * A feature's run-trace root.
88
- * @param {string} cwd - Project root.
89
- * @param {string} slug - Feature slug.
90
- * @returns {string} `<cwd>/.shapeup/<slug>`.
91
- */
92
- export const localRoot = (cwd, slug) => join(cwd, LOCAL, slug);
93
-
94
- // ---------------------------------------------------------------------------
95
- // SHARED — the deliverable
96
- // ---------------------------------------------------------------------------
97
-
98
- /** The committed spec tree — the evaluator's grading truth. */
99
- export const specDir = (cwd, slug) => join(sharedRoot(cwd, slug), "spec");
100
- /** Use-case directory inside the spec tree. */
101
- export const usecasesDir = (cwd, slug) => join(specDir(cwd, slug), "usecases");
102
- /** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. */
103
- export const shapingDir = (cwd, slug) => join(sharedRoot(cwd, slug), "shaping");
104
- // The three contracts are markdown on disk and JSON on the wire (ADR-0001) — see
105
- // `lib/contract-md.mjs`. `readContract()` accepts either extension, so a project mid-migration
106
- // keeps working; these builders name the form the harness WRITES.
107
-
108
- /** Scope contracts, one markdown file per vertical slice. */
109
- export const scopesDir = (cwd, slug) => join(sharedRoot(cwd, slug), "scopes");
110
- /** One scope contract by id. */
111
- export const scopeContract = (cwd, slug, id) => join(scopesDir(cwd, slug), `${id}.md`);
112
- /** The wiring map — engine → seam → entry-point call site → affordance. */
113
- export const wiringMap = (cwd, slug) => join(sharedRoot(cwd, slug), "wiring-map.md");
114
- /** Archetype + entry_point; gates the reachability arm of trace-lint. */
115
- export const projectProfile = (cwd, slug) => join(sharedRoot(cwd, slug), "project-profile.md");
116
- /** The REQ clause registry that covers-closure checks against. */
117
- export const requirements = (cwd, slug) => join(sharedRoot(cwd, slug), "requirements.md");
118
- /** Hill shards — mechanical phase per scope. */
119
- export const hillDir = (cwd, slug) => join(sharedRoot(cwd, slug), "hill");
120
- /** The frozen ship report, written once at GATE L4. */
121
- export const report = (cwd, slug) => join(sharedRoot(cwd, slug), "REPORT.md");
122
- /** Team-shared coaching rules, read back by the three coachable workers. */
123
- export const knowledgeBaseDir = (cwd) => join(sharedDir(cwd), "knowledge-base");
124
- /** One worker's coaching file. */
125
- export const knowledgeBase = (cwd, skill) => join(knowledgeBaseDir(cwd), `${skill}.md`);
126
- // `.harness-version` and `.harness-migrations` were resolved here, committed by necessity as
127
- // ADR-0001's third exception. Their sole writer was the upgrade path's data-migration runner, which
128
- // has been removed; no code in the plugin has written either file since. The helpers went with
129
- // them rather than remaining as resolvable paths to files nothing produces — a path helper is a
130
- // claim that the file is part of the layout, and this one would have been false.
131
-
132
- // ---------------------------------------------------------------------------
133
- // LOCAL — run state
134
- // ---------------------------------------------------------------------------
135
-
136
- /** The run receipt — the mechanical fact that a run started (GATE L0.1). */
137
- export const receipt = (cwd, slug) => join(localRoot(cwd, slug), "receipt.json");
138
- /** The intake, verbatim, next to its digest in the receipt. */
139
- export const intake = (cwd, slug) => join(localRoot(cwd, slug), "intake.md");
140
- /** The run ledger — rounds, decisions, status frontmatter. */
141
- export const harnessRun = (cwd, slug) => join(localRoot(cwd, slug), "harness-run.md");
142
- /** File-derived mid-run digest, frozen before compaction. */
143
- export const runSnapshot = (cwd, slug) => join(localRoot(cwd, slug), "run-snapshot.json");
144
- /** The task board directory. */
145
- export const tasksDir = (cwd, slug) => join(localRoot(cwd, slug), "tasks");
146
- /** The board index. */
147
- export const boardIndex = (cwd, slug) => join(tasksDir(cwd, slug), "_index.md");
148
- /** Compiled WorkOrders. */
149
- export const ordersDir = (cwd, slug) => join(localRoot(cwd, slug), "orders");
150
- /** Returned WorkResults. */
151
- export const resultsDir = (cwd, slug) => join(localRoot(cwd, slug), "results");
152
- /** T0 verification artifacts. */
153
- export const t0Dir = (cwd, slug) => join(localRoot(cwd, slug), "t0");
154
- /** Immutable per-attempt verdict artifacts the evaluator must cite. */
155
- export const verdictsDir = (cwd, slug) => join(t0Dir(cwd, slug), "verdicts");
156
- /** The append-only trial ledger the ratchet reduces over. */
157
- export const trials = (cwd, slug) => join(t0Dir(cwd, slug), "trials.jsonl");
158
- /** Finished-scope fixture registry for the seesaw regression check. */
159
- export const seesawRegistry = (cwd, slug) => join(localRoot(cwd, slug), "seesaw", "registry.json");
160
- /** Evaluator output — report, evidence, verdict ledger. */
161
- export const evaluationDir = (cwd, slug) => join(localRoot(cwd, slug), "evaluation");
162
- /** QA hunt output. */
163
- export const qaDir = (cwd, slug) => join(localRoot(cwd, slug), "qa");
164
- /** Scout recon — code-surface map, spikes, discovered seed. */
165
- export const orientDir = (cwd, slug) => join(localRoot(cwd, slug), "orient");
166
- /** Time-boxed spike workspace. */
167
- export const spikesDir = (cwd, slug) => join(localRoot(cwd, slug), "spikes");
168
- /** Covers-closure + reachability run trace. */
169
- export const traceDir = (cwd, slug) => join(localRoot(cwd, slug), "trace");
170
- /** The discovered-task ledger every discovery flow appends to. */
171
- export const discoveryLedger = (cwd, slug) => join(localRoot(cwd, slug), "discovery", "ledger.md");
172
- /**
173
- * Adjudicated decisions, read back by `compile-order` as binding precedent.
174
- *
175
- * LOCAL since ADR-0001. It was committed, and it is appended to DURING a build round — so a run
176
- * left the working tree dirty in the deliverable tier while it was still building. Its
177
- * conclusions reach the team in `REPORT.md` at GATE L4 instead, frozen once.
178
- */
179
- export const roundLedger = (cwd, slug) => join(localRoot(cwd, slug), "round-ledger.md");
180
-
181
- /**
182
- * Spec WORKING NOTES — analysis that informed the contract but is not the contract.
183
- *
184
- * `synthesis.md`, `assess-report.md`, `feedback.md`, `api-feasibility.md`, `integration.md`. The
185
- * committed `spec/` keeps only what the evaluator grades against and a reviewer needs (ADR-0001
186
- * "contract vs working artifact").
187
- */
188
- export const workingDir = (cwd, slug) => join(localRoot(cwd, slug), "working");
189
-
190
- // --- checkout-wide ---------------------------------------------------------
191
-
192
- /** The pointer the sandbox guard reads to answer "which scope is checked out?". */
193
- export const activeScope = (cwd) => join(localDir(cwd), "active-scope");
194
- /** The pointer the sandbox guard reads to answer "which order is executing?". */
195
- export const activeOrder = (cwd) => join(localDir(cwd), "active-order");
196
- /** Hook receipts — one row per evaluation, so `allow` carries evidence. */
197
- export const decisions = (cwd) => join(localDir(cwd), "decisions.jsonl");
198
- /** Human-authored safety escape hatch. LOCAL so no PR can widen another machine's envelope. */
199
- export const safetyOverrides = (cwd) => join(localDir(cwd), "safety-overrides.json");
200
- /**
201
- * Telemetry shards.
202
- *
203
- * LOCAL since ADR-0001. Committed, they put `process.env.HOSTNAME` — a person's laptop name —
204
- * into the repository, and append-only JSONL in git only grows. The cost, stated plainly: with
205
- * these local, `stats.mjs` becomes a personal tool and "is the KB flywheel working across the
206
- * team?" is no longer answerable from the repo.
207
- */
208
- export const metricsDir = (cwd) => join(localDir(cwd), "metrics");
209
- /** This machine's telemetry shard. */
210
- export const metricsShard = (cwd, id = process.env.HOSTNAME || "local") =>
211
- join(metricsDir(cwd), `${id}.jsonl`);
212
- /** Archived pitches. */
213
- export const pitchArchiveDir = (cwd) => join(localDir(cwd), "pitch-archive");
214
-
215
- /**
216
- * Gate answer sets, in resolution order (first hit wins).
217
- *
218
- * ONE TIER ONLY, since ADR-0001. There used to be a third candidate — a COMMITTED
219
- * `gate-answers.json`, auto-discovered with no flag — so a file with `preset: ci` pre-approved
220
- * GATE L4 ship sign-off for everyone who pulled the repo, and the same filename meant "my
221
- * personal lane" at one path and "team policy" at another. Consent is now per-machine by
222
- * construction: no committed file can cross a gate on another person's behalf.
223
- *
224
- * @param {string} cwd - Project root.
225
- * @param {(string|null)} [slug] - Feature slug; adds the per-run candidate when given.
226
- * @returns {string[]} Candidate paths, most specific first.
227
- */
228
- export const gateAnswerCandidates = (cwd, slug = null) => [
229
- ...(slug ? [join(localRoot(cwd, slug), "gate-answers.json")] : []),
230
- join(localDir(cwd), "gate-answers.json"),
231
- ];
232
-
233
- // ---------------------------------------------------------------------------
234
- // Relative forms — for messages, receipts and anything a human reads
235
- // ---------------------------------------------------------------------------
236
-
237
- /**
238
- * A run-trace path relative to the project root, for display.
239
- * @param {string} slug - Feature slug.
240
- * @param {...string} parts - Path segments under the feature's local root.
241
- * @returns {string} e.g. `.shapeup/checkout/t0/verdicts`.
242
- */
243
- export const relLocal = (slug, ...parts) => join(LOCAL, slug, ...parts);
244
-
245
- /**
246
- * A deliverable path relative to the project root, for display.
247
- * @param {string} slug - Feature slug.
248
- * @param {...string} parts - Path segments under the feature's shared root.
249
- * @returns {string} e.g. `shapeup/checkout/spec`.
250
- */
251
- export const relShared = (slug, ...parts) => join(SHARED, slug, ...parts);
252
-
253
- // ---------------------------------------------------------------------------
254
- // Glob forms — substrate whitelists, matched against repo-relative POSIX paths
255
- // ---------------------------------------------------------------------------
256
-
257
- /**
258
- * A run-trace glob. Always POSIX-separated: `sandbox-guard` matches these against
259
- * `path.relative()` output normalised to forward slashes, so a `join()` here would stop matching
260
- * on Windows without any error to notice.
261
- * @param {string} slug - Feature slug.
262
- * @param {...string} parts - Glob segments under the feature's local root.
263
- * @returns {string} e.g. `.shapeup-sdlc/checkout/tasks/**`.
264
- */
265
- export const globLocal = (slug, ...parts) => [LOCAL, slug, ...parts].join("/");
266
-
267
- /**
268
- * A deliverable glob, POSIX-separated for the same reason as {@link globLocal}.
269
- * @param {string} slug - Feature slug.
270
- * @param {...string} parts - Glob segments under the feature's shared root.
271
- * @returns {string} e.g. `shapeup/checkout/scopes/*.md`.
272
- */
273
- export const globShared = (slug, ...parts) => [SHARED, slug, ...parts].join("/");
274
-
275
- /**
276
- * The coaching file a coachable worker reads, as a repo-relative path for the WorkOrder payload.
277
- * @param {string} skill - Worker name (task-executor | ba-pitch-analyzer | qa-edge-hunter).
278
- * @returns {string} e.g. `shapeup/knowledge-base/task-executor.md`.
279
- */
280
- export const relKnowledgeBase = (skill) => [SHARED, "knowledge-base", `${skill}.md`].join("/");
@@ -1,381 +0,0 @@
1
- #!/usr/bin/env node
2
- // run-workflow — a Bash-invoked control plane for Workflow-format orchestrator scripts.
3
- //
4
- // WHY THIS FILE EXISTS. The `Workflow` tool — the only lane for scoped specs — is denied in a
5
- // headless session with "Review dynamic workflow before running". Left to it, `shapeup-run.js`
6
- // executes zero times and the agent improvises the feature by hand instead; a session can reach
7
- // GATE L4 with a valid receipt while the lane never started. Bash HAS a path-scoped grantable
8
- // prefix, and `npx shapeup-sdlc init` already
9
- // writes exactly that rule (`Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/<owner>/scripts/:*)` —
10
- // bin/init.mjs mergePipelinePermissions), so this file runs the SAME Workflow-format script through
11
- // a surface the install already grants.
12
- //
13
- // ⟐ ONE CORRECTION, from probing the permission layer rather than concluding from denials. It is
14
- // NOT true that no permission string can grant the tool: a bare `"Workflow"` entry in
15
- // `permissions.allow` grants it, and with that entry removed the same call is denied. So the tool
16
- // was never ungrantable — the installer simply never wrote the entry, because it writes Bash
17
- // prefixes only. The defect is real, and it is an INSTALLER defect: the plugin never granted the
18
- // permission its own lane needs.
19
- //
20
- // TWO THINGS SURVIVE THE CORRECTION, and they are the reason this file still exists rather than a
21
- // one-line change to `init`:
22
- // 1. THE GRANT CANNOT BE SCOPED. `Workflow(<path>)` and `Workflow(<script>)` are both denied —
23
- // only the bare token works, which grants EVERY dynamic workflow script in the project,
24
- // including one a model writes at runtime. A harness whose thesis is "gates the agent cannot
25
- // talk its way past" should not ask for blanket dynamic-code execution. The Bash prefix is
26
- // path-scoped to this directory.
27
- // 2. It costs no new grant at all: existing installs already allow it.
28
- // The one-line `"Workflow"` grant remains a legitimate alternative for anyone who prefers the
29
- // native runtime's resume/isolation, and the upgrade notes document it as such. It is a choice
30
- // with a real trade-off, which is why it is documented rather than silently taken.
31
- //
32
- // IT LIVES IN `scripts/` FOR A LOAD-BEARING REASON, not a filing one. The grant `init` already
33
- // writes is a PREFIX rule over this exact directory, so shipping the launcher here means every
34
- // install that ever ran `npx shapeup-sdlc init` can already start the lane — zero new permission
35
- // strings, zero migration for existing users. Putting it anywhere else would require a new grant
36
- // and reproduce the same denial one directory over. The structural suite asserts that the
37
- // documented call site is covered by a prefix `bin/init.mjs` actually writes.
38
- //
39
- // PROVENANCE: prototyped and proven before it shipped — a headless `acceptEdits` session runs the
40
- // lane through a granted Bash prefix with zero denials, this loader executes the unmodified
41
- // `shapeup-run.js`, and a real worker dispatches under `acceptEdits`.
42
- //
43
- // WHAT IT PROVIDES to the script — the Workflow runtime surface shapeup-run.js actually uses:
44
- // args, agent(prompt, {label, phase, schema, model, effort}), parallel(thunks),
45
- // pipeline(items, ...stages), phase(title), log(msg), budget, workflow() [stub — throws].
46
- // Scripts keep the Workflow tool's contract: `export const meta = {...}` + a bare top-level body
47
- // with top-level `await` and `return`. The loader rewrites the one export and wraps the body in an
48
- // AsyncFunction; nothing about the script format changes, which is the point — shapeup-run.js runs
49
- // through this file byte-identical to how it ships.
50
- //
51
- // HOW agent() DISPATCHES. Each call spawns a fresh headless CLI session:
52
- // claude -p <prompt> --model <m> --output-format json --permission-mode acceptEdits
53
- // (detached process group, SIGTERM-then-SIGKILL escalation on timeout, so a hook grandchild can
54
- // never hold a dead worker's pipe open — without that, a time-capped session can outlive its cap
55
- // many times over). Workers are
56
- // stateless, craft-only, pipeline-blind (the envelope port) — fresh processes fit that contract
57
- // exactly; nothing here shares context between dispatches. `schema` is enforced by instruction +
58
- // parse + shallow validation + one retry; a worker that still fails returns null, which is the
59
- // Workflow tool's own documented behavior for a dead subagent and the case every shapeup-run.js
60
- // call site already survives (mechEnvelope).
61
- //
62
- // DIVERGENCES from the Workflow tool, stated rather than silent. None is reached by
63
- // `shapeup-run.js`, which is why this lane can carry it today; each is a real gap for any other
64
- // script, and a reader deciding whether to write one should read this list as a limit, not a note:
65
- // - budget counts USD (summed from each worker envelope's total_cost_usd), not output tokens.
66
- // Interface is identical: {total, spent(), remaining()}; total comes from --budget-usd.
67
- // - Date.now()/Math.random() are NOT banned inside scripts. The tool bans them for replay-safe
68
- // resume; this file journals every dispatch (journal.jsonl) but does not implement
69
- // resume-from-journal. shapeup-run.js resumes from DISK state by design, not from the
70
- // journal — which is why the lane's kill/resume story (`kill-resume-probe: PASS`, four
71
- // assertions on a live SIGKILL) does not depend on this and survives the surface swap.
72
- // - workflow() (child workflows) throws. shapeup-run.js inlines its round loop and never calls it.
73
- // - isolation: 'worktree' throws. shapeup-run.js is sequential today (design doc D3).
74
- // - schema validation is shallow (type + required keys + declared property types, one level).
75
- //
76
- // A RUN OUTLIVES A FOREGROUND TOOL CALL. A real pipeline runs for tens of minutes; every
77
- // foreground Bash call has a ceiling well below that. Launch it as a BACKGROUND Bash call and read
78
- // `<run-dir>/result.json`, which this file writes on completion with the same `{ok, result}` shape
79
- // stdout carries. Headless callers must also set `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0`, or the
80
- // wait is cut at 600 s and a truncated run is reported as a clean one.
81
- //
82
- // STDOUT DISCIPLINE: stdout carries exactly one JSON line — {ok, result} — because whatever
83
- // launched this process (a mech courier, an outer session, a test) reads stdout as data. All
84
- // narration goes to stderr.
85
- //
86
- // exit 0: script completed; stdout = {"ok":true,"result":...}
87
- // exit 1: script threw or the launch failed after parsing; stdout = {"ok":false,"error":...}
88
- // exit 2: argv rejected before anything ran (lib/argv.mjs) — nothing spawned, nothing written.
89
-
90
- import { spawn } from "node:child_process";
91
- import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
92
- import { resolve, basename } from "node:path";
93
- import process from "node:process";
94
- import { runArgs } from "./lib/argv.mjs";
95
- import { isMain } from "./lib/is-main.mjs";
96
-
97
- /**
98
- * The typed argv boundary.
99
- *
100
- * FAILING CLOSED IS THE POINT HERE, not hygiene. The cost of a silent launch failure is not that the lane
101
- * refused to start — it is that the refusal was quiet enough for an agent to route around, so a
102
- * run that never happened reported like one that did. A launcher that accepts `--max-concurrency`
103
- * with no value and proceeds on `NaN` is the same shape of defect: this rejects at exit 2 with a
104
- * machine-readable reason before a single worker is spawned or a run directory created.
105
- */
106
- export const ARGV_SPEC = {
107
- usage: 'run-workflow.mjs <workflow-script.js> [--args <json> | --args-file <path>] ' +
108
- "[--run-dir <dir>] [--worker-permission-mode <mode>] [--worker-cwd <dir>] " +
109
- "[--max-concurrency <n>] [--agent-timeout-s <n>] [--budget-usd <n>]",
110
- _: { arity: 1, name: "workflow-script.js" },
111
- args: { type: "json" },
112
- "args-file": { type: "path" },
113
- "run-dir": { type: "path" },
114
- "worker-permission-mode": { type: "enum", values: ["acceptEdits", "default", "plan", "bypassPermissions"] },
115
- "worker-cwd": { type: "path" },
116
- "max-concurrency": { type: "int", min: 1, max: 32 },
117
- "agent-timeout-s": { type: "int", min: 1 },
118
- "budget-usd": { type: "num", min: 0 },
119
- };
120
-
121
- /**
122
- * Resolve parsed argv into the loader's configuration.
123
- * @param {object} a - The object `runArgs(ARGV_SPEC)` returns.
124
- * @returns {object} Fully defaulted, absolute-path configuration.
125
- */
126
- export function configure(a) {
127
- const script = resolve(a._[0]);
128
- return {
129
- script,
130
- args: a.argsFile ? JSON.parse(readFileSync(a.argsFile, "utf8")) : (a.args ?? {}),
131
- runDir: a.runDir
132
- ? resolve(a.runDir)
133
- : resolve(`.run-workflow-${basename(script).replace(/\.[^.]+$/, "")}-${process.pid}`),
134
- workerPermissionMode: a.workerPermissionMode || "acceptEdits",
135
- workerCwd: a.workerCwd ? resolve(a.workerCwd) : process.cwd(),
136
- maxConcurrency: a.maxConcurrency ?? 4,
137
- agentTimeoutS: a.agentTimeoutS ?? 900,
138
- budgetUsd: a.budgetUsd ?? null,
139
- };
140
- }
141
-
142
- // ---------------------------------------------------------------------------------------------
143
- // Loader — the Workflow tool's script format, executed as-is. One rewrite (`export const meta`
144
- // -> `const meta`), then the whole body becomes an AsyncFunction so top-level `await` and
145
- // top-level `return` mean exactly what the tool defines them to mean.
146
- // ---------------------------------------------------------------------------------------------
147
- function loadWorkflow(path) {
148
- let src = readFileSync(path, "utf8");
149
- src = src.replace(/^export\s+const\s+meta\s*=/m, "const meta =");
150
- if (/^export\s/m.test(src)) {
151
- throw new Error(`${basename(path)}: unsupported export — Workflow scripts export only \`const meta\``);
152
- }
153
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
154
- return new AsyncFunction(
155
- "args", "agent", "parallel", "pipeline", "phase", "log", "budget", "workflow",
156
- `"use strict";\n${src}`
157
- );
158
- }
159
-
160
- // ---------------------------------------------------------------------------------------------
161
- // Concurrency — a plain semaphore; excess agent() calls queue, mirroring the tool's cap.
162
- // ---------------------------------------------------------------------------------------------
163
- function makeSemaphore(max) {
164
- let active = 0; const queue = [];
165
- const release = () => { active--; const next = queue.shift(); if (next) { active++; next(); } };
166
- const acquire = () => new Promise((res) => {
167
- if (active < max) { active++; res(); } else queue.push(res);
168
- });
169
- return { acquire, release };
170
- }
171
-
172
- // ---------------------------------------------------------------------------------------------
173
- // Worker spawn — one headless CLI session per dispatch. Process-group kill on timeout
174
- // (hook grandchildren keep pipes open, so
175
- // signaling one pid is not a cap).
176
- // ---------------------------------------------------------------------------------------------
177
- const KILL_GRACE_MS = 10_000;
178
-
179
- function runClaude({ prompt, model, permissionMode, cwd, timeoutMs }) {
180
- return new Promise((resolvePromise) => {
181
- const child = spawn("claude", [
182
- "-p", prompt,
183
- "--model", model,
184
- "--output-format", "json",
185
- "--permission-mode", permissionMode,
186
- ], { cwd, env: process.env, detached: true });
187
-
188
- let stdout = "", stderr = "", settled = false, killed = false;
189
- const settle = (r) => { if (!settled) { settled = true; clearTimeout(timer); clearTimeout(hardTimer); resolvePromise(r); } };
190
-
191
- let hardTimer = null;
192
- const timer = setTimeout(() => {
193
- killed = true;
194
- try { process.kill(-child.pid, "SIGTERM"); } catch { /* already gone */ }
195
- hardTimer = setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch { /* already gone */ } }, KILL_GRACE_MS);
196
- }, timeoutMs);
197
-
198
- child.stdout.on("data", (d) => { stdout += d; });
199
- child.stderr.on("data", (d) => { stderr += d; });
200
- child.on("error", (e) => settle({ ok: false, error: `cannot spawn claude: ${e.message}`, killed, stdout, stderr }));
201
- child.on("close", (code) => {
202
- let envelope = null;
203
- try { envelope = JSON.parse(stdout.trim()); } catch { /* non-JSON stdout stays raw */ }
204
- settle({ ok: code === 0 && envelope !== null && envelope.is_error !== true, code, killed, envelope, stdout, stderr });
205
- });
206
- });
207
- }
208
-
209
- // ---------------------------------------------------------------------------------------------
210
- // Structured output — instruction + extraction + shallow validation + one retry.
211
- // Extraction is parseMechJson's balanced-scan (shapeup-run.js:176) — proven against couriers
212
- // that wrap clean JSON in commentary; the same failure mode applies to whole workers.
213
- // ---------------------------------------------------------------------------------------------
214
- function extractJson(text) {
215
- if (typeof text !== "string") return null;
216
- const s = text.trim();
217
- try { return JSON.parse(s); } catch { /* fall through to extraction */ }
218
- const start = s.search(/[{[]/);
219
- if (start < 0) return null;
220
- const open = s[start], close = open === "{" ? "}" : "]";
221
- let depth = 0, inStr = false, esc = false;
222
- for (let i = start; i < s.length; i++) {
223
- const c = s[i];
224
- if (inStr) {
225
- if (esc) esc = false;
226
- else if (c === "\\") esc = true;
227
- else if (c === '"') inStr = false;
228
- continue;
229
- }
230
- if (c === '"') { inStr = true; continue; }
231
- if (c === open) depth++;
232
- else if (c === close && --depth === 0) {
233
- try { return JSON.parse(s.slice(start, i + 1)); } catch { return null; }
234
- }
235
- }
236
- return null;
237
- }
238
-
239
- function shallowValidate(schema, value) {
240
- const problems = [];
241
- if (!schema || typeof schema !== "object") return problems;
242
- if (schema.type === "object") {
243
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
244
- return [`expected object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`];
245
- }
246
- for (const k of schema.required || []) if (!(k in value)) problems.push(`missing required key "${k}"`);
247
- for (const [k, sub] of Object.entries(schema.properties || {})) {
248
- if (!(k in value) || !sub.type) continue;
249
- const v = value[k];
250
- const t = sub.type === "integer" ? (Number.isInteger(v) ? "integer" : typeof v)
251
- : Array.isArray(v) ? "array" : typeof v;
252
- if (t !== sub.type && !(sub.type === "number" && typeof v === "number")) {
253
- problems.push(`key "${k}": expected ${sub.type}, got ${t}`);
254
- }
255
- }
256
- } else if (schema.type === "array" && !Array.isArray(value)) {
257
- problems.push(`expected array, got ${typeof value}`);
258
- }
259
- return problems;
260
- }
261
-
262
- const schemaInstruction = (schema) =>
263
- "\n\n---\nSTRUCTURED OUTPUT REQUIRED. Your final reply must be ONLY a single JSON value that " +
264
- "validates against this JSON Schema — no prose, no markdown fences, nothing before or after " +
265
- `it:\n${JSON.stringify(schema)}`;
266
-
267
- // ---------------------------------------------------------------------------------------------
268
- // main
269
- // ---------------------------------------------------------------------------------------------
270
- async function main() {
271
- const cli = configure(runArgs(ARGV_SPEC));
272
- mkdirSync(cli.runDir, { recursive: true });
273
- const journalPath = resolve(cli.runDir, "journal.jsonl");
274
- const journal = (entry) => appendFileSync(journalPath, JSON.stringify(entry) + "\n");
275
- const note = (m) => process.stderr.write(`[run-workflow] ${m}\n`);
276
-
277
- const sem = makeSemaphore(cli.maxConcurrency);
278
- let seq = 0, costAccum = 0, currentPhase = null;
279
-
280
- const budget = {
281
- total: cli.budgetUsd,
282
- spent: () => costAccum,
283
- remaining: () => (cli.budgetUsd == null ? Infinity : Math.max(0, cli.budgetUsd - costAccum)),
284
- };
285
-
286
- async function agent(prompt, opts = {}) {
287
- const id = ++seq;
288
- const label = opts.label || `agent-${id}`;
289
- const model = opts.model || "sonnet";
290
- const phaseName = opts.phase || currentPhase;
291
- if (budget.total != null && budget.remaining() <= 0) {
292
- throw new Error(`budget exhausted ($${costAccum.toFixed(3)} of $${budget.total}) before agent "${label}"`);
293
- }
294
- if (opts.isolation) throw new Error(`agent "${label}": isolation: 'worktree' is not implemented on this lane`);
295
- await sem.acquire();
296
- const startedAt = new Date().toISOString();
297
- const t0 = process.hrtime.bigint();
298
- try {
299
- let fullPrompt = opts.schema ? prompt + schemaInstruction(opts.schema) : prompt;
300
- let result = null, attempts = 0, sessions = [];
301
- while (attempts < 2) {
302
- attempts++;
303
- note(`agent#${id} "${label}" attempt ${attempts} (model=${model}, mode=${cli.workerPermissionMode})`);
304
- const r = await runClaude({
305
- prompt: fullPrompt, model, permissionMode: cli.workerPermissionMode,
306
- cwd: cli.workerCwd, timeoutMs: cli.agentTimeoutS * 1000,
307
- });
308
- const env = r.envelope || {};
309
- sessions.push({ session_id: env.session_id ?? null, cost_usd: env.total_cost_usd ?? null, is_error: env.is_error ?? null, killed: r.killed });
310
- if (typeof env.total_cost_usd === "number") costAccum += env.total_cost_usd;
311
- if (!r.ok) { result = null; if (r.killed) break; continue; }
312
- if (!opts.schema) { result = env.result ?? null; break; }
313
- const parsed = extractJson(env.result);
314
- const problems = parsed === null ? ["reply contained no parseable JSON"] : shallowValidate(opts.schema, parsed);
315
- if (problems.length === 0) { result = parsed; break; }
316
- note(`agent#${id} "${label}" schema problems: ${problems.join("; ")}`);
317
- fullPrompt = prompt + schemaInstruction(opts.schema) +
318
- `\n\nA previous attempt failed validation: ${problems.join("; ")}. Correct this.`;
319
- result = null;
320
- }
321
- const wallMs = Number(process.hrtime.bigint() - t0) / 1e6;
322
- journal({ seq: id, label, phase: phaseName, model, permission_mode: cli.workerPermissionMode,
323
- started_at: startedAt, wall_ms: Math.round(wallMs), attempts, sessions, ok: result !== null, result });
324
- return result;
325
- } finally {
326
- sem.release();
327
- }
328
- }
329
-
330
- // Thunk errors resolve to null and the call never rejects — the Workflow tool's contract,
331
- // which shapeup-run.js's call sites (mechEnvelope, dispatch guards) are written against.
332
- const parallel = (thunks) => Promise.all(
333
- thunks.map((t) => Promise.resolve().then(t).catch((e) => { note(`parallel thunk failed: ${e.message}`); return null; }))
334
- );
335
-
336
- // No barrier between stages; a stage that throws drops the item to null and skips the rest.
337
- const pipeline = (items, ...stages) => Promise.all(
338
- items.map(async (item, i) => {
339
- let acc = item;
340
- for (const stage of stages) {
341
- try { acc = await stage(acc, item, i); }
342
- catch (e) { note(`pipeline item ${i} failed: ${e.message}`); return null; }
343
- }
344
- return acc;
345
- })
346
- );
347
-
348
- const phase = (title) => { currentPhase = title; note(`── phase: ${title}`); };
349
- const log = (m) => note(String(m));
350
- const workflow = () => { throw new Error("workflow() child workflows are not implemented on this lane"); };
351
-
352
- const fn = loadWorkflow(cli.script);
353
- note(`running ${basename(cli.script)} (run-dir ${cli.runDir})`);
354
- const result = await fn(cli.args, agent, parallel, pipeline, phase, log, budget, workflow);
355
-
356
- const summary = { ok: true, script: basename(cli.script), agents_dispatched: seq,
357
- cost_usd: Number(costAccum.toFixed(6)), result };
358
- writeFileSync(resolve(cli.runDir, "result.json"), JSON.stringify(summary, null, 2) + "\n");
359
- process.stdout.write(JSON.stringify({ ok: true, result }) + "\n");
360
- return cli.runDir;
361
- }
362
-
363
- if (isMain(import.meta.url)) {
364
- main().catch((e) => {
365
- // The failure is written where a background caller will look for it, not only to a stdout
366
- // nobody is reading: a launch that dies silently is the exact shape this file exists to end.
367
- process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
368
- process.stderr.write(`[run-workflow] FATAL ${e.stack}\n`);
369
- try {
370
- const dir = process.argv.includes("--run-dir")
371
- ? resolve(process.argv[process.argv.indexOf("--run-dir") + 1])
372
- : null;
373
- if (dir) {
374
- mkdirSync(dir, { recursive: true });
375
- writeFileSync(resolve(dir, "result.json"),
376
- JSON.stringify({ ok: false, error: e.message }, null, 2) + "\n");
377
- }
378
- } catch { /* the stdout line above is still the record */ }
379
- process.exit(1);
380
- });
381
- }