shapeup-sdlc 1.6.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +54 -107
- package/README.md +165 -151
- package/SECURITY.md +49 -27
- package/bin/init.mjs +93 -108
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +20 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +53 -7
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +16 -17
- package/hooks/gate-zerowork.mjs +107 -25
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +38 -19
- package/hooks/safety-spine.mjs +4 -4
- package/hooks/sandbox-guard.mjs +130 -50
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
- package/kernel/init/run.mjs +489 -0
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/kernel/probe/resume.mjs +528 -0
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
- package/kernel/reduce/graph.mjs +437 -0
- package/kernel/reduce/hill.mjs +152 -0
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +2 -2
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +2 -2
- package/oracles/snapshot-oracle.mjs +2 -2
- package/oracles/test-oracle.mjs +2 -2
- package/package.json +11 -13
- package/skills/ba-pitch-analyzer/SKILL.md +24 -19
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +15 -41
- package/skills/scope-architect/SKILL.md +57 -18
- 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 +37 -15
- package/skills/spec-evaluator/SKILL.md +24 -7
- 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/task-executor/SKILL.md +32 -18
- package/skills/tech-lead/SKILL.md +127 -438
- package/skills/tech-lead/references/gates.md +140 -49
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +645 -198
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +56 -18
- package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
- package/skills/translator/SKILL.md +2 -2
- package/hooks/anti-rationalization.mjs +0 -244
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -151
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -109
- package/skills/advisor-protocol/SKILL.md +0 -171
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -254
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -214
- package/skills/tech-lead/references/round-protocol.md +0 -184
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
- package/skills/tech-lead/scripts/init-run.mjs +0 -326
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
- package/skills/translator/README.md +0 -66
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// probe leg — "did this scope's work actually reach the board?"
|
|
2
|
+
//
|
|
3
|
+
// CONTRACT. A bounded, read-only query over one scope's orders, results and leg-completion rows for
|
|
4
|
+
// one round. Prints `{closed, scope_id, round, orders: [...], unapplied: [...]}` on stdout; exits 0
|
|
5
|
+
// when every order that produced a result has been ingested, 1 when at least one has not, 2 on a bad
|
|
6
|
+
// argv. Writes nothing.
|
|
7
|
+
//
|
|
8
|
+
// WHY IT EXISTS. `probe t0` answers "is the T0 verdict green", and the BUILD round's confirm stage
|
|
9
|
+
// asked only that. A green T0 says the worker's fixtures ran and passed; it says nothing about
|
|
10
|
+
// whether the WorkResult was applied — and this is a state a real run reaches. A build leg can write
|
|
11
|
+
// its code, a green T0 verdict, a kept trial row and its WorkResult to disk, and never run step 3 of
|
|
12
|
+
// its own script, `reduce ingest`. It reports green; a confirm stage that re-verifies only the T0
|
|
13
|
+
// artifact agrees, and the round walks on. That scope's task is left `pending` with zero acceptance
|
|
14
|
+
// criteria ticked while its code sits finished on disk, beside a sibling scope whose task is `done`.
|
|
15
|
+
//
|
|
16
|
+
// Nothing has to be wrong with the order, the receipt or the result for this to happen — ingesting the
|
|
17
|
+
// same file afterwards succeeds and ticks the criteria. The single writer of shared state simply never
|
|
18
|
+
// ran, and the board GATE L2 reads as "100% ✅" silently disagrees with a scope that is genuinely
|
|
19
|
+
// finished. Under fan-out it gets worse rather than better: N legs can each drop the step
|
|
20
|
+
// independently, and the only symptom is a board that lags reality.
|
|
21
|
+
//
|
|
22
|
+
// WHY A LEG ROW IS THE RIGHT EVIDENCE. The row is appended BY `reduce ingest`. Its presence is
|
|
23
|
+
// therefore proof the writer ran, and it is not something the leg can assert about itself — the same
|
|
24
|
+
// reason the dispatch receipt is written by the hook layer rather than by the sub-agent making the
|
|
25
|
+
// call. "The worker says it ingested" is exactly the class of claim this pipeline does not accept.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
30
|
+
import { ordersDir, resultsDir, legLedger } from "../lib/paths.mjs";
|
|
31
|
+
|
|
32
|
+
/** `<scope>-r<round>-a<attempt>.json` — the only address a build order is written under. */
|
|
33
|
+
const BUILD_ORDER = /^(.+)-r(\d+)-a(\d+)\.json$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every leg-completion row on disk, tolerant of a torn last line.
|
|
37
|
+
* @param {string} path - The leg ledger.
|
|
38
|
+
* @returns {object[]} Parsed rows; an unparsable line is skipped rather than fatal.
|
|
39
|
+
*/
|
|
40
|
+
export function readLegs(path) {
|
|
41
|
+
if (!existsSync(path)) return [];
|
|
42
|
+
return readFileSync(path, "utf8").split("\n").filter(Boolean)
|
|
43
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which of one scope's orders in one round produced a result, and which of those were applied.
|
|
49
|
+
*
|
|
50
|
+
* An order with no result has not finished and is not a finding — the leg may still be running, or
|
|
51
|
+
* may have died, which the round already treats as a spent attempt. What this reports is the
|
|
52
|
+
* narrower and stranger case: a result on disk that the single writer never applied.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} cwd - Project root.
|
|
55
|
+
* @param {string} slug - Feature slug.
|
|
56
|
+
* @param {string} scopeId - Scope contract id.
|
|
57
|
+
* @param {number} round - Build round.
|
|
58
|
+
* @returns {{closed: boolean, orders: object[], unapplied: object[]}} `closed` is false only when a
|
|
59
|
+
* result exists that was never ingested; a scope with no results at all reports `closed: false`
|
|
60
|
+
* with an empty `orders` list, so a caller can tell "nothing ran" from "nothing was applied".
|
|
61
|
+
*/
|
|
62
|
+
export function legState(cwd, slug, scopeId, round) {
|
|
63
|
+
const oDir = ordersDir(cwd, slug);
|
|
64
|
+
const rDir = resultsDir(cwd, slug);
|
|
65
|
+
const legs = readLegs(legLedger(cwd, slug));
|
|
66
|
+
const ingested = new Set(legs
|
|
67
|
+
.filter((r) => Number(r.round) === round && r.scope_id === scopeId && r.ingested_at)
|
|
68
|
+
.map((r) => String(r.order_id)));
|
|
69
|
+
|
|
70
|
+
const orders = [];
|
|
71
|
+
for (const f of (existsSync(oDir) ? readdirSync(oDir) : []).sort()) {
|
|
72
|
+
const m = f.match(BUILD_ORDER);
|
|
73
|
+
if (!m || m[1] !== scopeId || Number(m[2]) !== round) continue;
|
|
74
|
+
// The order id is read off the order rather than rebuilt from the filename: the filename is the
|
|
75
|
+
// path the round knows, and the id is the key every record joins on, and they are allowed to be
|
|
76
|
+
// written by different steps.
|
|
77
|
+
let orderId = null;
|
|
78
|
+
try { orderId = JSON.parse(readFileSync(join(oDir, f), "utf8")).order_id ?? null; } catch { /* unreadable — reported below */ }
|
|
79
|
+
orders.push({
|
|
80
|
+
order: join(oDir, f),
|
|
81
|
+
order_id: orderId,
|
|
82
|
+
attempt: Number(m[3]),
|
|
83
|
+
has_result: existsSync(join(rDir, f)),
|
|
84
|
+
applied: orderId !== null && ingested.has(orderId),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const unapplied = orders.filter((o) => o.has_result && !o.applied);
|
|
88
|
+
return { closed: orders.some((o) => o.applied) && unapplied.length === 0, orders, unapplied };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const ARGV_SPEC = {
|
|
92
|
+
usage: "harness.mjs probe leg --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
|
|
93
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
94
|
+
slug: { type: "str", required: true },
|
|
95
|
+
scope: { type: "str", required: true },
|
|
96
|
+
round: { type: "int", min: 1, required: true },
|
|
97
|
+
cwd: { type: "path" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Report whether one scope's results for one round reached the board.
|
|
102
|
+
*
|
|
103
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
104
|
+
* @returns {void} Exits 0 when closed, 1 when a result was never applied — the shape a caller can
|
|
105
|
+
* branch on without parsing prose.
|
|
106
|
+
*/
|
|
107
|
+
export function cli(rawArgv) {
|
|
108
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
109
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
110
|
+
const s = legState(cwd, args.slug, args.scope, args.round);
|
|
111
|
+
console.log(JSON.stringify({
|
|
112
|
+
closed: s.closed,
|
|
113
|
+
scope_id: args.scope,
|
|
114
|
+
round: args.round,
|
|
115
|
+
// The counts travel with the verdict: `closed: false` over zero orders means the leg has not run,
|
|
116
|
+
// and over three orders means its work is on disk and unapplied. Those need different answers
|
|
117
|
+
// from the caller, so they must not read the same.
|
|
118
|
+
orders_total: s.orders.length,
|
|
119
|
+
results_total: s.orders.filter((o) => o.has_result).length,
|
|
120
|
+
applied_total: s.orders.filter((o) => o.applied).length,
|
|
121
|
+
orders: s.orders,
|
|
122
|
+
unapplied: s.unapplied.map((o) => o.order),
|
|
123
|
+
}));
|
|
124
|
+
process.exit(s.closed ? 0 : 1);
|
|
125
|
+
}
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// RESUME STATE — the fast-forward derivation, as a script rather than as a string.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS (observed, not theorized).
|
|
5
|
+
//
|
|
6
|
+
// `shapeup-run.js` derives "which phase do I resume at" from disk on every launch — that
|
|
7
|
+
// derivation is what retires the whole class of handoff where a fresh session rebuilds a pipeline
|
|
8
|
+
// already on disk. Until this file existed, it lived inside the workflow script as a
|
|
9
|
+
// `node --input-type=module -e "…"` blob passed to a courier agent. Three consequences, all of
|
|
10
|
+
// them realised:
|
|
11
|
+
//
|
|
12
|
+
// 1. IT COULD NOT BE TESTED. A Workflow script has no `import`, takes `args` as a runtime
|
|
13
|
+
// global, and is executed by the Workflow runtime — there is no seam a fixture can reach.
|
|
14
|
+
// So the derivation shipped unverified, and the kill/resume probe found it re-dispatching a
|
|
15
|
+
// COMPLETED ORIENT phase: three orient
|
|
16
|
+
// artifacts rewritten, a spike added, the discovery ledger and two task files mutated.
|
|
17
|
+
// The cause was one branch reading stored `status` instead of ORIENT's own artifacts,
|
|
18
|
+
// while WIRE and MAP SCOPES read artifacts and fast-forwarded correctly.
|
|
19
|
+
// 2. IT MATCHED NO PERMISSION GRANT. An inline `node -e` matches no rule in `permissions.allow`
|
|
20
|
+
// and passes only at the safety classifier's discretion. As a named script it is covered by
|
|
21
|
+
// the per-script rules the installer writes (`bin/lib/grant.mjs`).
|
|
22
|
+
// 3. TWO WRITES HAD NO READER. `setRunStatus` and the substrate pointer were the only
|
|
23
|
+
// call sites in the workflow whose return value was discarded — and they are the only two
|
|
24
|
+
// whose failure went unnoticed for two entire runs. `status` never left `orienting` across
|
|
25
|
+
// 46 dispatched agents, and `.shapeup/active-scope` still named scope 1 while scope 2 was
|
|
26
|
+
// being built, which is the pointer `hooks/sandbox-guard.mjs` reads to decide which
|
|
27
|
+
// substrate a worker may write. A courier write whose result nobody reads back is
|
|
28
|
+
// indistinguishable from one that succeeded. Both writes live here now, both refuse
|
|
29
|
+
// silently-wrong outcomes (a missing ledger, a status line that matched nothing), and both
|
|
30
|
+
// report a non-zero exit the caller is required to act on.
|
|
31
|
+
//
|
|
32
|
+
// WHAT THIS FILE DOES NOT DECIDE. It reports facts; the workflow decides phases. `next_phase` is
|
|
33
|
+
// offered as a derived convenience (and is what the fixture asserts over), but every underlying
|
|
34
|
+
// boolean travels too, so a caller is never forced to trust a summary it cannot check.
|
|
35
|
+
//
|
|
36
|
+
// WHY `--require` EXISTS (the same lesson, one layer up).
|
|
37
|
+
//
|
|
38
|
+
// Making the RESUME decision read artifacts left the COMPLETION decision reading
|
|
39
|
+
// nothing at all: shapeup-run.js dispatched a phase, ingested its result, and moved to the next
|
|
40
|
+
// gate without ever re-asking the predicate. A worker that returns `status: "escalated"` with
|
|
41
|
+
// `artifacts: []` — a legitimate outcome its own contract defines — satisfied that. So a phase
|
|
42
|
+
// that wrote no artifact was recorded as complete, the artifact-gated fast-forward then correctly
|
|
43
|
+
// found nothing on the next launch, re-dispatched it, and the worker escalated again: an
|
|
44
|
+
// unbounded loop, invisible inside one leg, which is why several runs and a status review never
|
|
45
|
+
// saw it.
|
|
46
|
+
//
|
|
47
|
+
// `--require <phase>` is the completion check, and it is deliberately the SAME derivation the
|
|
48
|
+
// fast-forward uses — not a second predicate that can drift from it. Two predicates that can
|
|
49
|
+
// disagree about "is this phase done" is the defect class itself.
|
|
50
|
+
//
|
|
51
|
+
// USAGE
|
|
52
|
+
// node harness probe resume --slug <slug> [--cwd <dir>] # derive, print ResumeState
|
|
53
|
+
// node harness probe resume --slug <slug> --require <phase> # post-condition: exit 6 if unmet
|
|
54
|
+
// node harness probe resume --slug <slug> --set-status <status> # write harness-run.md status
|
|
55
|
+
//
|
|
56
|
+
// Exit: 0 ok · 2 malformed argv (nothing ran) · 3 the target the operation needs is not on disk ·
|
|
57
|
+
// 6 the required phase's artifact is NOT on disk (the phase did not complete).
|
|
58
|
+
|
|
59
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
60
|
+
import { dirname, join, resolve } from "node:path";
|
|
61
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
62
|
+
import { splitFrontmatter } from "../lib/contract.mjs";
|
|
63
|
+
import { globToRegExp } from "../verify/spec.mjs";
|
|
64
|
+
import {
|
|
65
|
+
intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
|
|
66
|
+
orientDir, activeOrder, usecasesDir,
|
|
67
|
+
} from "../lib/paths.mjs";
|
|
68
|
+
|
|
69
|
+
/** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
|
|
70
|
+
* not a write — the whole point of this file is that a write nobody validates is a write nobody
|
|
71
|
+
* can trust. */
|
|
72
|
+
export const RUN_STATUSES = ["orienting", "mapping", "building", "evaluating", "shipped", "escalated"];
|
|
73
|
+
|
|
74
|
+
/** ORIENT's four artifacts (skills/orient/SKILL.md §Outputs): three by exact name, plus a spike
|
|
75
|
+
* whose filename carries the area it spiked (`spike-<area>.md`, or `spike-not-needed.md` when
|
|
76
|
+
* the risk scan came back rank 0 — both count, because both are ORIENT having finished). */
|
|
77
|
+
export const ORIENT_REQUIRED = ["code-surface.md", "discovered-seed.md", "hill-signal.md"];
|
|
78
|
+
export const ORIENT_SPIKE = /^spike-.+\.md$/;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a leading `---` frontmatter block into a flat object, through the ONE library that reads
|
|
82
|
+
* this file form (`lib/contract.mjs`).
|
|
83
|
+
*
|
|
84
|
+
* It used to be a private scalar-only regex — every value came back a string, including a
|
|
85
|
+
* `key: [a, b]` list. That is why `eval_dimensions` was pinned: the ledger could carry the set the
|
|
86
|
+
* PO asked for and the `Array.isArray(hr.eval_dimensions)` branch below could never be true, so the
|
|
87
|
+
* fallback fired on every read and the run graded spec-conformance whatever the file said. A second
|
|
88
|
+
* parser for a format that already has one is the defect; the dialects diverge silently and the
|
|
89
|
+
* reader that loses a value looks identical to a file that never carried it.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} text - Whole file contents.
|
|
92
|
+
* @returns {Object<string,*>} Frontmatter keys, coerced: `[a, b]` → string[], `~` → null,
|
|
93
|
+
* digits → number, true/false → boolean, everything else the unquoted string.
|
|
94
|
+
*/
|
|
95
|
+
export function parseFrontmatter(text) {
|
|
96
|
+
return splitFrontmatter(text).meta;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Has ORIENT actually produced its artifacts? This is the predicate the fast-forward's ORIENT
|
|
101
|
+
* branch was missing — the comment above that branch has always described it, and until now the
|
|
102
|
+
* code read a stored status field instead.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} cwd - Project root.
|
|
105
|
+
* @param {string} slug - Feature slug.
|
|
106
|
+
* @returns {boolean} True when all three named artifacts and at least one spike file exist.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Group the scopes into dependency WAVES: every scope in a wave can be built at the same time,
|
|
110
|
+
* and no wave contains a scope that depends on one still in flight.
|
|
111
|
+
*
|
|
112
|
+
* WHY THIS EXISTS, measured rather than reasoned. BUILD chunked the scopes by a fixed width over
|
|
113
|
+
* whatever order the directory listing gave — alphabetical. On the criterion-1 run that put
|
|
114
|
+
* `cli-integration` second, in the first wave, alongside the `foundation` and command scopes its own
|
|
115
|
+
* contract says it consumes ("replaces foundation's bin/todo.js placeholder", "routes to each command
|
|
116
|
+
* scope's module"). It burned all three attempts at 0/2 fixtures in round 1 and its leg died in round
|
|
117
|
+
* 2, so `bin/todo.js` stayed a two-line placeholder and four individually T0-green command modules
|
|
118
|
+
* were unreachable from the entry point. Five of six scopes went green; the sixth was the one that
|
|
119
|
+
* had to go last, and it was scheduled second by alphabet.
|
|
120
|
+
*
|
|
121
|
+
* THE ORDER LIVES IN THE SAME TIER AS THE THING IT ORDERS. Each scope contract declares
|
|
122
|
+
* `depends_on: [scope_id, …]`, and scope A follows scope B when A names B. This used to be derived
|
|
123
|
+
* instead — contract `tasks` → the board's task `depends_on` → back to the owning scope — which
|
|
124
|
+
* read an ordering off the LOCAL board while the contracts it ordered were COMMITTED. On a fresh
|
|
125
|
+
* clone the board is absent, every scope's task list resolved to nothing, and the whole relation
|
|
126
|
+
* collapsed to "no edges": the scheduler degraded to the unscheduled fan-out this exists to
|
|
127
|
+
* replace, and reported nothing, because an empty relation is the same value as no relation.
|
|
128
|
+
* `scope_id` is the stable cross-machine key, so an edge between two of them survives the clone
|
|
129
|
+
* that the two-hop join through renumbering task ids never could.
|
|
130
|
+
*
|
|
131
|
+
* NON-REGRESSION IS THE DEFAULT. Any missing or unreadable input — no contract declaring
|
|
132
|
+
* `depends_on`, an id naming a scope that is not here, a dependency cycle — falls back to one wave
|
|
133
|
+
* containing everything, which is exactly the pre-scheduler behavior. A scheduler that refuses to
|
|
134
|
+
* run is worse than one that runs unscheduled.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} cwd - Project root.
|
|
137
|
+
* @param {string} slug - Feature slug.
|
|
138
|
+
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
139
|
+
* @returns {string[][]} Waves of resolved scope paths, dependencies first. Order within a wave is
|
|
140
|
+
* the input order, so the result is deterministic.
|
|
141
|
+
*/
|
|
142
|
+
export function scopeWaves(cwd, slug, scopes) {
|
|
143
|
+
const all = scopes.map((s) => s.path);
|
|
144
|
+
if (scopes.length < 2) return all.length ? [all] : [];
|
|
145
|
+
const deps = scopeDepGraph(scopes);
|
|
146
|
+
if (!deps) return [all];
|
|
147
|
+
|
|
148
|
+
// Kahn, one wave per level. A cycle cannot stall the build: the remainder ships as one wave.
|
|
149
|
+
const done = new Set();
|
|
150
|
+
const waves = [];
|
|
151
|
+
while (done.size < scopes.length) {
|
|
152
|
+
const ready = scopes.filter((s) => !done.has(s.scope_id)
|
|
153
|
+
&& [...deps.get(s.scope_id)].every((d) => done.has(d)));
|
|
154
|
+
if (!ready.length) {
|
|
155
|
+
waves.push(scopes.filter((s) => !done.has(s.scope_id)).map((s) => s.path));
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
waves.push(ready.map((s) => s.path));
|
|
159
|
+
for (const s of ready) done.add(s.scope_id);
|
|
160
|
+
}
|
|
161
|
+
return waves;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The dependency relation `scopeWaves` levels — parsed once, so the two answers cannot disagree.
|
|
166
|
+
*
|
|
167
|
+
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
168
|
+
* @returns {(Map<string,Set<string>>|null)} scope_id → the scope_ids it waits for, or null when the
|
|
169
|
+
* contracts carry no usable relation at all (none declares `depends_on`, or every id it names is
|
|
170
|
+
* a scope that is not in this run).
|
|
171
|
+
*/
|
|
172
|
+
function scopeDepGraph(scopes) {
|
|
173
|
+
const present = new Set(scopes.map((s) => s.scope_id));
|
|
174
|
+
const deps = new Map(scopes.map((s) => [s.scope_id, new Set()]));
|
|
175
|
+
let sawEdge = false;
|
|
176
|
+
for (const s of scopes) {
|
|
177
|
+
let declared;
|
|
178
|
+
try { declared = parseFrontmatter(readFileSync(s.path, "utf8")).depends_on; } catch { continue; }
|
|
179
|
+
if (!Array.isArray(declared)) continue;
|
|
180
|
+
for (const d of declared) {
|
|
181
|
+
const id = String(d).trim();
|
|
182
|
+
// An id naming a scope that is not in this run is DROPPED, not an error: a contract may
|
|
183
|
+
// legitimately name a scope superseded or cut since it was written, and a scheduler that
|
|
184
|
+
// stalls on a stale edge is worse than one that ignores it. spec-lint reports the dangling
|
|
185
|
+
// id — this is the scheduling path, and it fails open.
|
|
186
|
+
if (id && id !== s.scope_id && present.has(id)) { deps.get(s.scope_id).add(id); sawEdge = true; }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return sawEdge ? deps : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The SAME relation as {@link scopeWaves}, as edges rather than levels.
|
|
194
|
+
*
|
|
195
|
+
* WHY BOTH SHAPES SHIP. A level says when it is definitely safe to start a scope; an edge says when
|
|
196
|
+
* it BECAME safe, and the two differ by however long the slowest sibling in the previous level takes.
|
|
197
|
+
* A build that releases per level holds `cli-integration` until the last command scope lands even
|
|
198
|
+
* when the only scope it consumes went green first. Levels remain the ORDER the fan-out considers
|
|
199
|
+
* scopes in; edges are what release them.
|
|
200
|
+
*
|
|
201
|
+
* ADDITIVE, in the same sense `scope_waves` is: a consumer that ignores this field can rebuild the
|
|
202
|
+
* level-release points from `scope_waves` alone, which is exactly the previous behaviour.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} cwd - Project root.
|
|
205
|
+
* @param {string} slug - Feature slug.
|
|
206
|
+
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
207
|
+
* @returns {string[][]} `[dependant_path, dependency_path]` pairs, resolved paths on both ends so a
|
|
208
|
+
* consumer resolves ids the one way it already resolves `scope_files`. Empty when no relation is
|
|
209
|
+
* derivable — never a partial or invented one.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* Pairs of scopes that must not BUILD AT THE SAME TIME, because both may write the same path.
|
|
213
|
+
*
|
|
214
|
+
* WHY THIS IS A SCHEDULING FACT AND NOT A LINT. `shared_substrate` is the declared escape hatch from
|
|
215
|
+
* the disjointness rule: the spec lint passes an overlap both contracts declare, and the sandbox
|
|
216
|
+
* guard permits that path to every live order that names it. Every layer is individually correct and
|
|
217
|
+
* the join is wrong — concurrent writers to one declared-shared entry point lose each other's work,
|
|
218
|
+
* with every check green. An overlap that is NOT declared shared never reaches BUILD (the lint reds
|
|
219
|
+
* it and the run stops at the board review), so what this returns is precisely the set the escape
|
|
220
|
+
* hatch created.
|
|
221
|
+
*
|
|
222
|
+
* It is an EXCLUSION, not a dependency: neither scope has to go first, they only have to not
|
|
223
|
+
* overlap. The consumer orients each pair by build position, which is what keeps the constraint from
|
|
224
|
+
* ever forming a cycle with a real dependency edge.
|
|
225
|
+
*
|
|
226
|
+
* ADDITIVE, like the two fields above it: a consumer that ignores it schedules exactly as before.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} cwd - Project root.
|
|
229
|
+
* @param {string} slug - Feature slug.
|
|
230
|
+
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
231
|
+
* @returns {string[][]} Unordered `[pathA, pathB]` pairs. Empty when nothing can collide.
|
|
232
|
+
*/
|
|
233
|
+
export function scopeExclusions(cwd, slug, scopes) {
|
|
234
|
+
if (scopes.length < 2) return [];
|
|
235
|
+
const globsOf = (s) => {
|
|
236
|
+
try {
|
|
237
|
+
const fm = parseFrontmatter(readFileSync(s.path, "utf8"));
|
|
238
|
+
return [...(fm.allowed_file_substrate || []), ...(fm.shared_substrate || [])].map((g) => String(g).trim()).filter(Boolean);
|
|
239
|
+
} catch { return []; }
|
|
240
|
+
};
|
|
241
|
+
const globs = new Map(scopes.map((s) => [s.scope_id, globsOf(s)]));
|
|
242
|
+
// Two globs MEET when either matches the other read as a literal path. It is an approximation of
|
|
243
|
+
// "these two patterns can name the same file", and it is deliberately the generous one: a false
|
|
244
|
+
// meet costs two scopes their overlap in time, a missed meet costs one of them its work.
|
|
245
|
+
const meet = (a, b) => a === b || globToRegExp(a).test(b) || globToRegExp(b).test(a);
|
|
246
|
+
const out = [];
|
|
247
|
+
for (let i = 0; i < scopes.length; i++) {
|
|
248
|
+
for (let j = i + 1; j < scopes.length; j++) {
|
|
249
|
+
const A = globs.get(scopes[i].scope_id) || [], B = globs.get(scopes[j].scope_id) || [];
|
|
250
|
+
if (A.some((a) => B.some((b) => meet(a, b)))) out.push([scopes[i].path, scopes[j].path]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function scopeDeps(cwd, slug, scopes) {
|
|
257
|
+
if (scopes.length < 2) return [];
|
|
258
|
+
const deps = scopeDepGraph(scopes);
|
|
259
|
+
if (!deps) return [];
|
|
260
|
+
const pathOf = new Map(scopes.map((s) => [s.scope_id, s.path]));
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const s of scopes) {
|
|
263
|
+
for (const d of deps.get(s.scope_id) || []) {
|
|
264
|
+
if (pathOf.has(d)) out.push([s.path, pathOf.get(d)]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function hasOrientArtifacts(cwd, slug) {
|
|
271
|
+
const dir = orientDir(cwd, slug);
|
|
272
|
+
if (!existsSync(dir)) return false;
|
|
273
|
+
let files;
|
|
274
|
+
try { files = readdirSync(dir); } catch { return false; }
|
|
275
|
+
const present = new Set(files);
|
|
276
|
+
return ORIENT_REQUIRED.every((f) => present.has(f)) && files.some((f) => ORIENT_SPIKE.test(f));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The use-case directory this run's spec tree lands in. The ledger may name a non-default
|
|
281
|
+
* `spec_folder` (`harness init run --spec-folder`), so honour it when present and fall back to the
|
|
282
|
+
* registry path otherwise — never a spelled-out root (test #45).
|
|
283
|
+
*
|
|
284
|
+
* @param {string} cwd - Project root.
|
|
285
|
+
* @param {string} slug - Feature slug.
|
|
286
|
+
* @param {string|null} specFolder - The ledger's `spec_folder`, if it names one.
|
|
287
|
+
* @returns {string} Absolute path to `usecases/`.
|
|
288
|
+
*/
|
|
289
|
+
export function usecasesPath(cwd, slug, specFolder) {
|
|
290
|
+
return specFolder ? resolve(cwd, specFolder, "usecases") : usecasesDir(cwd, slug);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Has ANALYZE actually produced the spec tree? `_index.md` alone is a tree with no use cases in
|
|
295
|
+
* it, and a wiring map is written one entry PER use case — so the index does not count.
|
|
296
|
+
*
|
|
297
|
+
* This predicate is why ANALYZE is in the phase chain at all. WIRE was dispatched before it ran, so
|
|
298
|
+
* `usecases/` did not exist, so `solution-architect` had nothing to wire and escalated — honestly,
|
|
299
|
+
* and identically on every relaunch (skills/solution-architect/SKILL.md:43-44, :108).
|
|
300
|
+
*
|
|
301
|
+
* @param {string} cwd - Project root.
|
|
302
|
+
* @param {string} slug - Feature slug.
|
|
303
|
+
* @param {string|null} specFolder - The ledger's `spec_folder`, if it names one.
|
|
304
|
+
* @returns {boolean} True when at least one use case (not the index) is on disk.
|
|
305
|
+
*/
|
|
306
|
+
export function hasSpecTree(cwd, slug, specFolder) {
|
|
307
|
+
const dir = usecasesPath(cwd, slug, specFolder);
|
|
308
|
+
if (!existsSync(dir)) return false;
|
|
309
|
+
let files;
|
|
310
|
+
try { files = readdirSync(dir); } catch { return false; }
|
|
311
|
+
return files.some((f) => f.endsWith(".md") && f !== "_index.md");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Each dispatched phase and the artifact that IS its completion. One table, read by the resume
|
|
316
|
+
* decision (`nextPhase`) and by the post-condition check (`--require`) alike — see the banner.
|
|
317
|
+
*/
|
|
318
|
+
export const PHASE_ARTIFACT = {
|
|
319
|
+
orient: { fact: "has_orient_artifacts", artifact: "orient/{code-surface,discovered-seed,hill-signal}.md + spike-*.md" },
|
|
320
|
+
analyze: { fact: "has_spec_tree", artifact: "spec/usecases/*.md" },
|
|
321
|
+
wire: { fact: "has_wiring_map", artifact: "wiring-map.md" },
|
|
322
|
+
"map-scopes": { fact: "scope_files", artifact: "scopes/*.md" },
|
|
323
|
+
};
|
|
324
|
+
export const PHASES = Object.keys(PHASE_ARTIFACT);
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Is this phase's artifact on disk? The ONE reading of "complete" in this pipeline.
|
|
328
|
+
*
|
|
329
|
+
* @param {object} state - A derived ResumeState.
|
|
330
|
+
* @param {string} phase - One of {@link PHASES}.
|
|
331
|
+
* @returns {boolean} True when the phase's artifact exists.
|
|
332
|
+
*/
|
|
333
|
+
export function phaseSatisfied(state, phase) {
|
|
334
|
+
const v = state[PHASE_ARTIFACT[phase].fact];
|
|
335
|
+
return Array.isArray(v) ? v.length > 0 : Boolean(v);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The first phase whose artifacts are incomplete — the design doc's §4 fast-forward, stated once
|
|
340
|
+
* so the workflow and the fixture cannot disagree about it.
|
|
341
|
+
*
|
|
342
|
+
* ⟐ ANALYZE sits between ORIENT and WIRE. The pipeline used to dispatch
|
|
343
|
+
* WIRE first, which is the position solution-architect's own input contract excludes — it reads
|
|
344
|
+
* `usecases/`, and `analyze` is what writes them.
|
|
345
|
+
*
|
|
346
|
+
* @param {object} f - Facts (a derived ResumeState, or the subset the phase predicates read).
|
|
347
|
+
* @returns {"orient"|"analyze"|"wire"|"map-scopes"|"build"} The phase to resume at.
|
|
348
|
+
*/
|
|
349
|
+
export function nextPhase(f) {
|
|
350
|
+
for (const p of PHASES) if (!phaseSatisfied(f, p)) return p;
|
|
351
|
+
return "build";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Derive every path/status fact the outer pipeline needs, from files alone.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} cwd - Project root.
|
|
358
|
+
* @param {string} slug - Feature slug.
|
|
359
|
+
* @returns {object} The ResumeState record (domain.schema.json $defs/ResumeState).
|
|
360
|
+
*/
|
|
361
|
+
export function deriveResumeState(cwd, slug) {
|
|
362
|
+
const hrPath = harnessRun(cwd, slug);
|
|
363
|
+
const hr = existsSync(hrPath) ? parseFrontmatter(readFileSync(hrPath, "utf8")) : {};
|
|
364
|
+
|
|
365
|
+
// Resolved contract PATHS, not bare filenames: harness compile and harness verify t0 both resolve
|
|
366
|
+
// `--scope` against cwd, so a bare "SC-x.md" names a file that does not exist, compile-order
|
|
367
|
+
// exits 2, and the attempt loop reads that non-zero exit as the stagnation breaker — a resumed
|
|
368
|
+
// run would falsely trip the inner breaker and hammer-propose every scope instead of continuing.
|
|
369
|
+
const sdir = scopesDir(cwd, slug);
|
|
370
|
+
const scope_files = existsSync(sdir)
|
|
371
|
+
? readdirSync(sdir).filter((f) => f.endsWith(".md")).sort()
|
|
372
|
+
.map((f) => ({ scope_id: f.replace(/\.md$/, ""), path: join(sdir, f) }))
|
|
373
|
+
: [];
|
|
374
|
+
|
|
375
|
+
const resultFiles = existsSync(resultsDir(cwd, slug)) ? readdirSync(resultsDir(cwd, slug)) : [];
|
|
376
|
+
const orderFiles = existsSync(ordersDir(cwd, slug)) ? readdirSync(ordersDir(cwd, slug)) : [];
|
|
377
|
+
|
|
378
|
+
const facts = {
|
|
379
|
+
intake_path: intake(cwd, slug),
|
|
380
|
+
spec_folder: hr.spec_folder || null,
|
|
381
|
+
status: hr.status || null,
|
|
382
|
+
lens: hr.lens || null,
|
|
383
|
+
stack: hr.stack || null,
|
|
384
|
+
run_cmd: hr.run_cmd || null,
|
|
385
|
+
app_url: hr.app_url || null,
|
|
386
|
+
eval_dimensions: Array.isArray(hr.eval_dimensions) ? hr.eval_dimensions : ["spec-conformance"],
|
|
387
|
+
orient_dir: `.shapeup/${slug}/orient/`,
|
|
388
|
+
has_orient_artifacts: hasOrientArtifacts(cwd, slug),
|
|
389
|
+
has_spec_tree: hasSpecTree(cwd, slug, hr.spec_folder || null),
|
|
390
|
+
has_wiring_map: existsSync(wiringMap(cwd, slug)),
|
|
391
|
+
project_profile_path: projectProfile(cwd, slug),
|
|
392
|
+
has_project_profile: existsSync(projectProfile(cwd, slug)),
|
|
393
|
+
scope_files,
|
|
394
|
+
// The same scopes, grouped so a wave never contains a scope depending on one still in flight.
|
|
395
|
+
// Additive: a caller that ignores it gets exactly today's behavior.
|
|
396
|
+
scope_waves: scopeWaves(cwd, slug, scope_files),
|
|
397
|
+
// The same relation as edges. A wave says when a scope is definitely safe to start; an edge says
|
|
398
|
+
// when it BECAME safe. A fan-out that releases per wave holds a scope until the slowest member of
|
|
399
|
+
// the previous wave lands, even when the one scope it consumes finished first. Also additive: the
|
|
400
|
+
// wave list alone rebuilds the per-wave release points, which is the previous behaviour.
|
|
401
|
+
scope_deps: scopeDeps(cwd, slug, scope_files),
|
|
402
|
+
// Pairs that may write the same declared-shared path. Not an ordering — an exclusion: they may
|
|
403
|
+
// build in either order, and they may not build at the same time. Concurrent writers to one
|
|
404
|
+
// shared entry point lose each other's work while every check stays green, and the escape hatch
|
|
405
|
+
// that permits the overlap is the same one that makes it invisible to the disjointness lint.
|
|
406
|
+
scope_exclusions: scopeExclusions(cwd, slug, scope_files),
|
|
407
|
+
pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
|
|
408
|
+
eval_rounds_done: resultFiles
|
|
409
|
+
.filter((f) => /^evaluate-r\d+\.json$/.test(f))
|
|
410
|
+
.map((f) => Number(f.match(/\d+/)[0])),
|
|
411
|
+
};
|
|
412
|
+
return { ...facts, next_phase: nextPhase(facts) };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Rewrite `harness-run.md`'s status line. Refuses rather than silently no-ops: a ledger that is
|
|
417
|
+
* absent, or that carries no `status:` line to replace, is a fact the run must act on — that
|
|
418
|
+
* silent no-op is exactly what pinned a run at `orienting` for two complete legs.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} cwd - Project root.
|
|
421
|
+
* @param {string} slug - Feature slug.
|
|
422
|
+
* @param {string} status - One of {@link RUN_STATUSES}.
|
|
423
|
+
* @returns {{ok: boolean, path: string, status: string, reason?: string}} Outcome record.
|
|
424
|
+
*/
|
|
425
|
+
export function setRunStatus(cwd, slug, status) {
|
|
426
|
+
const p = harnessRun(cwd, slug);
|
|
427
|
+
if (!existsSync(p)) {
|
|
428
|
+
return { ok: false, path: p, status, reason: `no harness-run.md for slug "${slug}" — open the run with harness init run (GATE L0.1) before setting its status` };
|
|
429
|
+
}
|
|
430
|
+
const body = readFileSync(p, "utf8");
|
|
431
|
+
if (!/^status:.*$/m.test(body)) {
|
|
432
|
+
return { ok: false, path: p, status, reason: `harness-run.md carries no "status:" line to replace — the ledger's frontmatter is malformed (references/protocol.md)` };
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
writeFileSync(p, body.replace(/^status:.*$/m, `status: ${status}`));
|
|
436
|
+
} catch (e) {
|
|
437
|
+
return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
|
|
438
|
+
}
|
|
439
|
+
const after = parseFrontmatter(readFileSync(p, "utf8")).status;
|
|
440
|
+
if (after !== status) {
|
|
441
|
+
return { ok: false, path: p, status, reason: `wrote "status: ${status}" but the ledger reads "${after}" — the write did not take` };
|
|
442
|
+
}
|
|
443
|
+
return { ok: true, path: p, status };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Point the substrate pointer at the order about to be executed.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} cwd - Project root.
|
|
450
|
+
* @param {string} slug - Feature slug.
|
|
451
|
+
* @param {string} orderPath - Path to the active order.
|
|
452
|
+
* @returns {{ok: boolean, path: string, slug: string, order_path: string, reason?: string}} Outcome.
|
|
453
|
+
*/
|
|
454
|
+
export function writeActiveOrder(cwd, slug, orderPath) {
|
|
455
|
+
const p = activeOrder(cwd);
|
|
456
|
+
try {
|
|
457
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
458
|
+
writeFileSync(p, `${JSON.stringify({ slug, order_path: orderPath }, null, 2)}\n`);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return { ok: false, path: p, slug, order_path: orderPath, reason: `could not write the active-order pointer: ${e.message}` };
|
|
461
|
+
}
|
|
462
|
+
let readBack;
|
|
463
|
+
try { readBack = JSON.parse(readFileSync(p, "utf8")); } catch { readBack = null; }
|
|
464
|
+
if (readBack?.order_path !== orderPath || readBack?.slug !== slug) {
|
|
465
|
+
return { ok: false, path: p, slug, order_path: orderPath, reason: `pointer read back as ${JSON.stringify(readBack)}` };
|
|
466
|
+
}
|
|
467
|
+
return { ok: true, path: p, slug, order_path: orderPath };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
471
|
+
export const ARGV_SPEC = {
|
|
472
|
+
usage: "harness.mjs probe resume --slug <slug> [--cwd <dir>] [--require <phase> | --set-status <status> | --set-active-order <path>]",
|
|
473
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
474
|
+
slug: { type: "str", required: true },
|
|
475
|
+
cwd: { type: "path" },
|
|
476
|
+
require: { type: "enum", values: PHASES },
|
|
477
|
+
"set-status": { type: "enum", values: RUN_STATUSES },
|
|
478
|
+
"set-active-order": { type: "str" },
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Derive the run's resume state, or set one of the run pointers.
|
|
483
|
+
*
|
|
484
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
485
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
486
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
487
|
+
*/
|
|
488
|
+
export function cli(rawArgv) {
|
|
489
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
490
|
+
const cwd = args.cwd || process.cwd();
|
|
491
|
+
|
|
492
|
+
const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveOrder && "--set-active-order"].filter(Boolean);
|
|
493
|
+
if (ops.length > 1) {
|
|
494
|
+
process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ops, expected: "one operation per invocation" }) + "\n");
|
|
495
|
+
process.exit(2);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The post-condition. It prints the SAME ResumeState the derivation prints — plus which phase was
|
|
499
|
+
// asked about and whether its artifact is there — so a caller that wants to act on the facts
|
|
500
|
+
// rather than on the exit code never has to make a second call.
|
|
501
|
+
if (args.require) {
|
|
502
|
+
const state = deriveResumeState(cwd, args.slug);
|
|
503
|
+
const satisfied = phaseSatisfied(state, args.require);
|
|
504
|
+
console.log(JSON.stringify({
|
|
505
|
+
...state,
|
|
506
|
+
required_phase: args.require,
|
|
507
|
+
required_artifact: PHASE_ARTIFACT[args.require].artifact,
|
|
508
|
+
satisfied,
|
|
509
|
+
}));
|
|
510
|
+
process.exit(satisfied ? 0 : 6);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (args.setStatus) {
|
|
514
|
+
const r = setRunStatus(cwd, args.slug, args.setStatus);
|
|
515
|
+
console.log(JSON.stringify(r));
|
|
516
|
+
process.exit(r.ok ? 0 : 3);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (args.setActiveOrder) {
|
|
520
|
+
const r = writeActiveOrder(cwd, args.slug, args.setActiveOrder);
|
|
521
|
+
console.log(JSON.stringify(r));
|
|
522
|
+
process.exit(r.ok ? 0 : 3);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
console.log(JSON.stringify(deriveResumeState(cwd, args.slug)));
|
|
526
|
+
process.exit(0);
|
|
527
|
+
}
|
|
528
|
+
|