shapeup-sdlc 3.1.0 → 3.1.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.1.0",
4
+ "version": "3.1.2",
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
@@ -58,14 +58,16 @@ 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
65
65
 
66
66
  - Orders/results live in `.shapeup/<slug>/orders|results/`; the envelope schemas ship inside the tech-lead skill.
67
67
  - The plugin's run entry points need a one-time permission grant — `npx shapeup-sdlc init` writes it into `.claude/settings.json` (`permissions.allow`); without it a headless run stalls at step one. That grant is necessary, not sufficient: it covers the run's own deterministic entry points, not the generic file edits every worker skill makes constantly, or any command a worker reaches for beyond the grant's own exact shape. A truly unattended run also needs a Claude Code permission mode that covers those (`acceptEdits` at minimum) — the plugin cannot grant that on your behalf.
68
- - Two storage tiers (ADR-0001): COMMITTED `shapeup/<slug>/` (shaping, spec, scopes, wiring-map, project-profile, requirements, hill, `REPORT.md` frozen at L4) vs GITIGNORED `.shapeup/` (board, orders/results, T0/eval/QA artifacts, ledgers, metrics, gate answers).
68
+ - Two storage tiers (ADR-0001): COMMITTED `shapeup/<slug>/` (shaping, spec, scopes, wiring-map, project-profile, requirements, hill, `REPORT.md` frozen at L4) vs GITIGNORED `.shapeup/` (board, orders/results, T0/eval/QA artifacts, ledgers, metrics, gate answers, and the run scripts staged for launch).
69
+ - **The run launches from a copy inside your project, and it has to.** The Workflow tool loads a script only from a directory the session may already read; the plugin installs outside your project, so naming the shipped path is refused before the run begins and no permission rule repairs it — the grant authorises the tool, not what it may read. Opening a run therefore re-copies the run scripts to `.shapeup/workflows/` and reports the path the launch names. A run in flight keeps the copy it started with: an upgrade reaches the next run, not the current round.
70
+ - **A write the substrate does not cover is denied for as long as the dispatch is in flight, and no longer.** A dispatch is live from the moment its order is compiled until a result for *that* dispatch lands, and liveness is read off the run's order set — not off the pointer that names the run, which outlives it. So a finished run fences nothing, a re-dispatch is fenced again, and the committed tier is never a worker's to write: those files belong to the orchestrator, whose window is a phase boundary rather than the middle of somebody else's order.
69
71
  - Every run has a `run_id` — the receipt mints it, and orders, T0 artifacts, trial rows, agent-call journal rows and hook decisions all carry it. It is the only key that separates two runs of the same feature: everything else (`order_id`, round/attempt) repeats. It is **not** a time boundary — a relaunch resumes the same run and reuses the key, so one `run_id` legitimately spans every launch after a paused gate or a kill, with hours of wall clock between them, and `orders/<id>.json` is rewritten by each. Anything measuring elapsed time reads the append-only records, never the span of a key. SHIP S.7 exports the run's records as fact tables under `.shapeup/exports/<run_id>/` before the run trace is superseded; a WorkResult carries no `run_id` and reaches it through `order_id`.
70
72
  - Every run projects a **run graph** — `.shapeup/<slug>/graph.jsonl`, append-only, written only by
