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
|
@@ -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.
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
// Output is a StatsReport (domain.schema.json#/$defs/StatsReport), self-validated before it
|
|
18
18
|
// is emitted; --format table renders the human view from the already-validated object.
|
|
19
19
|
//
|
|
20
|
-
// Usage: node stats
|
|
20
|
+
// Usage: node `harness probe stats` [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table]
|
|
21
21
|
|
|
22
22
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
23
23
|
import { resolve, join } from "node:path";
|
|
24
|
-
import { validate } from "
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { localDir, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "
|
|
24
|
+
import { validate } from "../verify/envelope.mjs";
|
|
25
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
26
|
+
import { collectRun } from "../report/export.mjs";
|
|
27
|
+
import { localDir, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "../lib/paths.mjs";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Read every metrics shard, partitioning valid rows, pathology rows, and malformed lines.
|
|
@@ -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,
|
|
@@ -285,6 +284,79 @@ export function readDecisions(cwd) {
|
|
|
285
284
|
} catch { return []; }
|
|
286
285
|
}
|
|
287
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Project run economics for every run in the checkout — measurement-table row 4.
|
|
289
|
+
*
|
|
290
|
+
* WHY IT LIVES HERE and reads the run trace rather than the metrics shards: the same reason
|
|
291
|
+
* `--ratchet` reads `trials.jsonl` and `--hooks` reads `decisions.jsonl`. A harvest row is written
|
|
292
|
+
* once at SHIP S.6 and carries counts, never durations or cost — so a run that never shipped, which
|
|
293
|
+
* is exactly the run whose cost you want to see, has no harvest row at all. The journal has one row
|
|
294
|
+
* per agent call from the first dispatch onwards.
|
|
295
|
+
*
|
|
296
|
+
* FACTS ONLY, unchanged: sums, counts and durations over rows that already exist. Nothing here is
|
|
297
|
+
* divided by an expectation or compared to a target, because that would be a grade.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} cwd - Project root.
|
|
300
|
+
* @param {(string|null)} [slug=null] - Restrict to one feature slug.
|
|
301
|
+
* @returns {object} `{runs, per_run[]}` — one economics block per run, newest last, each tagged
|
|
302
|
+
* with its run_id and slug.
|
|
303
|
+
*/
|
|
304
|
+
export function economicsReport(cwd, slug = null) {
|
|
305
|
+
const root = localDir(cwd);
|
|
306
|
+
if (!existsSync(root)) return { runs: 0, per_run: [] };
|
|
307
|
+
let slugs;
|
|
308
|
+
try { slugs = slug ? [slug] : readdirSync(root); } catch { return { runs: 0, per_run: [] }; }
|
|
309
|
+
const per_run = [];
|
|
310
|
+
for (const s of slugs.sort()) {
|
|
311
|
+
let collected = null;
|
|
312
|
+
try { collected = collectRun(cwd, s); } catch { collected = null; }
|
|
313
|
+
if (!collected) continue; // no receipt ⇒ not a run, which is not an error
|
|
314
|
+
per_run.push({ run_id: collected.run_id, slug: s, ...collected.economics });
|
|
315
|
+
}
|
|
316
|
+
return { runs: per_run.length, per_run };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Format a dollar figure, keeping "no cost row was recorded" visibly different from "$0.0000".
|
|
321
|
+
* @param {(number|null|undefined)} v - A cost in USD, or null when nothing recorded one.
|
|
322
|
+
* @returns {string} e.g. `$1.2000`, or `—` when the value is absent.
|
|
323
|
+
*/
|
|
324
|
+
function money(v) {
|
|
325
|
+
return v === null || v === undefined ? "—" : `$${v.toFixed(4)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Render the economics report as text.
|
|
330
|
+
* @param {object} r - Output of {@link economicsReport}.
|
|
331
|
+
* @returns {string} The multi-line report.
|
|
332
|
+
*/
|
|
333
|
+
function renderEconomics(r) {
|
|
334
|
+
const lines = [`economics: ${r.runs} run(s) with a receipt`];
|
|
335
|
+
if (!r.runs) {
|
|
336
|
+
lines.push("", "(no run trace in this checkout — this reads the run's own records, so it is empty");
|
|
337
|
+
lines.push(" until a run opens, and stays readable after one ends until the trace is cleaned.)");
|
|
338
|
+
return lines.join("\n");
|
|
339
|
+
}
|
|
340
|
+
for (const e of r.per_run) {
|
|
341
|
+
lines.push("", ` ${e.run_id ?? e.slug}`,
|
|
342
|
+
` agent calls ${e.agent_calls} (${e.retried_calls} retried, ${e.failed_calls} failed, ${e.killed_calls} killed)`,
|
|
343
|
+
` cost ${money(e.cost_usd)} attributed ${money(e.cost_attributed_usd)} · unattributed ${money(e.cost_unattributed_usd)}`,
|
|
344
|
+
` wall clock ${e.wall_ms_total === null ? "—" : `${Math.round(e.wall_ms_total / 1000)}s`}`,
|
|
345
|
+
` to first write ${e.calls_to_first_write ?? "—"} call(s)` +
|
|
346
|
+
`${e.seconds_to_first_write === null ? "" : ` · ${e.seconds_to_first_write}s`}`,
|
|
347
|
+
` dispatches ${e.dispatches} (${e.dispatches_answered} answered, ${e.dispatches_costed} costed)`);
|
|
348
|
+
for (const m of e.by_model) {
|
|
349
|
+
lines.push(` ${String(m.model).padEnd(22)}${String(m.calls).padStart(3)} call(s) ${money(m.cost_usd)}`);
|
|
350
|
+
}
|
|
351
|
+
// The gap is named, never left as a quiet shortfall in the total.
|
|
352
|
+
if (e.dispatches_costed < e.dispatches) {
|
|
353
|
+
lines.push(` ⓘ ${e.dispatches - e.dispatches_costed} dispatch(es) carry no cost row — the journal exists only on the`,
|
|
354
|
+
" workflow lane, so a prose-lane or --tiny dispatch has no agent call to join to.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return lines.join("\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
288
360
|
/**
|
|
289
361
|
* Render a StatsReport as a human-readable fixed-width table.
|
|
290
362
|
* @param {object} report - A validated StatsReport (see {@link aggregate}).
|
|
@@ -322,8 +394,8 @@ function renderTable(report) {
|
|
|
322
394
|
|
|
323
395
|
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
324
396
|
export const ARGV_SPEC = {
|
|
325
|
-
usage: "
|
|
326
|
-
"[--ratchet] [--hooks]",
|
|
397
|
+
usage: "harness.mjs probe stats [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table] " +
|
|
398
|
+
"[--ratchet] [--hooks] [--economics]",
|
|
327
399
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
328
400
|
cwd: { type: "path" },
|
|
329
401
|
"metrics-dir": { type: "path" },
|
|
@@ -331,6 +403,7 @@ export const ARGV_SPEC = {
|
|
|
331
403
|
format: { type: "enum", values: ["json", "table"], default: "json" },
|
|
332
404
|
ratchet: { type: "flag" },
|
|
333
405
|
hooks: { type: "flag" },
|
|
406
|
+
economics: { type: "flag" },
|
|
334
407
|
};
|
|
335
408
|
|
|
336
409
|
/**
|
|
@@ -387,23 +460,31 @@ function renderHooks(r) {
|
|
|
387
460
|
return lines.join("\n");
|
|
388
461
|
}
|
|
389
462
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Aggregate the metric shards, or report the ratchet, hook and economics ledgers.
|
|
465
|
+
*
|
|
466
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
467
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
468
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
469
|
+
*/
|
|
470
|
+
export async function cli(rawArgv) {
|
|
471
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
393
472
|
const cwd = resolve(args.cwd || process.cwd());
|
|
394
473
|
const metricsDir = args.metricsDir ? resolve(cwd, args.metricsDir) : metricsDirPath(cwd);
|
|
395
474
|
const format = args.format;
|
|
396
475
|
|
|
397
476
|
// The two exit measurements are separate modes: each reads a different ledger, and neither is a
|
|
398
477
|
// StatsReport (which is schema-locked to the harvest shards).
|
|
399
|
-
if (args.ratchet || args.hooks) {
|
|
478
|
+
if (args.ratchet || args.hooks || args.economics) {
|
|
400
479
|
const out = {};
|
|
401
480
|
if (args.ratchet) out.ratchet = ratchetReport(readAllTrials(cwd, args.slug ?? null));
|
|
402
481
|
if (args.hooks) out.hooks = hooksReport(readDecisions(cwd));
|
|
482
|
+
if (args.economics) out.economics = economicsReport(cwd, args.slug ?? null);
|
|
403
483
|
if (format === "table") {
|
|
404
484
|
const parts = [];
|
|
405
485
|
if (out.ratchet) parts.push(renderRatchet(out.ratchet));
|
|
406
486
|
if (out.hooks) parts.push(renderHooks(out.hooks));
|
|
487
|
+
if (out.economics) parts.push(renderEconomics(out.economics));
|
|
407
488
|
console.log(parts.join("\n\n"));
|
|
408
489
|
} else {
|
|
409
490
|
console.log(JSON.stringify(out, null, 2));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// probe t0 — "has this scope already reached T0-green in this round?"
|
|
2
|
+
//
|
|
3
|
+
// CONTRACT. A bounded, read-only query over the verdict artifacts on disk. Prints
|
|
4
|
+
// `{green, path, round, scope_id}` on stdout; exits 0 when green, 1 when not, 2 on a bad argv.
|
|
5
|
+
// Writes nothing.
|
|
6
|
+
//
|
|
7
|
+
// WHY IT IS A SUBCOMMAND AND NOT AN INLINE SNIPPET. The control plane has no filesystem of its
|
|
8
|
+
// own, so the alternative is a `node -e` blob assembled inside the workflow script. Such a blob is
|
|
9
|
+
// untestable (a workflow script cannot be imported), and it is the one command shape no permission
|
|
10
|
+
// rule can match — the grant covers this entry point, not arbitrary evaluated source.
|
|
11
|
+
//
|
|
12
|
+
// WHY IT READS ARTIFACTS AND NOT A LEDGER. It answers the question a RESUMED round asks: an
|
|
13
|
+
// attempt loop that was killed mid-round must not re-do a scope whose T0 already went green, and
|
|
14
|
+
// the only durable evidence of that is the verdict artifact the evaluator is required to cite.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
19
|
+
import { verdictsDir } from "../lib/paths.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The newest green T0 verdict for one scope in one round.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} cwd - Project root.
|
|
25
|
+
* @param {string} slug - Feature slug.
|
|
26
|
+
* @param {string} scopeId - Scope contract id.
|
|
27
|
+
* @param {number} round - Build round.
|
|
28
|
+
* @returns {{green: boolean, path: (string|null)}} `path` is the artifact a later EVAL can cite.
|
|
29
|
+
*/
|
|
30
|
+
export function greenVerdict(cwd, slug, scopeId, round) {
|
|
31
|
+
const dir = verdictsDir(cwd, slug);
|
|
32
|
+
if (!existsSync(dir)) return { green: false, path: null };
|
|
33
|
+
// Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
|
|
34
|
+
// (round, attempt) address, and the LAST verdict is the one that stands.
|
|
35
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort().reverse()) {
|
|
36
|
+
const p = join(dir, f);
|
|
37
|
+
try {
|
|
38
|
+
const b = JSON.parse(readFileSync(p, "utf8"));
|
|
39
|
+
if (b.scope_id === scopeId && b.round === round && b.overall === "green") return { green: true, path: p };
|
|
40
|
+
} catch { /* a torn artifact proves nothing; keep looking */ }
|
|
41
|
+
}
|
|
42
|
+
return { green: false, path: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const ARGV_SPEC = {
|
|
46
|
+
usage: "harness.mjs probe t0 --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
|
|
47
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
48
|
+
slug: { type: "str", required: true },
|
|
49
|
+
scope: { type: "str", required: true },
|
|
50
|
+
round: { type: "int", min: 1, required: true },
|
|
51
|
+
cwd: { type: "path" },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Report whether one scope is already T0-green for one round.
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
58
|
+
* @returns {void} Exits 0 when green, 1 when not — the shape a caller can branch on without parsing.
|
|
59
|
+
*/
|
|
60
|
+
export function cli(rawArgv) {
|
|
61
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
62
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
63
|
+
const { green, path } = greenVerdict(cwd, args.slug, args.scope, args.round);
|
|
64
|
+
console.log(JSON.stringify({ green, path, scope_id: args.scope, round: args.round }));
|
|
65
|
+
process.exit(green ? 0 : 1);
|
|
66
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Board derivation
|
|
2
|
+
// Board derivation — the planner's graph math, as code a model never re-derives.
|
|
3
3
|
//
|
|
4
4
|
// The mechanical half of the old ba-pitch-analyzer Phase 7b + v3.3 link-field rules — pure
|
|
5
|
-
// graph math a model should never re-derive (and
|
|
5
|
+
// graph math a model should never re-derive (and gets wrong: hand-authored inverses drift asymmetric
|
|
6
6
|
// edges came from hand-authored `unlocks`):
|
|
7
7
|
//
|
|
8
8
|
// • `unlocks` = the depends_on inverse, recomputed over the WHOLE board (--write persists
|
|
@@ -11,26 +11,28 @@
|
|
|
11
11
|
// • Appetite Guard arithmetic (--appetite-hours N → overflow flag; the HAMMER *pause* on
|
|
12
12
|
// overflow is an orchestrator gate, never resolved here)
|
|
13
13
|
// • board-vs-T0 drift check (a FINISHED scope whose tasks still read `ready`) when scope
|
|
14
|
-
// contracts
|
|
14
|
+
// contracts anchor use cases the board's tasks name back — flag, never fix
|
|
15
|
+
// • board-vs-dispatch reconciliation (BOARD-UNDISPATCHED, Phase 3.5 / S6): a scope with a
|
|
16
|
+
// done-marked task but no dispatched-and-answered order anywhere for it — a cut, never
|
|
17
|
+
// dispatched scope cannot read `done` — flag, never fix
|
|
15
18
|
//
|
|
16
19
|
// Zero dependencies. Usage:
|
|
17
|
-
// node
|
|
20
|
+
// node kernel/harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write]
|
|
18
21
|
// [--appetite-hours N]
|
|
19
22
|
// Prints a JSON report; exit 0 (drift/overflow are flags for the caller's gate, not errors).
|
|
20
23
|
|
|
21
24
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
22
25
|
import { resolve, join } from "node:path";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { readAllContracts, splitFrontmatter, SCOPE_CONTRACT } from "../../tech-lead/scripts/lib/contract-md.mjs";
|
|
26
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
27
|
+
import { tasksDir, scopesDir, hillDir, ordersDir, resultsDir } from "../lib/paths.mjs";
|
|
28
|
+
import { readAllContracts, splitFrontmatter, tasksForScope, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Read a list field from a frontmatter string, inline `[a, b]` or YAML block sequence alike.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
* frontmatter readers: `contract
|
|
33
|
-
*
|
|
33
|
+
* The SECOND instance of the same defect, in a second parser. This repo had two hand-rolled
|
|
34
|
+
* frontmatter readers: `lib/contract.mjs` for the committed contracts, and this one for the board.
|
|
35
|
+
* It was fixed in the first, and this one still silently dropped a block sequence — so a board
|
|
34
36
|
* written as
|
|
35
37
|
* use_case_refs:
|
|
36
38
|
* - UC-01
|
|
@@ -61,7 +63,7 @@ const listField = (fm, key) => {
|
|
|
61
63
|
* Parse every TASK-*.md in a board directory into structured task records.
|
|
62
64
|
* @param {string} tasksDir - Absolute path to the LOCAL tasks directory.
|
|
63
65
|
* @returns {Array<{file:string, id:string, type:string, status:string, hours:number, pkg:string,
|
|
64
|
-
* depends_on:string[], unlocks:string[], use_case_refs:string[], body:string}>} One record per
|
|
66
|
+
* depends_on:string[], unlocks:string[], use_case_refs:string[], scope_id:string, body:string}>} One record per
|
|
65
67
|
* task file; [] when the directory does not exist.
|
|
66
68
|
*/
|
|
67
69
|
export function parseBoard(tasksDir) {
|
|
@@ -81,6 +83,7 @@ export function parseBoard(tasksDir) {
|
|
|
81
83
|
depends_on: listField(fm, "depends_on"),
|
|
82
84
|
unlocks: listField(fm, "unlocks"),
|
|
83
85
|
use_case_refs: listField(fm, "use_case_refs"),
|
|
86
|
+
scope_id: (fm.match(/^scope_id:\s*(\S+)/im) || [, ""])[1].trim(),
|
|
84
87
|
body,
|
|
85
88
|
};
|
|
86
89
|
});
|
|
@@ -136,24 +139,89 @@ export function criticalPath(tasks) {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
/**
|
|
139
|
-
* Flag board-vs-T0 drift: FINISHED scopes whose
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
+
* Flag board-vs-T0 drift: FINISHED scopes whose tasks are not yet done (flag only, never fix).
|
|
143
|
+
*
|
|
144
|
+
* The scope's tasks are joined through its committed `use_cases`, not read off a task-id list the
|
|
145
|
+
* contract used to carry — see {@link tasksForScope}. A scope that anchors no use case contributes
|
|
146
|
+
* nothing here rather than a false clean bill: "no anchor" is SCOPE-ANCHOR's finding to report.
|
|
147
|
+
*
|
|
148
|
+
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
149
|
+
* @param {Array<{scope_id:string, use_cases?:string[], finished?:boolean}>} scopes - Scope facts.
|
|
142
150
|
* @returns {Array<{scope_id:string, task_id:string, status:string}>} One entry per drifting task; [] when none.
|
|
143
151
|
*/
|
|
144
152
|
export function driftCheck(tasks, scopes) {
|
|
145
|
-
const byId = Object.fromEntries(tasks.map((t) => [t.id, t]));
|
|
146
153
|
const drift = [];
|
|
147
154
|
for (const s of scopes) {
|
|
148
|
-
if (!
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
if (t && t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: id, status: t.status });
|
|
155
|
+
if (!s.finished) continue;
|
|
156
|
+
for (const t of tasksForScope(tasks, s)) {
|
|
157
|
+
if (t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: t.id, status: t.status });
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
return drift;
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Read every dispatched-and-answered order's scope id (Phase 3.5 / S6).
|
|
165
|
+
*
|
|
166
|
+
* "Dispatched and answered" is filename presence in both directories, the same completion signal
|
|
167
|
+
* `sandbox-guard`'s `liveOrders()` already uses — a result join on content would let a malformed
|
|
168
|
+
* result count as an answer. The scope an order belongs to comes from `payload.scope_contract.
|
|
169
|
+
* scope_id`, not the filename: a non-BUILD order's suffix puts the operation before the scope id
|
|
170
|
+
* (`compileOrder`'s `scopedRoundSuffix`), so filename-prefix matching would miss it.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
173
|
+
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
174
|
+
* @returns {Set<string>} scope_ids with at least one dispatched order answered by a same-named
|
|
175
|
+
* result file; empty when neither directory has anything to read.
|
|
176
|
+
*/
|
|
177
|
+
function answeredScopeIds(orders, results) {
|
|
178
|
+
const answered = new Set();
|
|
179
|
+
if (!existsSync(orders)) return answered;
|
|
180
|
+
const done = new Set(existsSync(results) ? readdirSync(results) : []);
|
|
181
|
+
for (const f of readdirSync(orders)) {
|
|
182
|
+
if (!f.endsWith(".json") || !done.has(f)) continue;
|
|
183
|
+
try {
|
|
184
|
+
const sid = JSON.parse(readFileSync(join(orders, f), "utf8"))?.payload?.scope_contract?.scope_id;
|
|
185
|
+
if (sid) answered.add(sid);
|
|
186
|
+
} catch { /* an unreadable order answers nothing — spec-lint reports unreadable contracts, not this */ }
|
|
187
|
+
}
|
|
188
|
+
return answered;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Flag a scope whose board tasks read done but has no dispatched-and-answered order on disk
|
|
193
|
+
* (Phase 3.5 / S6) — the defect that motivated this phase: a scope cut before it was ever
|
|
194
|
+
* dispatched can still read `done` on the board because nothing compared the board's rows to what
|
|
195
|
+
* was actually dispatched, though both the scope contract and the order/result trail already exist
|
|
196
|
+
* on disk. A sibling to {@link driftCheck} (a FINISHED scope whose tasks are NOT done) — this is
|
|
197
|
+
* the opposite direction (done tasks, no dispatch trail) and both must keep running.
|
|
198
|
+
*
|
|
199
|
+
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
200
|
+
* @param {Array<{scope_id:string, use_cases?:string[]}>} scopes - Scope contracts.
|
|
201
|
+
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
202
|
+
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
203
|
+
* @returns {Array<{rule:string, level:string, scope_id:string, detail:string}>} One finding per
|
|
204
|
+
* undispatched-but-done scope, in this codebase's `{rule, level, detail}` finding shape
|
|
205
|
+
* (`kernel/verify/spec.mjs`); [] when every done-marked scope has a dispatch trail.
|
|
206
|
+
*/
|
|
207
|
+
export function undispatchedCheck(tasks, scopes, orders, results) {
|
|
208
|
+
const answered = answeredScopeIds(orders, results);
|
|
209
|
+
const findings = [];
|
|
210
|
+
for (const s of scopes) {
|
|
211
|
+
// Joined through the contract's committed `use_cases`, same as driftCheck — the two read the
|
|
212
|
+
// scope↔task relation from one function so they cannot disagree about which tasks a scope owns.
|
|
213
|
+
const doneTaskId = tasksForScope(tasks, s).find((t) => t.status === "done")?.id;
|
|
214
|
+
if (doneTaskId && !answered.has(s.scope_id)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
rule: "BOARD-UNDISPATCHED", level: "red", scope_id: s.scope_id,
|
|
217
|
+
detail: `${doneTaskId} reads done for scope ${s.scope_id}, but no order under orders/ has a ` +
|
|
218
|
+
`matching result under results/ for this scope — a cut, never-dispatched scope cannot read done`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
157
225
|
/**
|
|
158
226
|
* Derive the full board report (unlocks, hours, packages, critical path, appetite overflow, drift).
|
|
159
227
|
* @param {{cwd:string, slug:string, appetiteHours?:(number|null)}} opts - Working root, feature
|
|
@@ -163,14 +231,15 @@ export function driftCheck(tasks, scopes) {
|
|
|
163
231
|
* board, deleted before CLI output).
|
|
164
232
|
*/
|
|
165
233
|
export function derive({ cwd, slug, appetiteHours = null }) {
|
|
166
|
-
const
|
|
167
|
-
const tasks = parseBoard(
|
|
234
|
+
const boardDir = tasksDir(cwd, slug);
|
|
235
|
+
const tasks = parseBoard(boardDir);
|
|
168
236
|
const unlocks = deriveUnlocks(tasks);
|
|
169
237
|
const keepHours = tasks.filter((t) => t.status !== "cut").reduce((a, t) => a + t.hours, 0);
|
|
170
238
|
const packages = {};
|
|
171
239
|
for (const t of tasks) packages[t.pkg || "(none)"] = (packages[t.pkg || "(none)"] || 0) + 1;
|
|
172
240
|
|
|
173
|
-
// Scope facts for the drift check: contract `
|
|
241
|
+
// Scope facts for the drift check: the contract (its `use_cases` carry the task join)
|
|
242
|
+
// + the committed hill shard's phase.
|
|
174
243
|
const hillRoot = hillDir(cwd, slug);
|
|
175
244
|
const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map(({ contract }) => {
|
|
176
245
|
const shard = join(hillRoot, `${contract.scope_id}.yml`);
|
|
@@ -194,6 +263,7 @@ export function derive({ cwd, slug, appetiteHours = null }) {
|
|
|
194
263
|
unlocks,
|
|
195
264
|
unlocks_stale: tasks.filter((t) => JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(unlocks[t.id])).map((t) => t.id),
|
|
196
265
|
drift: driftCheck(tasks, scopes),
|
|
266
|
+
undispatched: undispatchedCheck(tasks, scopes, ordersDir(cwd, slug), resultsDir(cwd, slug)),
|
|
197
267
|
_tasks: tasks,
|
|
198
268
|
};
|
|
199
269
|
}
|
|
@@ -220,9 +290,9 @@ export function writeUnlocks(report) {
|
|
|
220
290
|
return written;
|
|
221
291
|
}
|
|
222
292
|
|
|
223
|
-
/** The typed argv contract (see `
|
|
293
|
+
/** The typed argv contract (see `kernel/lib/argv.mjs`). */
|
|
224
294
|
export const ARGV_SPEC = {
|
|
225
|
-
usage: "
|
|
295
|
+
usage: "harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
|
|
226
296
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
227
297
|
slug: { type: "str", required: true },
|
|
228
298
|
cwd: { type: "path" },
|
|
@@ -230,9 +300,15 @@ export const ARGV_SPEC = {
|
|
|
230
300
|
"appetite-hours": { type: "num", min: 0 },
|
|
231
301
|
};
|
|
232
302
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Derive the task board from the scope contracts, and optionally write it.
|
|
305
|
+
*
|
|
306
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
307
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
308
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
309
|
+
*/
|
|
310
|
+
export async function cli(rawArgv) {
|
|
311
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
236
312
|
const cwd = resolve(args.cwd || process.cwd());
|
|
237
313
|
const report = derive({ cwd, slug: args.slug, appetiteHours: args.appetiteHours ?? null });
|
|
238
314
|
if (args.write) report.unlocks_written = writeUnlocks(report);
|