shapeup-sdlc 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +36 -103
- package/README.md +60 -67
- package/SECURITY.md +31 -16
- package/bin/init.mjs +24 -69
- package/commands/build.md +19 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +37 -4
- package/hooks/anti-rationalization.mjs +7 -13
- package/hooks/compact-snapshot.mjs +5 -5
- package/hooks/gate-deadline.mjs +12 -16
- package/hooks/gate-intake.mjs +13 -14
- package/hooks/gate-l2.mjs +3 -3
- package/hooks/gate-zerowork.mjs +97 -16
- package/hooks/lib/decision.mjs +21 -13
- package/hooks/safety-spine.mjs +1 -1
- package/hooks/sandbox-guard.mjs +83 -47
- package/hooks/session-rehydrate.mjs +8 -9
- package/hooks/slop-cleaner.mjs +1 -1
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +1 -1
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +1 -1
- package/oracles/snapshot-oracle.mjs +1 -1
- package/oracles/test-oracle.mjs +1 -1
- package/package.json +3 -7
- package/skills/ba-pitch-analyzer/SKILL.md +20 -15
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +1 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +6 -6
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +5 -5
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +2 -2
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +9 -39
- package/skills/scope-architect/SKILL.md +17 -12
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +18 -11
- package/skills/spec-evaluator/SKILL.md +12 -5
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +1 -1
- package/skills/task-executor/SKILL.md +25 -12
- package/skills/tech-lead/SKILL.md +115 -438
- package/skills/tech-lead/references/delegation.md +38 -31
- package/skills/tech-lead/references/gates.md +75 -33
- package/skills/tech-lead/references/hard-rules.md +34 -0
- package/skills/tech-lead/references/invocation.md +2 -2
- package/skills/tech-lead/references/ledger-schema.md +17 -18
- package/skills/tech-lead/references/round-protocol.md +77 -27
- package/skills/tech-lead/references/state-model.md +1 -1
- package/skills/tech-lead/schemas/domain.schema.json +320 -148
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-result.schema.json +55 -17
- package/skills/tech-lead/scripts/aegis-digest.mjs +1 -1
- package/skills/tech-lead/scripts/budget-check.mjs +7 -13
- package/skills/tech-lead/scripts/compile-order.mjs +69 -22
- package/skills/tech-lead/scripts/fit-check.mjs +10 -10
- package/skills/tech-lead/scripts/gate-answers.mjs +11 -12
- package/skills/tech-lead/scripts/hill-derive.mjs +133 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +7 -19
- package/skills/tech-lead/scripts/init-run.mjs +60 -18
- package/skills/tech-lead/scripts/lib/argv.mjs +1 -1
- package/skills/tech-lead/scripts/lib/contract-md.mjs +10 -10
- package/skills/tech-lead/scripts/lib/is-main.mjs +11 -12
- package/skills/tech-lead/scripts/lib/paths.mjs +15 -13
- package/skills/tech-lead/scripts/resume-state.mjs +387 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +1 -6
- package/skills/tech-lead/scripts/run-workflow.mjs +381 -0
- package/skills/tech-lead/scripts/ship-report.mjs +33 -2
- package/skills/tech-lead/scripts/stats.mjs +9 -10
- package/skills/tech-lead/scripts/t0-verify.mjs +4 -4
- package/skills/tech-lead/scripts/trace-lint.mjs +2 -2
- package/skills/tech-lead/workflows/shapeup-run.js +911 -0
- package/skills/translator/SKILL.md +2 -2
- package/skills/advisor-protocol/SKILL.md +0 -171
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-workflow — a Bash-invoked control plane for Workflow-format orchestrator scripts.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. The `Workflow` tool — the only lane for scoped specs — is denied in a
|
|
5
|
+
// headless session with "Review dynamic workflow before running". Left to it, `shapeup-run.js`
|
|
6
|
+
// executes zero times and the agent improvises the feature by hand instead; a session can reach
|
|
7
|
+
// GATE L4 with a valid receipt while the lane never started. Bash HAS a path-scoped grantable
|
|
8
|
+
// prefix, and `npx shapeup-sdlc init` already
|
|
9
|
+
// writes exactly that rule (`Bash(node ${CLAUDE_PLUGIN_ROOT}/skills/<owner>/scripts/:*)` —
|
|
10
|
+
// bin/init.mjs mergePipelinePermissions), so this file runs the SAME Workflow-format script through
|
|
11
|
+
// a surface the install already grants.
|
|
12
|
+
//
|
|
13
|
+
// ⟐ ONE CORRECTION, from probing the permission layer rather than concluding from denials. It is
|
|
14
|
+
// NOT true that no permission string can grant the tool: a bare `"Workflow"` entry in
|
|
15
|
+
// `permissions.allow` grants it, and with that entry removed the same call is denied. So the tool
|
|
16
|
+
// was never ungrantable — the installer simply never wrote the entry, because it writes Bash
|
|
17
|
+
// prefixes only. The defect is real, and it is an INSTALLER defect: the plugin never granted the
|
|
18
|
+
// permission its own lane needs.
|
|
19
|
+
//
|
|
20
|
+
// TWO THINGS SURVIVE THE CORRECTION, and they are the reason this file still exists rather than a
|
|
21
|
+
// one-line change to `init`:
|
|
22
|
+
// 1. THE GRANT CANNOT BE SCOPED. `Workflow(<path>)` and `Workflow(<script>)` are both denied —
|
|
23
|
+
// only the bare token works, which grants EVERY dynamic workflow script in the project,
|
|
24
|
+
// including one a model writes at runtime. A harness whose thesis is "gates the agent cannot
|
|
25
|
+
// talk its way past" should not ask for blanket dynamic-code execution. The Bash prefix is
|
|
26
|
+
// path-scoped to this directory.
|
|
27
|
+
// 2. It costs no new grant at all: existing installs already allow it.
|
|
28
|
+
// The one-line `"Workflow"` grant remains a legitimate alternative for anyone who prefers the
|
|
29
|
+
// native runtime's resume/isolation, and the upgrade notes document it as such. It is a choice
|
|
30
|
+
// with a real trade-off, which is why it is documented rather than silently taken.
|
|
31
|
+
//
|
|
32
|
+
// IT LIVES IN `scripts/` FOR A LOAD-BEARING REASON, not a filing one. The grant `init` already
|
|
33
|
+
// writes is a PREFIX rule over this exact directory, so shipping the launcher here means every
|
|
34
|
+
// install that ever ran `npx shapeup-sdlc init` can already start the lane — zero new permission
|
|
35
|
+
// strings, zero migration for existing users. Putting it anywhere else would require a new grant
|
|
36
|
+
// and reproduce the same denial one directory over. The structural suite asserts that the
|
|
37
|
+
// documented call site is covered by a prefix `bin/init.mjs` actually writes.
|
|
38
|
+
//
|
|
39
|
+
// PROVENANCE: prototyped and proven before it shipped — a headless `acceptEdits` session runs the
|
|
40
|
+
// lane through a granted Bash prefix with zero denials, this loader executes the unmodified
|
|
41
|
+
// `shapeup-run.js`, and a real worker dispatches under `acceptEdits`.
|
|
42
|
+
//
|
|
43
|
+
// WHAT IT PROVIDES to the script — the Workflow runtime surface shapeup-run.js actually uses:
|
|
44
|
+
// args, agent(prompt, {label, phase, schema, model, effort}), parallel(thunks),
|
|
45
|
+
// pipeline(items, ...stages), phase(title), log(msg), budget, workflow() [stub — throws].
|
|
46
|
+
// Scripts keep the Workflow tool's contract: `export const meta = {...}` + a bare top-level body
|
|
47
|
+
// with top-level `await` and `return`. The loader rewrites the one export and wraps the body in an
|
|
48
|
+
// AsyncFunction; nothing about the script format changes, which is the point — shapeup-run.js runs
|
|
49
|
+
// through this file byte-identical to how it ships.
|
|
50
|
+
//
|
|
51
|
+
// HOW agent() DISPATCHES. Each call spawns a fresh headless CLI session:
|
|
52
|
+
// claude -p <prompt> --model <m> --output-format json --permission-mode acceptEdits
|
|
53
|
+
// (detached process group, SIGTERM-then-SIGKILL escalation on timeout, so a hook grandchild can
|
|
54
|
+
// never hold a dead worker's pipe open — without that, a time-capped session can outlive its cap
|
|
55
|
+
// many times over). Workers are
|
|
56
|
+
// stateless, craft-only, pipeline-blind (the envelope port) — fresh processes fit that contract
|
|
57
|
+
// exactly; nothing here shares context between dispatches. `schema` is enforced by instruction +
|
|
58
|
+
// parse + shallow validation + one retry; a worker that still fails returns null, which is the
|
|
59
|
+
// Workflow tool's own documented behavior for a dead subagent and the case every shapeup-run.js
|
|
60
|
+
// call site already survives (mechEnvelope).
|
|
61
|
+
//
|
|
62
|
+
// DIVERGENCES from the Workflow tool, stated rather than silent. None is reached by
|
|
63
|
+
// `shapeup-run.js`, which is why this lane can carry it today; each is a real gap for any other
|
|
64
|
+
// script, and a reader deciding whether to write one should read this list as a limit, not a note:
|
|
65
|
+
// - budget counts USD (summed from each worker envelope's total_cost_usd), not output tokens.
|
|
66
|
+
// Interface is identical: {total, spent(), remaining()}; total comes from --budget-usd.
|
|
67
|
+
// - Date.now()/Math.random() are NOT banned inside scripts. The tool bans them for replay-safe
|
|
68
|
+
// resume; this file journals every dispatch (journal.jsonl) but does not implement
|
|
69
|
+
// resume-from-journal. shapeup-run.js resumes from DISK state by design, not from the
|
|
70
|
+
// journal — which is why the lane's kill/resume story (`kill-resume-probe: PASS`, four
|
|
71
|
+
// assertions on a live SIGKILL) does not depend on this and survives the surface swap.
|
|
72
|
+
// - workflow() (child workflows) throws. shapeup-run.js inlines its round loop and never calls it.
|
|
73
|
+
// - isolation: 'worktree' throws. shapeup-run.js is sequential today (design doc D3).
|
|
74
|
+
// - schema validation is shallow (type + required keys + declared property types, one level).
|
|
75
|
+
//
|
|
76
|
+
// A RUN OUTLIVES A FOREGROUND TOOL CALL. A real pipeline runs for tens of minutes; every
|
|
77
|
+
// foreground Bash call has a ceiling well below that. Launch it as a BACKGROUND Bash call and read
|
|
78
|
+
// `<run-dir>/result.json`, which this file writes on completion with the same `{ok, result}` shape
|
|
79
|
+
// stdout carries. Headless callers must also set `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0`, or the
|
|
80
|
+
// wait is cut at 600 s and a truncated run is reported as a clean one.
|
|
81
|
+
//
|
|
82
|
+
// STDOUT DISCIPLINE: stdout carries exactly one JSON line — {ok, result} — because whatever
|
|
83
|
+
// launched this process (a mech courier, an outer session, a test) reads stdout as data. All
|
|
84
|
+
// narration goes to stderr.
|
|
85
|
+
//
|
|
86
|
+
// exit 0: script completed; stdout = {"ok":true,"result":...}
|
|
87
|
+
// exit 1: script threw or the launch failed after parsing; stdout = {"ok":false,"error":...}
|
|
88
|
+
// exit 2: argv rejected before anything ran (lib/argv.mjs) — nothing spawned, nothing written.
|
|
89
|
+
|
|
90
|
+
import { spawn } from "node:child_process";
|
|
91
|
+
import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
92
|
+
import { resolve, basename } from "node:path";
|
|
93
|
+
import process from "node:process";
|
|
94
|
+
import { runArgs } from "./lib/argv.mjs";
|
|
95
|
+
import { isMain } from "./lib/is-main.mjs";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The typed argv boundary.
|
|
99
|
+
*
|
|
100
|
+
* FAILING CLOSED IS THE POINT HERE, not hygiene. The cost of a silent launch failure is not that the lane
|
|
101
|
+
* refused to start — it is that the refusal was quiet enough for an agent to route around, so a
|
|
102
|
+
* run that never happened reported like one that did. A launcher that accepts `--max-concurrency`
|
|
103
|
+
* with no value and proceeds on `NaN` is the same shape of defect: this rejects at exit 2 with a
|
|
104
|
+
* machine-readable reason before a single worker is spawned or a run directory created.
|
|
105
|
+
*/
|
|
106
|
+
export const ARGV_SPEC = {
|
|
107
|
+
usage: 'run-workflow.mjs <workflow-script.js> [--args <json> | --args-file <path>] ' +
|
|
108
|
+
"[--run-dir <dir>] [--worker-permission-mode <mode>] [--worker-cwd <dir>] " +
|
|
109
|
+
"[--max-concurrency <n>] [--agent-timeout-s <n>] [--budget-usd <n>]",
|
|
110
|
+
_: { arity: 1, name: "workflow-script.js" },
|
|
111
|
+
args: { type: "json" },
|
|
112
|
+
"args-file": { type: "path" },
|
|
113
|
+
"run-dir": { type: "path" },
|
|
114
|
+
"worker-permission-mode": { type: "enum", values: ["acceptEdits", "default", "plan", "bypassPermissions"] },
|
|
115
|
+
"worker-cwd": { type: "path" },
|
|
116
|
+
"max-concurrency": { type: "int", min: 1, max: 32 },
|
|
117
|
+
"agent-timeout-s": { type: "int", min: 1 },
|
|
118
|
+
"budget-usd": { type: "num", min: 0 },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve parsed argv into the loader's configuration.
|
|
123
|
+
* @param {object} a - The object `runArgs(ARGV_SPEC)` returns.
|
|
124
|
+
* @returns {object} Fully defaulted, absolute-path configuration.
|
|
125
|
+
*/
|
|
126
|
+
export function configure(a) {
|
|
127
|
+
const script = resolve(a._[0]);
|
|
128
|
+
return {
|
|
129
|
+
script,
|
|
130
|
+
args: a.argsFile ? JSON.parse(readFileSync(a.argsFile, "utf8")) : (a.args ?? {}),
|
|
131
|
+
runDir: a.runDir
|
|
132
|
+
? resolve(a.runDir)
|
|
133
|
+
: resolve(`.run-workflow-${basename(script).replace(/\.[^.]+$/, "")}-${process.pid}`),
|
|
134
|
+
workerPermissionMode: a.workerPermissionMode || "acceptEdits",
|
|
135
|
+
workerCwd: a.workerCwd ? resolve(a.workerCwd) : process.cwd(),
|
|
136
|
+
maxConcurrency: a.maxConcurrency ?? 4,
|
|
137
|
+
agentTimeoutS: a.agentTimeoutS ?? 900,
|
|
138
|
+
budgetUsd: a.budgetUsd ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------------------------
|
|
143
|
+
// Loader — the Workflow tool's script format, executed as-is. One rewrite (`export const meta`
|
|
144
|
+
// -> `const meta`), then the whole body becomes an AsyncFunction so top-level `await` and
|
|
145
|
+
// top-level `return` mean exactly what the tool defines them to mean.
|
|
146
|
+
// ---------------------------------------------------------------------------------------------
|
|
147
|
+
function loadWorkflow(path) {
|
|
148
|
+
let src = readFileSync(path, "utf8");
|
|
149
|
+
src = src.replace(/^export\s+const\s+meta\s*=/m, "const meta =");
|
|
150
|
+
if (/^export\s/m.test(src)) {
|
|
151
|
+
throw new Error(`${basename(path)}: unsupported export — Workflow scripts export only \`const meta\``);
|
|
152
|
+
}
|
|
153
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
154
|
+
return new AsyncFunction(
|
|
155
|
+
"args", "agent", "parallel", "pipeline", "phase", "log", "budget", "workflow",
|
|
156
|
+
`"use strict";\n${src}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------------------------
|
|
161
|
+
// Concurrency — a plain semaphore; excess agent() calls queue, mirroring the tool's cap.
|
|
162
|
+
// ---------------------------------------------------------------------------------------------
|
|
163
|
+
function makeSemaphore(max) {
|
|
164
|
+
let active = 0; const queue = [];
|
|
165
|
+
const release = () => { active--; const next = queue.shift(); if (next) { active++; next(); } };
|
|
166
|
+
const acquire = () => new Promise((res) => {
|
|
167
|
+
if (active < max) { active++; res(); } else queue.push(res);
|
|
168
|
+
});
|
|
169
|
+
return { acquire, release };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------------------------
|
|
173
|
+
// Worker spawn — one headless CLI session per dispatch. Process-group kill on timeout
|
|
174
|
+
// (hook grandchildren keep pipes open, so
|
|
175
|
+
// signaling one pid is not a cap).
|
|
176
|
+
// ---------------------------------------------------------------------------------------------
|
|
177
|
+
const KILL_GRACE_MS = 10_000;
|
|
178
|
+
|
|
179
|
+
function runClaude({ prompt, model, permissionMode, cwd, timeoutMs }) {
|
|
180
|
+
return new Promise((resolvePromise) => {
|
|
181
|
+
const child = spawn("claude", [
|
|
182
|
+
"-p", prompt,
|
|
183
|
+
"--model", model,
|
|
184
|
+
"--output-format", "json",
|
|
185
|
+
"--permission-mode", permissionMode,
|
|
186
|
+
], { cwd, env: process.env, detached: true });
|
|
187
|
+
|
|
188
|
+
let stdout = "", stderr = "", settled = false, killed = false;
|
|
189
|
+
const settle = (r) => { if (!settled) { settled = true; clearTimeout(timer); clearTimeout(hardTimer); resolvePromise(r); } };
|
|
190
|
+
|
|
191
|
+
let hardTimer = null;
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
killed = true;
|
|
194
|
+
try { process.kill(-child.pid, "SIGTERM"); } catch { /* already gone */ }
|
|
195
|
+
hardTimer = setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch { /* already gone */ } }, KILL_GRACE_MS);
|
|
196
|
+
}, timeoutMs);
|
|
197
|
+
|
|
198
|
+
child.stdout.on("data", (d) => { stdout += d; });
|
|
199
|
+
child.stderr.on("data", (d) => { stderr += d; });
|
|
200
|
+
child.on("error", (e) => settle({ ok: false, error: `cannot spawn claude: ${e.message}`, killed, stdout, stderr }));
|
|
201
|
+
child.on("close", (code) => {
|
|
202
|
+
let envelope = null;
|
|
203
|
+
try { envelope = JSON.parse(stdout.trim()); } catch { /* non-JSON stdout stays raw */ }
|
|
204
|
+
settle({ ok: code === 0 && envelope !== null && envelope.is_error !== true, code, killed, envelope, stdout, stderr });
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------------------------
|
|
210
|
+
// Structured output — instruction + extraction + shallow validation + one retry.
|
|
211
|
+
// Extraction is parseMechJson's balanced-scan (shapeup-run.js:176) — proven against couriers
|
|
212
|
+
// that wrap clean JSON in commentary; the same failure mode applies to whole workers.
|
|
213
|
+
// ---------------------------------------------------------------------------------------------
|
|
214
|
+
function extractJson(text) {
|
|
215
|
+
if (typeof text !== "string") return null;
|
|
216
|
+
const s = text.trim();
|
|
217
|
+
try { return JSON.parse(s); } catch { /* fall through to extraction */ }
|
|
218
|
+
const start = s.search(/[{[]/);
|
|
219
|
+
if (start < 0) return null;
|
|
220
|
+
const open = s[start], close = open === "{" ? "}" : "]";
|
|
221
|
+
let depth = 0, inStr = false, esc = false;
|
|
222
|
+
for (let i = start; i < s.length; i++) {
|
|
223
|
+
const c = s[i];
|
|
224
|
+
if (inStr) {
|
|
225
|
+
if (esc) esc = false;
|
|
226
|
+
else if (c === "\\") esc = true;
|
|
227
|
+
else if (c === '"') inStr = false;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (c === '"') { inStr = true; continue; }
|
|
231
|
+
if (c === open) depth++;
|
|
232
|
+
else if (c === close && --depth === 0) {
|
|
233
|
+
try { return JSON.parse(s.slice(start, i + 1)); } catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function shallowValidate(schema, value) {
|
|
240
|
+
const problems = [];
|
|
241
|
+
if (!schema || typeof schema !== "object") return problems;
|
|
242
|
+
if (schema.type === "object") {
|
|
243
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
244
|
+
return [`expected object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`];
|
|
245
|
+
}
|
|
246
|
+
for (const k of schema.required || []) if (!(k in value)) problems.push(`missing required key "${k}"`);
|
|
247
|
+
for (const [k, sub] of Object.entries(schema.properties || {})) {
|
|
248
|
+
if (!(k in value) || !sub.type) continue;
|
|
249
|
+
const v = value[k];
|
|
250
|
+
const t = sub.type === "integer" ? (Number.isInteger(v) ? "integer" : typeof v)
|
|
251
|
+
: Array.isArray(v) ? "array" : typeof v;
|
|
252
|
+
if (t !== sub.type && !(sub.type === "number" && typeof v === "number")) {
|
|
253
|
+
problems.push(`key "${k}": expected ${sub.type}, got ${t}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else if (schema.type === "array" && !Array.isArray(value)) {
|
|
257
|
+
problems.push(`expected array, got ${typeof value}`);
|
|
258
|
+
}
|
|
259
|
+
return problems;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const schemaInstruction = (schema) =>
|
|
263
|
+
"\n\n---\nSTRUCTURED OUTPUT REQUIRED. Your final reply must be ONLY a single JSON value that " +
|
|
264
|
+
"validates against this JSON Schema — no prose, no markdown fences, nothing before or after " +
|
|
265
|
+
`it:\n${JSON.stringify(schema)}`;
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------------------------
|
|
268
|
+
// main
|
|
269
|
+
// ---------------------------------------------------------------------------------------------
|
|
270
|
+
async function main() {
|
|
271
|
+
const cli = configure(runArgs(ARGV_SPEC));
|
|
272
|
+
mkdirSync(cli.runDir, { recursive: true });
|
|
273
|
+
const journalPath = resolve(cli.runDir, "journal.jsonl");
|
|
274
|
+
const journal = (entry) => appendFileSync(journalPath, JSON.stringify(entry) + "\n");
|
|
275
|
+
const note = (m) => process.stderr.write(`[run-workflow] ${m}\n`);
|
|
276
|
+
|
|
277
|
+
const sem = makeSemaphore(cli.maxConcurrency);
|
|
278
|
+
let seq = 0, costAccum = 0, currentPhase = null;
|
|
279
|
+
|
|
280
|
+
const budget = {
|
|
281
|
+
total: cli.budgetUsd,
|
|
282
|
+
spent: () => costAccum,
|
|
283
|
+
remaining: () => (cli.budgetUsd == null ? Infinity : Math.max(0, cli.budgetUsd - costAccum)),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
async function agent(prompt, opts = {}) {
|
|
287
|
+
const id = ++seq;
|
|
288
|
+
const label = opts.label || `agent-${id}`;
|
|
289
|
+
const model = opts.model || "sonnet";
|
|
290
|
+
const phaseName = opts.phase || currentPhase;
|
|
291
|
+
if (budget.total != null && budget.remaining() <= 0) {
|
|
292
|
+
throw new Error(`budget exhausted ($${costAccum.toFixed(3)} of $${budget.total}) before agent "${label}"`);
|
|
293
|
+
}
|
|
294
|
+
if (opts.isolation) throw new Error(`agent "${label}": isolation: 'worktree' is not implemented on this lane`);
|
|
295
|
+
await sem.acquire();
|
|
296
|
+
const startedAt = new Date().toISOString();
|
|
297
|
+
const t0 = process.hrtime.bigint();
|
|
298
|
+
try {
|
|
299
|
+
let fullPrompt = opts.schema ? prompt + schemaInstruction(opts.schema) : prompt;
|
|
300
|
+
let result = null, attempts = 0, sessions = [];
|
|
301
|
+
while (attempts < 2) {
|
|
302
|
+
attempts++;
|
|
303
|
+
note(`agent#${id} "${label}" attempt ${attempts} (model=${model}, mode=${cli.workerPermissionMode})`);
|
|
304
|
+
const r = await runClaude({
|
|
305
|
+
prompt: fullPrompt, model, permissionMode: cli.workerPermissionMode,
|
|
306
|
+
cwd: cli.workerCwd, timeoutMs: cli.agentTimeoutS * 1000,
|
|
307
|
+
});
|
|
308
|
+
const env = r.envelope || {};
|
|
309
|
+
sessions.push({ session_id: env.session_id ?? null, cost_usd: env.total_cost_usd ?? null, is_error: env.is_error ?? null, killed: r.killed });
|
|
310
|
+
if (typeof env.total_cost_usd === "number") costAccum += env.total_cost_usd;
|
|
311
|
+
if (!r.ok) { result = null; if (r.killed) break; continue; }
|
|
312
|
+
if (!opts.schema) { result = env.result ?? null; break; }
|
|
313
|
+
const parsed = extractJson(env.result);
|
|
314
|
+
const problems = parsed === null ? ["reply contained no parseable JSON"] : shallowValidate(opts.schema, parsed);
|
|
315
|
+
if (problems.length === 0) { result = parsed; break; }
|
|
316
|
+
note(`agent#${id} "${label}" schema problems: ${problems.join("; ")}`);
|
|
317
|
+
fullPrompt = prompt + schemaInstruction(opts.schema) +
|
|
318
|
+
`\n\nA previous attempt failed validation: ${problems.join("; ")}. Correct this.`;
|
|
319
|
+
result = null;
|
|
320
|
+
}
|
|
321
|
+
const wallMs = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
322
|
+
journal({ seq: id, label, phase: phaseName, model, permission_mode: cli.workerPermissionMode,
|
|
323
|
+
started_at: startedAt, wall_ms: Math.round(wallMs), attempts, sessions, ok: result !== null, result });
|
|
324
|
+
return result;
|
|
325
|
+
} finally {
|
|
326
|
+
sem.release();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Thunk errors resolve to null and the call never rejects — the Workflow tool's contract,
|
|
331
|
+
// which shapeup-run.js's call sites (mechEnvelope, dispatch guards) are written against.
|
|
332
|
+
const parallel = (thunks) => Promise.all(
|
|
333
|
+
thunks.map((t) => Promise.resolve().then(t).catch((e) => { note(`parallel thunk failed: ${e.message}`); return null; }))
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// No barrier between stages; a stage that throws drops the item to null and skips the rest.
|
|
337
|
+
const pipeline = (items, ...stages) => Promise.all(
|
|
338
|
+
items.map(async (item, i) => {
|
|
339
|
+
let acc = item;
|
|
340
|
+
for (const stage of stages) {
|
|
341
|
+
try { acc = await stage(acc, item, i); }
|
|
342
|
+
catch (e) { note(`pipeline item ${i} failed: ${e.message}`); return null; }
|
|
343
|
+
}
|
|
344
|
+
return acc;
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const phase = (title) => { currentPhase = title; note(`── phase: ${title}`); };
|
|
349
|
+
const log = (m) => note(String(m));
|
|
350
|
+
const workflow = () => { throw new Error("workflow() child workflows are not implemented on this lane"); };
|
|
351
|
+
|
|
352
|
+
const fn = loadWorkflow(cli.script);
|
|
353
|
+
note(`running ${basename(cli.script)} (run-dir ${cli.runDir})`);
|
|
354
|
+
const result = await fn(cli.args, agent, parallel, pipeline, phase, log, budget, workflow);
|
|
355
|
+
|
|
356
|
+
const summary = { ok: true, script: basename(cli.script), agents_dispatched: seq,
|
|
357
|
+
cost_usd: Number(costAccum.toFixed(6)), result };
|
|
358
|
+
writeFileSync(resolve(cli.runDir, "result.json"), JSON.stringify(summary, null, 2) + "\n");
|
|
359
|
+
process.stdout.write(JSON.stringify({ ok: true, result }) + "\n");
|
|
360
|
+
return cli.runDir;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (isMain(import.meta.url)) {
|
|
364
|
+
main().catch((e) => {
|
|
365
|
+
// The failure is written where a background caller will look for it, not only to a stdout
|
|
366
|
+
// nobody is reading: a launch that dies silently is the exact shape this file exists to end.
|
|
367
|
+
process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
|
|
368
|
+
process.stderr.write(`[run-workflow] FATAL ${e.stack}\n`);
|
|
369
|
+
try {
|
|
370
|
+
const dir = process.argv.includes("--run-dir")
|
|
371
|
+
? resolve(process.argv[process.argv.indexOf("--run-dir") + 1])
|
|
372
|
+
: null;
|
|
373
|
+
if (dir) {
|
|
374
|
+
mkdirSync(dir, { recursive: true });
|
|
375
|
+
writeFileSync(resolve(dir, "result.json"),
|
|
376
|
+
JSON.stringify({ ok: false, error: e.message }, null, 2) + "\n");
|
|
377
|
+
}
|
|
378
|
+
} catch { /* the stdout line above is still the record */ }
|
|
379
|
+
process.exit(1);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
roundLedger, discoveryLedger, receipt as receiptPath, harnessRun, relShared,
|
|
36
36
|
} from "./lib/paths.mjs";
|
|
37
37
|
import { readTrials } from "./t0-verify.mjs";
|
|
38
|
+
import { ratchetReport } from "./stats.mjs";
|
|
38
39
|
|
|
39
40
|
/** @returns {string} Today as `YYYY-MM-DD` (UTC). */
|
|
40
41
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
@@ -164,7 +165,7 @@ export function section(md, heading) {
|
|
|
164
165
|
*/
|
|
165
166
|
export function buildReport(facts) {
|
|
166
167
|
const {
|
|
167
|
-
slug, at, verdict, qa, rounds, board, t0, artifacts,
|
|
168
|
+
slug, at, verdict, qa, rounds, board, t0, artifacts, ratchet,
|
|
168
169
|
evalCriteria, evalBugs, qaFindings, decisions, discovered, intakeSha,
|
|
169
170
|
} = facts;
|
|
170
171
|
|
|
@@ -202,6 +203,35 @@ export function buildReport(facts) {
|
|
|
202
203
|
L.push("");
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// The ratchet aggregate is derived, ~10 scalars that do not grow with the run, which is why it
|
|
207
|
+
// can live in the committed tier while `metrics/` correctly stays gitignored (ADR-0001: a
|
|
208
|
+
// committed shard keyed on $HOSTNAME only grows). Without this the instrument existed and was
|
|
209
|
+
// never read — the trial ledger it reduces is harvested at SHIP or lost with the local tier.
|
|
210
|
+
if (ratchet && ratchet.trials > 0) {
|
|
211
|
+
L.push("## Ratchet", "");
|
|
212
|
+
L.push("Measured over this run's trial ledger. A monotone series is a ratchet working; a flat or",
|
|
213
|
+
"sawtooth series says the loop is still a budgeted retry loop wearing a ratchet's shape.", "");
|
|
214
|
+
L.push("| | |", "|---|---|");
|
|
215
|
+
L.push(`| Trials | ${ratchet.trials} across ${ratchet.scopes} scope(s), ${ratchet.scopes_multi_trial} with more than one attempt |`);
|
|
216
|
+
L.push(`| Improvement rate | ${ratchet.improvement_rate} — kept ÷ trials after the first |`);
|
|
217
|
+
L.push(`| Monotone rate | ${ratchet.monotone_rate} — multi-trial scopes whose score never decreased |`);
|
|
218
|
+
L.push(`| Sawtooth count | ${ratchet.sawtooth_count} — a revert immediately after a keep |`);
|
|
219
|
+
L.push(`| Mean trials to green | ${ratchet.mean_trials_to_green ?? "— (no scope reached green)"} |`);
|
|
220
|
+
const hist = Object.entries(ratchet.status_histogram).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
221
|
+
L.push(`| Statuses | ${hist || "—"} |`);
|
|
222
|
+
L.push("");
|
|
223
|
+
// A zero improvement_rate means one of two opposite things, and the number alone cannot say
|
|
224
|
+
// which: the loop tried again and failed to improve, or nothing ever needed a second attempt.
|
|
225
|
+
// Measured on the first two real runs — every scope went green on attempt 1 — so the reading a
|
|
226
|
+
// reviewer meets by default is the degenerate one. `stats --ratchet` says so on the terminal;
|
|
227
|
+
// the committed report has to say it too, or a 0 here is read as "the ratchet did not work".
|
|
228
|
+
if (ratchet.scopes_multi_trial === 0) {
|
|
229
|
+
L.push("> No scope needed a second attempt, so the rates above are vacuous rather than bad:",
|
|
230
|
+
"> the ratchet was never asked to climb. The Day-1 question — does the loop measurably",
|
|
231
|
+
"> improve across attempts — needs a run where at least one scope retries.", "");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
205
235
|
if (evalCriteria) L.push("## Evaluation", "", evalCriteria, "");
|
|
206
236
|
if (evalBugs) L.push("### Refuted criteria and bugs", "", evalBugs, "");
|
|
207
237
|
if (qaFindings) L.push("## QA findings", "", qaFindings, "");
|
|
@@ -210,7 +240,7 @@ export function buildReport(facts) {
|
|
|
210
240
|
|
|
211
241
|
L.push("---", "",
|
|
212
242
|
"*Run state (board, orders, results, T0 artifacts, evaluation and QA reports) stays in the",
|
|
213
|
-
"gitignored local tier
|
|
243
|
+
"gitignored local tier (ADR-0001). This report",
|
|
214
244
|
"is the frozen conclusion of it.*", "");
|
|
215
245
|
return L.join("\n");
|
|
216
246
|
}
|
|
@@ -238,6 +268,7 @@ export function generate({ cwd, slug, verdict, qa }) {
|
|
|
238
268
|
intakeSha: receipt.intake_sha256,
|
|
239
269
|
board: boardCensus(cwd, slug),
|
|
240
270
|
t0: t0Summary(cwd, slug),
|
|
271
|
+
ratchet: ratchetReport(readTrials(trials(cwd, slug))),
|
|
241
272
|
artifacts: verdictArtifactCount(cwd, slug),
|
|
242
273
|
evalCriteria: section(evalReport, /^#+\s.*criteria/i) || section(evalReport, /^#+\s*spec-conformance/i),
|
|
243
274
|
evalBugs: section(evalReport, /^#+\s*Bugs?\b/i),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Stats — the telemetry read-plane (v1.2
|
|
2
|
+
// Stats — the telemetry read-plane (v1.2).
|
|
3
3
|
//
|
|
4
|
-
// SHIP S.6 has been writing fact rows to shapeup/metrics/<machine-id>.jsonl since
|
|
4
|
+
// SHIP S.6 has been writing fact rows to .shapeup/metrics/<machine-id>.jsonl since
|
|
5
5
|
// v0.x with exactly one documented reader: `cat *.jsonl`. This script is the missing
|
|
6
6
|
// projection: rounds per pitch, hammer-cut rate, attempt-budget exhaustions, QA promotion
|
|
7
7
|
// rate, and the round_count trend — the "is the KB flywheel actually working?" chart.
|
|
@@ -149,10 +149,10 @@ export function aggregate({ rows, pathologies, sources, rows_malformed }, { metr
|
|
|
149
149
|
// they reduce over artifacts the harness already writes while doing its ordinary work.
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* `--ratchet` —
|
|
152
|
+
* `--ratchet` — does the build loop actually ratchet?
|
|
153
153
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
154
|
+
* The question harness-versus-bare-agent is already answered elsewhere. This one is THE LOOP
|
|
155
|
+
* VERSUS ITS OWN FIRST ATTEMPT, and it cannot be
|
|
156
156
|
* won by a one-sentence control, because a one-sentence control has no second attempt to compare.
|
|
157
157
|
*
|
|
158
158
|
* A monotone series is a ratchet working. A flat or sawtooth series says the loop is still a
|
|
@@ -220,12 +220,11 @@ export function ratchetReport(trials) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
|
-
* `--hooks` —
|
|
223
|
+
* `--hooks` — do the enforcement points actually fire?
|
|
224
224
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* ("never had to fire after the fix") become SEPARABLE FACTS rather than the same blank.
|
|
225
|
+
* For a hook, "never had to fire" and "never ran" used to produce the same evidence: exit 0,
|
|
226
|
+
* empty stdout. With a decision row per evaluation, a hook that sat inert and a hook that
|
|
227
|
+
* inspected-and-permitted become SEPARABLE FACTS rather than the same blank.
|
|
229
228
|
*
|
|
230
229
|
* @param {Array<object>} decisions - Rows from `.shapeup/decisions.jsonl`.
|
|
231
230
|
* @returns {{evaluations:number, hooks:number, per_hook:Array<object>}} Per-hook fire, allow, deny,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// T0 mechanical verification layer
|
|
2
|
+
// T0 mechanical verification layer.
|
|
3
3
|
//
|
|
4
4
|
// Runs a scope's e2e fixtures + DB probe (zero LLM tokens), then — on green — the seesaw
|
|
5
5
|
// regression check (re-runs every FINISHED scope's fixtures from the registry). Writes one
|
|
6
6
|
// verdict artifact per attempt that spec-evaluator (T1) must cite; a verdict without it is
|
|
7
|
-
// structurally invalid
|
|
7
|
+
// structurally invalid. No agent can fabricate this file's contents
|
|
8
8
|
// because it is produced by actually running the commands.
|
|
9
9
|
//
|
|
10
10
|
// Zero dependencies, zero network — same discipline as oracles/* and gate-l2.mjs.
|
|
@@ -167,7 +167,7 @@ export function score({ fixtures, dbProbe, seesaw }) {
|
|
|
167
167
|
* • REGRESSIONS DOMINATE. Breaking a previously-finished scope is never an improvement, whatever
|
|
168
168
|
* the new scope's fixtures did. This is what lets the old seesaw branch collapse into the
|
|
169
169
|
* general rule rather than needing a special case.
|
|
170
|
-
* • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A
|
|
170
|
+
* • DIFFERENT `fixtures_total` IS INCOMPARABLE, not worse. A re-slice changes the
|
|
171
171
|
* denominator; comparing across it is a category error, so the ratchet treats it as a baseline
|
|
172
172
|
* reset (`rebased`) rather than issuing a false verdict.
|
|
173
173
|
*
|
|
@@ -322,7 +322,7 @@ function sha256(text) {
|
|
|
322
322
|
* the retry, so the artifact recording the regression was silently replaced by the one recording
|
|
323
323
|
* the recovery, at the same path. Reproduced against the shipped script: two runs at
|
|
324
324
|
* `--round 1 --attempt 2`, one red and one green, left ONE file — the red verdict was gone. This
|
|
325
|
-
* is the same missing-identity-key defect
|
|
325
|
+
* is the same missing-identity-key defect logged elsewhere in this codebase, recurring on the one
|
|
326
326
|
* artifact the evaluator is structurally required to cite.
|
|
327
327
|
*
|
|
328
328
|
* An `existsSync` guard would be check-then-write: still racy, and still a policy expressed in code
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Traceability oracle (spine v1.3
|
|
2
|
+
// Traceability oracle (spine v1.3).
|
|
3
3
|
//
|
|
4
4
|
// ONE oracle, TWO mechanically-checkable assertions — nothing that merely *asserts* quality:
|
|
5
5
|
//
|
|
@@ -260,7 +260,7 @@ export function traceLint(slug, { cwd, gate = false }) {
|
|
|
260
260
|
}
|
|
261
261
|
if (wiringFound) wiringMap = wiringFound.contract;
|
|
262
262
|
|
|
263
|
-
//
|
|
263
|
+
// A map whose `## Wiring` table is under a heading the parser does not claim reads as
|
|
264
264
|
// zero entries, and the loop below then walks nothing and reports `0/0 engines reach <entry>` —
|
|
265
265
|
// GREEN, for a committed file holding six correct rows. The gate whose entire purpose is that no
|
|
266
266
|
// engine ships orphaned failing open, on a file that looks right to every human who reviews it.
|