71
73
  `reduce graph`. Two families kept separate: work lineage (Run, Order, Result, Verdict, Trial,
package/README.md CHANGED
@@ -219,8 +219,9 @@ the layer that carries it, and the three layers here fail differently:
219
219
  - `PreToolUse` (`Skill|Agent`) — **`harness verify envelope` denies any worker dispatch whose order
220
220
  file is missing or fails the WorkOrder schema.** A malformed envelope never reaches a worker.
221
221
  - `PreToolUse` (`Edit|Write|MultiEdit`) — **`hooks/sandbox-guard.mjs` blocks a write that no LIVE
222
- order's substrate permits.** It reads every compiled-but-not-yet-ingested order rather than a
223
- pointer to one, so scopes building concurrently are each held to their own contract; `frozen`
222
+ order's substrate permits.** It reads every order that is compiled and not yet answered — a result
223
+ at least as new as the order itself — rather than a pointer to one, so scopes building
224
+ concurrently are each held to their own contract and a finished run fences nothing; `frozen`
224
225
  outranks everything, across all of them.
225
226
  - `PreToolUse` (`Bash|Read|Write|Edit|MultiEdit`) — **`hooks/safety-spine.mjs` denies destructive
226
227
  commands** (`rm -rf` on unrecoverable targets, force-push/push-to-main, `git reset --hard`,
package/SECURITY.md CHANGED
@@ -69,7 +69,7 @@ sitting, and reading them is the recommended review.
69
69
  | [`safety-spine.mjs`](hooks/safety-spine.mjs) | PreToolUse (`Bash\|Read\|Write\|Edit\|MultiEdit`) | The proposed command/path; `.shapeup/safety-overrides.json` | Yes — provably destructive ops only: `rm -rf` on unrecoverable targets, `git push --force` / push to main, `git reset --hard`, `git clean -fdx`, `DROP TABLE`/`TRUNCATE`, reads of `.env`/keys/cloud credentials, and any write to its own overrides file | Never blocks an unmatched command; `--force-with-lease` stays allowed |
70
70
  | [`gate-intake.mjs`](hooks/gate-intake.mjs) | PreToolUse (`Skill`) | The `tech-lead` dispatch's own arguments | Yes — an orchestrator dispatch carrying no resolvable intake (no pitch, spec, resume or requirement text) | Fails open on `--order` and on any ambiguous arg shape |
71
71
  | [`harness verify envelope`](kernel/verify/envelope.mjs) | PreToolUse (`Skill\|Agent`) | The `--order` file named in the dispatch; the JSON schemas | Yes — a worker dispatch whose order file is missing or schema-invalid | Never gates a dispatch that carries no `--order` (standalone skill use stays free) |
72
- | [`sandbox-guard.mjs`](hooks/sandbox-guard.mjs) | PreToolUse (`Edit\|Write\|MultiEdit`) | The target path; the `substrate` block of every LIVE order (compiled, not yet ingested) | Yes — any write no live order permits: outside every `allowed`/`shared`, inside any `frozen`, or a `Write` to an `append_only` path | No-op unless an order is live; the active feature's own `.shapeup/<slug>/` run-trace is always writable. Appends denials to the local pathology log |
72
+ | [`sandbox-guard.mjs`](hooks/sandbox-guard.mjs) | PreToolUse (`Edit\|Write\|MultiEdit`) | The target path; the `substrate` block of every LIVE order — compiled, with no result at least as new as the order's own `compiled_at` | Yes — any write no live order permits: outside every `allowed`/`shared`, inside any `frozen`, or a `Write` to an `append_only` path | No-op unless an order is live, which a finished run no longer is: the pointer names the run, never a dispatch. The active feature's own `.shapeup/<slug>/` run-trace is always writable. Appends denials to the local pathology log |
73
73
  | [`dispatch-receipt.mjs`](hooks/dispatch-receipt.mjs) | PostToolUse (`Skill\|Agent`) | The `--order` file named in the dispatch; the tool result's own report of which skill ran | **No — it has no deny path at all.** It records that the shipped skill ran, so `harness reduce ingest` can refuse a result no dispatch produced | Never writes an attestation for a result that does not name a resolved skill; never fails the call it observes (every write is inside `try`/`catch`) |
74
74
  | [`gate-zerowork.mjs`](hooks/gate-zerowork.mjs) | Stop | Run receipts on disk; the session transcript; the decision ledger | **Yes — the one blocking hook.** Returns `decision:"block"` when the session dispatched the orchestrator and produced no run receipt | Defers the moment any receipt exists; `stop_hook_active` caps it at one block per stop chain |
75
75
 
@@ -83,6 +83,11 @@ sitting, and reading them is the recommended review.
83
83
  any kind.
84
84
  - **The safety-spine actively blocks secret reads** (`.env`, `*.pem`, `*.key`, ssh/cloud
85
85
  credentials) rather than merely not making them.
86
+ - **The script the run executes is a copy, and the copy is the shipped file.** Opening a run
87
+ copies the plugin's own workflow scripts into the gitignored `.shapeup/workflows/`, because the
88
+ Workflow tool loads a script only from a directory the session may already read and the plugin
89
+ installs outside your project. They are copied byte for byte — never generated, templated or
90
+ rewritten — so what you review in the plugin is what runs, and nothing else is added to your tree.
86
91
  - The installer (`scripts/install-harness.sh`) writes only into the target project
87
92
  (`.claude/`, `shapeup/`, `.gitignore`) and tells you what it
88
93
  is going to do first; the `curl | bash` form requires an explicit `--yes` for exactly that
package/commands/build.md CHANGED
@@ -21,7 +21,7 @@ it belongs to the orchestrator:
21
21
 
22
22
  ```
23
23
  Workflow({
24
- scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/workflows/shapeup-run.js",
24
+ scriptPath: ".shapeup/workflows/shapeup-run.js",
25
25
  args: <the RunArgs object>
26
26
  })
27
27
  ```
package/commands/ship.md CHANGED
@@ -20,7 +20,7 @@ launch and does not drive it turn by turn:
20
20
 
21
21
  ```
22
22
  Workflow({
23
- scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/workflows/shapeup-run.js",
23
+ scriptPath: ".shapeup/workflows/shapeup-run.js",
24
24
  args: <the RunArgs object>
25
25
  })
26
26
  ```
@@ -56,7 +56,7 @@
56
56
  import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
57
57
  import { join } from "node:path";
58
58
  import { isMain } from "../kernel/lib/argv.mjs";
59
- import { localDir, globLocal } from "../kernel/lib/paths.mjs";
59
+ import { localDir, globLocal, globWorkflowsStage } from "../kernel/lib/paths.mjs";
60
60
  import { runHook, readStdin, settle, decisionsPath } from "./lib/decision.mjs";
61
61
 
62
62
  const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024;
@@ -255,7 +255,7 @@ export function buildReason({ narration, census, enforcement }) {
255
255
  "it needs, unless --no-native-workflow was given, in which case approve the launch once):",
256
256
  "",
257
257
  " Workflow({",
258
- " scriptPath: \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/workflows/shapeup-run.js\",",
258
+ ` scriptPath: "${globWorkflowsStage("shapeup-run.js")}", // staged by \`init run\`, above`,
259
259
  " args: <the RunArgs object>",
260
260
  " })",
261
261
  "",
@@ -13,14 +13,34 @@
13
13
  // and the contract the hook enforces the same object, for every operation, with no per-operation
14
14
  // code here.
15
15
  //
16
- // IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` still names the run and
17
- // seeds the search — `harness compile` publishes it as it writes each order, which is what fences
18
- // the lanes that never reach the workflow (`--tiny`, the prose round loop, a standalone `/build`).
19
- // But a single pointer cannot survive concurrency: with scopes building side by side the last
20
- // compile wins the pointer, and a write from scope A is then judged against scope B's contract —
21
- // a false block or a false permit depending on which way the race fell. So the candidate set is
22
- // every order under `orders/` with no matching file under `results/` (compiled, not yet ingested),
23
- // and a write is permitted when SOME live contract covers it.
16
+ // IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` names the run and
17
+ // nothing more — `harness compile` publishes it as it writes each order, which is what fences the
18
+ // lanes that never reach the workflow (`--tiny`, the prose round loop, a standalone `/build`).
19
+ // A single pointer cannot survive concurrency: with scopes building side by side the last compile
20
+ // wins the pointer, and a write from scope A would be judged against scope B's contract — a false
21
+ // block or a false permit depending on which way the race fell. So the candidate set is every
22
+ // order under `orders/` that is not yet ANSWERED, and a write is permitted when SOME live contract
23
+ // covers it.
24
+ //
25
+ // THE POINTER IS NOT A LIVENESS SIGNAL, and it used to be one: the order it names was counted live
26
+ // unconditionally, "so the single-order lane behaves as it did before concurrency existed". The arm
27
+ // bought nothing — an order that is genuinely in flight has no result yet and is already live by
28
+ // the rule below — and it cost the checkout permanently. The pointer has one writer and no eraser,
29
+ // so the LAST dispatch of a FINISHED run stayed live for good and fenced everything to that one
30
+ // substrate: after a ship, an ordinary edit anywhere in the repo was denied, and the next feature
31
+ // could not write even its own run trace, because the carve-out below is keyed to the slug the
32
+ // stale pointer names. The documented fail-open state ("no pointer — not inside a dispatch") became
33
+ // unreachable after the first run, and the only way out was to delete a file nothing documents.
34
+ // The arm is gone; `reduce ship` and ``harness init run --force`` retire the pointer as well, so a
35
+ // leftover one is untidy rather than load-bearing.
36
+ //
37
+ // ANSWERED IS A COMPARISON, NOT A PRESENCE TEST, and the difference is a hole the removal above
38
+ // would otherwise open. Order filenames for the run-level operations carry no round (`hammer.json`,
39
+ // `wire.json`, `analyze.json`), so re-dispatching one inside the same run rewrites the order beside
40
+ // the PREVIOUS dispatch's result — and a presence test reads that as finished and runs the new
41
+ // dispatch unfenced. An order counts as answered only when its result file is at least as new as
42
+ // the order's own `compiled_at`, the stamp the compiler writes INTO the order, which a copy or a
43
+ // touch cannot perturb. An order carrying no stamp falls back to presence, which is all it ever had.
24
44
  //
25
45
  // That is the same question as "the writer's own contract" because scope substrates are disjoint by
26
46
  // construction — `harness verify spec`'s DISJOINT rule fails a spec where two scopes claim the same
@@ -50,10 +70,10 @@
50
70
  // Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
51
71
  // Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
52
72
 
53
- import { readFileSync, existsSync, appendFileSync, mkdirSync, readdirSync } from "node:fs";
73
+ import { readFileSync, existsSync, appendFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
54
74
  import { resolve, join, relative, dirname, sep } from "node:path";
55
75
  import { isMain } from "../kernel/lib/argv.mjs";
56
- import { LOCAL, activeOrder, ordersDir, resultsDir, metricsShard } from "../kernel/lib/paths.mjs";
76
+ import { LOCAL, SHARED, activeOrder, ordersDir, resultsDir, metricsShard } from "../kernel/lib/paths.mjs";
57
77
  import { runHook, readStdin, settle } from "./lib/decision.mjs";
58
78
 
59
79
  // --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
@@ -89,28 +109,48 @@ function readJSON(p) {
89
109
  }
90
110
 
91
111
  /**
92
- * Every order for this run that has been compiled and not yet ingested.
112
+ * Has this order been answered — i.e. has a result for THIS dispatch landed?
93
113
  *
94
- * "Not yet ingested" is read off the filesystem — an order with a same-named file under `results/`
95
- * has finished — because that is the only signal that survives a killed session. The pointer's own
96
- * order is always included, even when its result has landed, so the single-order lane behaves
97
- * exactly as it did before concurrency existed.
114
+ * Filesystem-only, because that is the one signal that survives a killed session. See the banner's
115
+ * "ANSWERED IS A COMPARISON" note for why a same-named result file is not on its own an answer.
116
+ *
117
+ * @param {string} resultPath - Where this order's result would be.
118
+ * @param {object} order - The parsed order, for its `compiled_at` stamp.
119
+ * @returns {boolean} True when the result belongs to this dispatch rather than an earlier one.
120
+ */
121
+ function answered(resultPath, order) {
122
+ let mtimeMs;
123
+ try { mtimeMs = statSync(resultPath).mtimeMs; } catch { return false; }
124
+ const compiledAt = Date.parse(order?.compiled_at ?? "");
125
+ if (Number.isNaN(compiledAt)) return true; // no stamp to compare against — presence is the answer
126
+ // WHOLE SECONDS, because that is all some filesystems keep of an mtime — HFS+ among them, which
127
+ // this plugin's own development volume uses. The stamp carries milliseconds; compared raw against
128
+ // a truncated mtime, a result written in the same second as its compile reads as OLDER than the
129
+ // order and the order stays live. Flooring the stamp costs a one-second window the other way — a
130
+ // re-dispatch inside the same second as the previous result reads as answered — which no real
131
+ // dispatch is fast enough to hit.
132
+ return mtimeMs >= Math.floor(compiledAt / 1000) * 1000;
133
+ }
134
+
135
+ /**
136
+ * Every order for this run that has been compiled and not yet answered.
98
137
  *
99
138
  * @param {string} cwd - Project root.
100
- * @param {string} slug - The active run's slug.
101
- * @param {string} pointerOrder - Absolute path of the order the pointer names.
139
+ * @param {string} slug - The run named by the pointer.
102
140
  * @returns {object[]} Parsed orders; unreadable files are skipped, never treated as permissive.
103
141
  */
104
- function liveOrders(cwd, slug, pointerOrder) {
142
+ function liveOrders(cwd, slug) {
105
143
  const dir = ordersDir(cwd, slug);
106
- const done = new Set(existsSync(resultsDir(cwd, slug)) ? readdirSync(resultsDir(cwd, slug)) : []);
107
- const paths = new Set(existsSync(pointerOrder) ? [pointerOrder] : []);
108
- if (existsSync(dir)) {
109
- for (const f of readdirSync(dir)) {
110
- if (f.endsWith(".json") && !done.has(f)) paths.add(join(dir, f));
111
- }
144
+ if (!existsSync(dir)) return [];
145
+ const rDir = resultsDir(cwd, slug);
146
+ const live = [];
147
+ for (const f of readdirSync(dir)) {
148
+ if (!f.endsWith(".json")) continue;
149
+ const order = readJSON(join(dir, f));
150
+ if (!order) continue;
151
+ if (!answered(join(rDir, f), order)) live.push(order);
112
152
  }
113
- return [...paths].map(readJSON).filter(Boolean);
153
+ return live;
114
154
  }
115
155
 
116
156
  function extractPaths(toolInput) {
@@ -159,9 +199,10 @@ async function main() {
159
199
  // writer's own contract" are the same question — and only the first can be asked without a
160
200
  // shared mutable pointer.
161
201
  //
162
- // Live = compiled and not yet ingested. An order whose result is on disk has finished; leaving it
163
- // in the candidate set would keep a finished scope's substrate open for the rest of the run.
164
- const orders = liveOrders(cwd, active.slug, resolve(cwd, active.order_path));
202
+ // Live = compiled and not yet answered. An order whose result is on disk has finished; leaving it
203
+ // in the candidate set would keep a finished scope's substrate open for the rest of the run — and
204
+ // leaving the POINTER's own order in unconditionally kept a finished RUN's substrate open forever.
205
+ const orders = liveOrders(cwd, active.slug);
165
206
  if (orders.length === 0) defer(`no live order for ${active.slug}`, "no-order");
166
207
 
167
208
  const withSubstrate = orders.filter((o) => o.substrate);
@@ -219,6 +260,18 @@ async function main() {
219
260
  defer(`${targetPaths.length} path(s) inside a live order's substrate (${contracts.length} live) — permitted`, "in-substrate");
220
261
  }
221
262
 
263
+ // THE REMEDY DIFFERS BY TIER, and naming the wrong one costs a session real time. A product-code
264
+ // path outside every substrate is a scope-cut question, and widening the order is the honest fix.
265
+ // A path under the COMMITTED tier is not: no build scope may own the run's own governance and
266
+ // spec artifacts, so widening a substrate to reach one is the wrong move in a plausible-looking
267
+ // direction. Those files belong to the orchestrator, whose write window is a phase boundary —
268
+ // no dispatch in flight — and never the middle of somebody else's dispatch.
269
+ const committed = violations.filter((v) => v.split(/[\\/]/)[0] === SHARED);
270
+ const hint = committed.length === violations.length
271
+ ? `${SHARED}/ is committed tier: these belong to the orchestrator, not to a worker substrate. `
272
+ + "Write them at a phase boundary, with no dispatch in flight — do not widen an order to reach one."
273
+ : "If this write legitimately crosses scopes, the order's substrate needs to be expanded (e.g. via ba --remap).";
274
+
222
275
  logPathology(metricsPath, {
223
276
  schema_version: 1,
224
277
  at: new Date().toISOString(),
@@ -240,7 +293,7 @@ async function main() {
240
293
  permissionDecisionReason:
241
294
  `Sandbox guard (PA3) — no live order's substrate covers these writes:\n` +
242
295
  `${blockReasons.join("\n")}\n` +
243
- `If this write legitimately crosses scopes, the order's substrate needs to be expanded (e.g. via ba --remap).`,
296
+ hint,
244
297
  },
245
298
  },
246
299
  };
@@ -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,
@@ -771,17 +820,17 @@ export async function cli(rawArgv) {
771
820
  // through.
772
821
  //
773
822
  // `hooks/sandbox-guard.mjs` enforces the order's own `substrate` block — allowed/shared,
774
- // append_only, frozen — and it finds the order through `.shapeup/active-order`. Until this
775
- // write existed the pointer had exactly one author, the workflow script, so the guard fenced
776
- // the workflow lane and DEFERRED everywhere else: `--tiny`, the prose round loop, and a
777
- // standalone `/build` all compiled an order carrying a write contract that nothing enforced.
778
- // A substrate that is only enforced on the lane that also happens to be the most supervised
779
- // one is the wrong way round.
823
+ // append_only, frozen — and it finds the RUN through `.shapeup/active-order`. Until this write
824
+ // existed the pointer had exactly one author, the workflow script, so the guard fenced the
825
+ // workflow lane and DEFERRED everywhere else: `--tiny`, the prose round loop, and a standalone
826
+ // `/build` all compiled an order carrying a write contract that nothing enforced. A substrate
827
+ // that is only enforced on the lane that also happens to be the most supervised one is the wrong
828
+ // way round. This is now the pointer's ONLY author.
780
829
  //
781
- // Compiling an order is the moment the write contract comes into existence, so it is the
782
- // correct moment to publish it. The workflow script still sets the pointer explicitly before
783
- // dispatch (it interleaves phases and must be exact about which order is live); this write
784
- // makes the SAME mechanism cover callers that never reach that code.
830
+ // Compiling an order is the moment the write contract comes into existence, so it is the correct
831
+ // moment to publish it. What the pointer supplies is the run's slug; which of that run's orders
832
+ // are LIVE is derived from the order set (compiled, not yet answered), never from this file — so
833
+ // republishing it on every compile costs nothing and a stale one fences nothing.
785
834
  //
786
835
  // Best-effort, on stderr, and never fatal: a compiled order that cannot publish its pointer is
787
836
  // still a valid order, and stdout belongs to the order path the caller consumes. The guard
@@ -68,7 +68,7 @@
68
68
  // that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
69
69
  // one moment it mattered named a mechanism that does not parse.
70
70
 
71
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from "node:fs";
71
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
72
72
  import { join, dirname, resolve } from "node:path";
73
73
  import { createHash } from "node:crypto";
74
74
  import { decideLane, treeSize } from "./fit.mjs";
@@ -76,7 +76,10 @@ import { runArgs } from "../lib/argv.mjs";
76
76
  import { uncoerce } from "../lib/contract.mjs";
77
77
  import { deriveSnapshot } from "../reduce/snapshot.mjs";
78
78
  import { mintRunId } from "../lib/paths.mjs";
79
- import { localRoot, activeScope, globLocal, globShared, ordersDir, resultsDir } from "../lib/paths.mjs";
79
+ import {
80
+ localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir,
81
+ workflowsStage, globWorkflowsStage,
82
+ } from "../lib/paths.mjs";
80
83
  import { resolveWorkers } from "../verify/skills.mjs";
81
84
 
82
85
  export const RECEIPT_VERSION = 1;
@@ -257,6 +260,58 @@ export function resolveAbandonedOrders(cwd, slug) {
257
260
  return resolved;
258
261
  }
259
262
 
263
+ /**
264
+ * Copy the plugin's run scripts into the project so the launch can actually read them.
265
+ *
266
+ * WHY A COPY, AND WHY HERE. `Workflow({scriptPath})` loads a script only from somewhere the session
267
+ * is already allowed to read — the working directory, or a directory the operator added. The plugin
268
+ * is installed OUTSIDE the project (a version-stamped cache directory on a marketplace install), so
269
+ * naming the shipped path fails the launch outright, and no permission rule fixes it: the grant that
270
+ * authorises the Workflow tool says nothing about where it may read from. The failure is invisible
271
+ * in development, where the plugin root and the working directory are the same tree, and total for
272
+ * everybody else. A project-local copy is inside the working directory by construction, so it loads
273
+ * in every permission mode without widening what the session may read.
274
+ *
275
+ * Opening a run is the right moment: it is the one step every lane passes through before a launch,
276
+ * and it already knows which copy of the plugin answered.
277
+ *
278
+ * `refresh` is what keeps an upgrade from arriving mid-round. Opening a run (or forcing over one)
279
+ * overwrites the staged copies, so a plugin upgrade reaches the NEXT run. A call that finds a run
280
+ * already open only fills in what is missing: that run should finish on the orchestrator it started
281
+ * with, and swapping the script under a resumed round is a different build than the one the gates
282
+ * were answered for — but a relaunch with no script at all is a dead end, so a copy that is gone
283
+ * (a cleaned run workspace, a run opened by a version that never staged) is written back.
284
+ *
285
+ * Best-effort by design: a project that cannot be written to still opens its run and can still be
286
+ * launched from the install path by an operator who adds that directory. Reporting beats refusing.
287
+ *
288
+ * @param {string} cwd - Project root.
289
+ * @param {string} pluginRoot - The plugin copy that answered this call.
290
+ * @param {object} [opts] - Options.
291
+ * @param {boolean} [opts.refresh] - Overwrite an existing copy (true) or only fill gaps (false).
292
+ * @returns {{ok: boolean, dir: string, staged: string[], reason?: string}} Outcome, for the caller to report.
293
+ */
294
+ export function stageWorkflows(cwd, pluginRoot, { refresh = true } = {}) {
295
+ const src = join(pluginRoot, "skills", "tech-lead", "workflows");
296
+ const dir = workflowsStage(cwd);
297
+ let names;
298
+ try {
299
+ names = readdirSync(src).filter((f) => f.endsWith(".js")).sort();
300
+ } catch (e) {
301
+ return { ok: false, dir, staged: [], reason: `no workflow scripts at ${src}: ${e.message}` };
302
+ }
303
+ try {
304
+ mkdirSync(dir, { recursive: true });
305
+ for (const f of names) {
306
+ const dst = join(dir, f);
307
+ if (refresh || !existsSync(dst)) copyFileSync(join(src, f), dst);
308
+ }
309
+ } catch (e) {
310
+ return { ok: false, dir, staged: [], reason: `could not stage into ${dir}: ${e.message}` };
311
+ }
312
+ return { ok: true, dir, staged: names };
313
+ }
314
+
260
315
  // ---- CLI -------------------------------------------------------------------
261
316
 
262
317
  /** The typed argv contract (see `./lib/argv.mjs`). */
@@ -398,6 +453,19 @@ export function cli(rawArgv) {
398
453
 
399
454
  const runRoot = localRoot(cwd, slug);
400
455
  const receiptPath = join(runRoot, "receipt.json");
456
+
457
+ // STAGE THE RUN SCRIPTS BEFORE THE ALREADY-OPEN REFUSAL, not after: a session resuming a paused
458
+ // run reaches that refusal and nothing else, and it still needs a `scriptPath` it can name. What
459
+ // it does NOT get is a swapped orchestrator — see stageWorkflows() for why `refresh` is false
460
+ // exactly when a run is already open and is not being forced over.
461
+ // `Boolean(...)`, not the bare flag: an absent `--force` is `undefined`, and `false || undefined`
462
+ // is `undefined`, which a destructured default reads as "not passed" and turns back into `true`.
463
+ const staged = stageWorkflows(cwd, plugin.root, { refresh: !existsSync(receiptPath) || Boolean(args.force) });
464
+ if (!staged.ok) {
465
+ console.error(`⚠ init-run: could not stage the run scripts — ${staged.reason}`);
466
+ console.error(` Launch from the install path instead, and add ${plugin.root} to the session's`);
467
+ console.error(" readable directories (/add-dir) if the Workflow tool refuses to load it.");
468
+ }
401
469
  // A RUN IS ALREADY OPEN. This is the resume path, and it used to be a dead end.
402
470
  //
403
471
  // The refusal is right: silently re-initialising would discard the round history the circuit
@@ -437,6 +505,10 @@ export function cli(rawArgv) {
437
505
  "To re-derive this at any time:",
438
506
  " node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
439
507
  "To abandon the open run and start over, deliberately: --force",
508
+ "",
509
+ staged.ok
510
+ ? `Relaunch the same run with: Workflow({scriptPath: "${globWorkflowsStage("shapeup-run.js")}", args: <the same RunArgs>})`
511
+ : "The run scripts are NOT staged in this project — see the warning above before relaunching.",
440
512
  ].join("\n"));
441
513
  }
442
514
 
@@ -453,6 +525,10 @@ export function cli(rawArgv) {
453
525
  `⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`,
454
526
  );
455
527
  }
528
+ // And the pointer the abandoned run left behind. It names a run that is being forced over, so
529
+ // the next compile republishes it within the second; retiring it here means a `--force` that
530
+ // resolves nothing still leaves no stale claim about which run is open.
531
+ rmSync(activeOrder(cwd), { force: true });
456
532
  }
457
533
 
458
534
  const startedAt = new Date().toISOString();
@@ -483,6 +559,8 @@ export function cli(rawArgv) {
483
559
  intake_sha256: receipt.intake_sha256,
484
560
  intake_chars: receipt.intake_chars,
485
561
  config,
562
+ // What the launch names. Project-local by necessity, not by preference — see stageWorkflows().
563
+ workflow_script: staged.ok ? globWorkflowsStage("shapeup-run.js") : null,
486
564
  next: "GATE L0 — pin the run config, emit the gate block, then ORIENT.",
487
565
  }, null, 2));
488
566
  }
@@ -258,8 +258,34 @@ export const workingDir = (cwd, slug) => join(localRoot(cwd, slug), "working");
258
258
  * rows, above all, which without it are written unjoinable to any run.
259
259
  */
260
260
  export const activeScope = (cwd) => join(localDir(cwd), "active-scope");
261
- /** The pointer the sandbox guard reads to answer "which order is executing?". */
261
+ /**
262
+ * The pointer that tells the sandbox guard WHICH RUN it is fencing.
263
+ *
264
+ * It names a run, not a live dispatch. `harness compile` republishes it as it writes each order —
265
+ * the one place every lane passes through — and the guard uses the `slug` in it to find the run's
266
+ * order set; liveness is then derived from that set, never from this file. That separation is
267
+ * deliberate: this pointer has one writer and no natural eraser, so a run that ends leaves it
268
+ * behind, and anything that treated it as "a dispatch is in flight" kept a finished run's last
269
+ * substrate armed over an idle checkout. `reduce ship` and ``harness init run --force`` retire it
270
+ * so it does not outlive its run, and the guard is correct either way.
271
+ */
262
272
  export const activeOrder = (cwd) => join(localDir(cwd), "active-order");
273
+
274
+ /**
275
+ * Where the run scripts are staged for launch, inside the project.
276
+ *
277
+ * The orchestrator's Workflow scripts ship with the plugin, which lives OUTSIDE the project — and
278
+ * the Workflow tool will only load a script it is already allowed to read, meaning the working
279
+ * directory or a directory the operator added. An install path therefore fails the launch outright,
280
+ * with no permission rule that can fix it: the grant that authorises the tool does not widen where
281
+ * it may read from. So ``harness init run`` copies the shipped scripts here, and the launch names
282
+ * this project-local path. LOCAL, because a staged copy is a build artifact of the run, not a
283
+ * source file anybody should review or commit.
284
+ */
285
+ export const workflowsStage = (cwd) => join(localDir(cwd), "workflows");
286
+
287
+ /** The staged run script as the LAUNCH names it — project-relative, `/`-joined on every platform. */
288
+ export const globWorkflowsStage = (...parts) => [LOCAL, "workflows", ...parts].join("/");
263
289
  /** Hook receipts — one row per evaluation, so `allow` carries evidence. */
264
290
  export const decisions = (cwd) => join(localDir(cwd), "decisions.jsonl");
265
291
  /** Human-authored safety escape hatch. LOCAL so no PR can widen another machine's envelope. */
@@ -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 found and readable, 1 when the
5
- // result is missing (nothing ran, or ingest hasn't landed yet), 2 on a bad argv. Writes nothing.
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), bug_count: (number|null),
39
- * report_path: (string|null)}} `found: false` when no result exists yet — a fact, not a guess.
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
- if (!existsSync(path)) return { found: false, overall: null, bug_count: null, report_path: null };
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 { found: false, overall: null, bug_count: null, report_path: null }; }
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: overall !== null,
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 was found, 1 when none exists yet for this round.
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
  }
