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.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +3 -1
- package/README.md +3 -2
- package/SECURITY.md +6 -1
- package/commands/build.md +1 -1
- package/commands/ship.md +1 -1
- package/hooks/gate-zerowork.mjs +2 -2
- package/hooks/sandbox-guard.mjs +82 -29
- package/kernel/compile.mjs +10 -10
- package/kernel/init/run.mjs +80 -2
- package/kernel/lib/paths.mjs +27 -1
- package/kernel/reduce/ship.mjs +11 -1
- package/package.json +1 -1
- package/skills/tech-lead/SKILL.md +8 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapeup-sdlc-plugin",
|
|
3
3
|
"displayName": "ShapeUp SDLC Plugin",
|
|
4
|
-
"version": "3.1.
|
|
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
|
|
223
|
-
|
|
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
|
|
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
package/commands/ship.md
CHANGED
package/hooks/gate-zerowork.mjs
CHANGED
|
@@ -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
|
-
|
|
258
|
+
` scriptPath: "${globWorkflowsStage("shapeup-run.js")}", // staged by \`init run\`, above`,
|
|
259
259
|
" args: <the RunArgs object>",
|
|
260
260
|
" })",
|
|
261
261
|
"",
|
package/hooks/sandbox-guard.mjs
CHANGED
|
@@ -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`
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
*
|
|
112
|
+
* Has this order been answered — i.e. has a result for THIS dispatch landed?
|
|
93
113
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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
|
|
142
|
+
function liveOrders(cwd, slug) {
|
|
105
143
|
const dir = ordersDir(cwd, slug);
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
163
|
-
// in the candidate set would keep a finished scope's substrate open for the rest of the run
|
|
164
|
-
|
|
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
|
-
|
|
296
|
+
hint,
|
|
244
297
|
},
|
|
245
298
|
},
|
|
246
299
|
};
|
package/kernel/compile.mjs
CHANGED
|
@@ -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
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
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
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
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
|
package/kernel/init/run.mjs
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/kernel/lib/paths.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/kernel/reduce/ship.mjs
CHANGED
|
@@ -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
|
@@ -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
|
|
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: "
|
|
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 |
|