shapeup-sdlc 3.1.0 → 3.1.1

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.1",
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
@@ -65,7 +65,9 @@ Everything discovered funnels into `.shapeup/<slug>/discovery/ledger.md` (Orient
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
  };
@@ -771,17 +771,17 @@ export async function cli(rawArgv) {
771
771
  // through.
772
772
  //
773
773
  // `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.
774
+ // append_only, frozen — and it finds the RUN through `.shapeup/active-order`. Until this write
775
+ // existed the pointer had exactly one author, the workflow script, so the guard fenced the
776
+ // workflow lane and DEFERRED everywhere else: `--tiny`, the prose round loop, and a standalone
777
+ // `/build` all compiled an order carrying a write contract that nothing enforced. A substrate
778
+ // that is only enforced on the lane that also happens to be the most supervised one is the wrong
779
+ // way round. This is now the pointer's ONLY author.
780
780
  //
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.
781
+ // Compiling an order is the moment the write contract comes into existence, so it is the correct
782
+ // moment to publish it. What the pointer supplies is the run's slug; which of that run's orders
783
+ // are LIVE is derived from the order set (compiled, not yet answered), never from this file — so
784
+ // republishing it on every compile costs nothing and a stale one fences nothing.
785
785
  //
786
786
  // Best-effort, on stderr, and never fatal: a compiled order that cannot publish its pointer is
787
787
  // 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. */
@@ -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.1",
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"
@@ -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 |