@@ -65,6 +65,7 @@ import {
65
65
  intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
66
66
  orientDir, activeOrder, usecasesDir,
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
@@ -405,9 +406,15 @@ export function deriveResumeState(cwd, slug) {
405
406
  // that permits the overlap is the same one that makes it invisible to the disjointness lint.
406
407
  scope_exclusions: scopeExclusions(cwd, slug, scope_files),
407
408
  pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
409
+ // A round is DONE when it was graded, not when its result file exists. An evaluator that
410
+ // refused the round — no PASS/FAIL, or a scoped verdict citing no T0 artifact — still writes
411
+ // `evaluate-r<N>.json`; counted, the relaunch opened round N+1 over a round nobody judged, with
412
+ // no bugs to route, and rebuilt every scope. Left open, it re-enters round N, skips the scopes
413
+ // already green there, and evaluates again.
408
414
  eval_rounds_done: resultFiles
409
415
  .filter((f) => /^evaluate-r\d+\.json$/.test(f))
410
- .map((f) => Number(f.match(/\d+/)[0])),
416
+ .map((f) => Number(f.match(/\d+/)[0]))
417
+ .filter((n) => evalVerdict(cwd, slug, n).found),
411
418
  };
412
419
  return { ...facts, next_phase: nextPhase(facts) };
413
420
  }
@@ -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")).sort().reverse()) {
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 };
@@ -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`.
@@ -26,12 +26,13 @@
26
26
  //
27
27
  // Exit: 0 written (path on stdout), 2 usage error.
28
28
 
29
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
29
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync } from "node:fs";
30
30
  import { join, dirname } from "node:path";
31
31
  import { runArgs } from "../lib/argv.mjs";
32
32
  import {
33
33
  report as reportPath, tasksDir, verdictsDir, trials, evaluationDir, qaDir,
34
34
  roundLedger, discoveryLedger, receipt as receiptPath, harnessRun, relShared, resultsDir,
35
+ activeOrder,
35
36
  } from "../lib/paths.mjs";
36
37
  import { readTrials } from "../verify/t0.mjs";
37
38
  import { ratchetReport } from "../probe/stats.mjs";
@@ -349,6 +350,15 @@ export async function cli(rawArgv) {
349
350
  } else {
350
351
  mkdirSync(dirname(path), { recursive: true });
351
352
  writeFileSync(path, markdown, "utf8");
353
+
354
+ // THE RUN IS OVER, SO RETIRE ITS POINTER. `harness compile` publishes `.shapeup/active-order`
355
+ // as it writes each order and nothing ever erased it, so the pointer outlived every run that
356
+ // produced one. That is harmless to the guard now — liveness comes from the order set, and a
357
+ // shipped run has no unanswered orders — but a pointer naming a finished run is a fact on disk
358
+ // that is no longer true, and the next reader to trust it inherits the same class of bug.
359
+ // Only on the writing path: `--stdout` is a preview of the report, not the end of the run.
360
+ rmSync(activeOrder(cwd), { force: true });
361
+
352
362
  console.log(relShared(args.slug, "REPORT.md"));
353
363
  }
354
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Shape Up for coding agents \u2014 with gates the agent can't talk its way past. Harness for Claude Code.",
5
5
  "bin": {
6
6
  "shapeup-sdlc": "bin/init.mjs"
@@ -37,7 +37,7 @@ Invoked as `--order <path>`. Fields you may rely on (absent = unknown, never inf
37
37
  | `payload.feature` | Feature slug — scopes the probe and names the report |
38
38
  | `payload.dimensions[]` | The active dimension set (the caller resolved precedence). Absent → `[spec-conformance]` + the auto-enable rules below |
39
39
  | `payload.run_cmd` | How to start the running app. Absent standalone → ask; absent orchestrated → ESCALATE, do not guess |
40
- | `payload.t0_artifacts[]` | Per-scope T0 verdict paths for this round (scoped specs). An artifact listed but missing/red on disk, or a scoped spec with none listed → the round is NOT gradeable: return `status: failed` naming the scope — a structural precondition, not a criterion |
40
+ | `payload.t0_artifacts[]` | Per-scope T0 verdict paths for this round (scoped specs), compiled from each scope's green verdict. An artifact listed but missing/red on disk, or a scoped spec with none listed → the round is NOT gradeable: return `status: failed` with the reason, naming the scope, as your FIRST deviation — a structural precondition, not a criterion |
41
41
  | `payload.browser` | `cli` (default, ~4x cheaper) \| `mcp` \| `none` |
42
42
  | `payload.tasks[]` | Traceability only (which UCs a task claims): NEVER a grading source — the committed UC text is the criterion, a paraphrase mismatch is a finding |
43
43
  | `substrate.allowed` | Your only write surface: `.shapeup/<slug>/evaluation/**` (the report + evidence) |
@@ -68,11 +68,11 @@ check the lane:
68
68
  `.shapeup/<slug>/run-args.json` before launching**, fresh on every launch and relaunch: the flags
69
69
  reach the workflow as a value in memory, so it is the run's only evidence of what it was launched
70
70
  with, and a run that cannot state its own configuration cannot have a claim about it checked.
71
- Then launch the run script with the **`Workflow` tool**:
71
+ Then launch with the **`Workflow` tool** — naming `init run`'s staged copy, never the install path:
72
72
 
73
73
  ```
74
74
  Workflow({
75
- scriptPath: "${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/workflows/shapeup-run.js",
75
+ scriptPath: ".shapeup/workflows/shapeup-run.js",
76
76
  args: <the RunArgs object>
77
77
  })
78
78
  ```
@@ -91,6 +91,12 @@ launch prompts for approval once per session and the unattended lane is unavaila
91
91
  comes back "requires approval" in a headless session, stop and say so — do not hand-build the
92
92
  feature instead.
93
93
 
94
+ **Why a project-local `scriptPath`.** The tool loads a script only from a directory the session may
95
+ already read, and the plugin installs outside your project, so the shipped path is refused before the
96
+ run begins — no permission rule repairs it. `init run` re-copies the scripts to `.shapeup/workflows/`
97
+ on every open, never on a relaunch, so a run finishes on the orchestrator it started with. If staging
98
+ failed, launch from the install path and have the operator `/add-dir` the plugin directory.
99
+
94
100
  ## Step 3 — the pause protocol: branch on `RunReturn.status`
95
101
 
96
102
  | `status` | What the workflow is telling you | What you do |
@@ -465,7 +465,9 @@ Read back: the stdout JSON — {path, sha256, trial, overall, regression, score,
465
465
  ## 4. EVAL → spec-evaluator (once per round)
466
466
  ```
467
467
  compile-order --operation evaluate --slug <slug> --worker spec-evaluator --round <r>
468
- --payload '{"dimensions": ["spec-conformance"], "run_cmd": "<cmd>", "t0_artifacts": [...]}'
468
+ --payload '{"dimensions": ["spec-conformance"], "run_cmd": "<cmd>"}'
469
+ t0_artifacts is compiled from each scope's green T0 verdict for round <r> — pass it only to
470
+ override. A scope with no green verdict is named on stderr: the judge has nothing to cite for it.
469
471
  Invoke via Agent (model: eval), ONCE, after GATE L2:
470
472
  Skill(shapeup-sdlc-plugin:spec-evaluator) --order <path>
471
473
  Effect: one feature-level pass over the running app against all AC + Done-when; writes
@@ -473,6 +475,8 @@ Effect: one feature-level pass over the running app against all AC + Done-when;
473
475
  verdicts, refuted boxes, T0 citations). It touches NO task file and NO board.
474
476
  ingest-result <results/evaluate-r<r>.json>: appends the .verdicts JSONL ledger, un-ticks the
475
477
  refuted AC boxes, sets eval_verdict frontmatter — the judge returns data, ingest writes.
478
+ A verdict on a scoped spec that cites no T0 artifact is refused and the round stays
479
+ open: re-dispatch the evaluator, do not advance the round.
476
480
  Read back: EVAL-FEATURE-<slug>.md → verdict (pass|fail) + the bug list (each bug has
477
481
  task ref, severity, file:line, expected vs actual).
478
482
  ```
@@ -2512,7 +2512,7 @@
2512
2512
  "items": {
2513
2513
  "type": "integer"
2514
2514
  },
2515
- "description": "Round numbers with an evaluate-r<n>.json result — the resumed run's round counter starts one past the maximum."
2515
+ "description": "Round numbers whose evaluate-r<n>.json holds a verdict the run may act on (PASS/FAIL, citing T0 artifacts when the spec is scoped) — the resumed run's round counter starts one past the maximum. A refused or uncited round is not done: the relaunch re-enters it."
2516
2516
  },
2517
2517
  "next_phase": {
2518
2518
  "type": "string",
@@ -560,6 +560,10 @@ const EVAL_VERDICT = {
560
560
  bug_count: nullable("integer"),
561
561
  report_path: nullable("string"),
562
562
  round: { type: "integer" },
563
+ // Why the round holds no verdict it may act on — the evaluator's own first deviation when it
564
+ // refused, or what is structurally wrong with the verdict it returned. Null when `ok`.
565
+ status: nullable("string"),
566
+ reason: nullable("string"),
563
567
  },
564
568
  required: ["ok", "round"],
565
569
  };
@@ -1358,14 +1362,22 @@ while (verdict !== "pass" && round <= maxRounds) {
1358
1362
  const e = await worker({
1359
1363
  skill: "spec-evaluator", operation: "evaluate", schema: EVAL, phase: "Eval", label: `eval:r${round}`,
1360
1364
  model: evalModel, round,
1365
+ // No `t0_artifacts` here, deliberately: `harness compile` derives them from the round's green
1366
+ // T0 verdicts on disk, for every lane — this script could only name paths it was told about.
1361
1367
  payload: { dimensions: evalDims, run_cmd: rs.run_cmd, round },
1362
- extra: "Evaluate the running feature against every acceptance criterion and Done-when. One feature-level pass; cite the T0 artifact you re-hash yourself.",
1368
+ extra: "Evaluate the running feature against every acceptance criterion and Done-when. One feature-level pass; cite every artifact the order lists under t0_artifacts, re-hashing each yourself.",
1363
1369
  });
1364
1370
  if (e.__failed) return diedAt("L3", e);
1365
1371
  // The pass/fail branch is decided from the WorkResult on disk, not from the dispatching
1366
1372
  // agent's own summary of it (`e.overall`) — see EVAL_VERDICT's comment for why.
1367
1373
  const ev = await query(`probe eval --slug ${slug} --round ${round}`, EVAL_VERDICT, "Eval", `verdict:r${round}`);
1368
- if (!ev || !ev.ok || !ev.overall) return diedAt("L3", nullFail(`verdict:r${round}`));
1374
+ if (!ev) return diedAt("L3", nullFail(`verdict:r${round}`));
1375
+ // A round with no verdict to act on is NOT a dead worker. An evaluator that refused the round
1376
+ // wrote a result saying why, and `probe eval` carries it as `reason`; reported as "died after
1377
+ // retries", the one sentence naming the cause stayed in a file nobody was pointed at.
1378
+ if (!ev.ok || !ev.overall) {
1379
+ return diedAt("L3", { __failed: `verdict:r${round}: no verdict this round can act on — ${ev.reason || `status ${ev.status || "unknown"}`}` });
1380
+ }
1369
1381
  verdict = ev.overall === "PASS" ? "pass" : "fail";
1370
1382
  findings = e.findings || [];
1371
1383
  }