shapeup-sdlc 3.18.2 → 4.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 +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +29 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/kernel/probe/t0.mjs
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
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, sha256?, round, scope_id}` on stdout; exits 0 when green, 1 when not, 2 on a bad
|
|
5
|
-
// argv. Writes nothing.
|
|
6
|
-
//
|
|
7
|
-
// WHY IT PRINTS A DIGEST. A verdict on a scoped spec must cite a T0 artifact with the sha256 of the
|
|
8
|
-
// file as it is on disk now, and the judge is asked to obtain that itself rather than accept one it
|
|
9
|
-
// was handed. Some sessions have no shell hasher they may run, and a grant that covers this entry
|
|
10
|
-
// point and not `shasum` is the ordinary case, not an oversight. The digest here is computed from
|
|
11
|
-
// the bytes at the moment of the call, with the same reading the ingest step re-checks it against,
|
|
12
|
-
// so a judge that asks for it is asking the file, not a caller. `sha256` is present only when the
|
|
13
|
-
// verdict is green and readable; absent — never null — otherwise.
|
|
14
|
-
//
|
|
15
|
-
// WHY IT IS A SUBCOMMAND AND NOT AN INLINE SNIPPET. The control plane has no filesystem of its
|
|
16
|
-
// own, so the alternative is a `node -e` blob assembled inside the workflow script. Such a blob is
|
|
17
|
-
// untestable (a workflow script cannot be imported), and it is the one command shape no permission
|
|
18
|
-
// rule can match — the grant covers this entry point, not arbitrary evaluated source.
|
|
19
|
-
//
|
|
20
|
-
// WHY IT READS ARTIFACTS AND NOT A LEDGER. It answers the question a RESUMED round asks: an
|
|
21
|
-
// attempt loop that was killed mid-round must not re-do a scope whose T0 already went green, and
|
|
22
|
-
// the only durable evidence of that is the verdict artifact the evaluator is required to cite.
|
|
23
|
-
|
|
24
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
|
-
import { join, resolve } from "node:path";
|
|
27
|
-
import { runArgs } from "../lib/argv.mjs";
|
|
28
|
-
import { verdictsDir, readRunId } from "../lib/paths.mjs";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Verdict filenames, newest first by their NUMERIC address.
|
|
32
|
-
*
|
|
33
|
-
* A string sort files `r1-a1-t10.json` before `r1-a1-t9.json`, and the trial ordinal is shared by
|
|
34
|
-
* every scope verified at one (round, attempt) — ten scopes on their first attempt are enough to
|
|
35
|
-
* make an older verdict read as the newest. Names that carry no address sort last.
|
|
36
|
-
*
|
|
37
|
-
* @param {string[]} names - Filenames from the verdicts directory.
|
|
38
|
-
* @returns {string[]} A new array, newest first: round, then attempt, then trial, descending.
|
|
39
|
-
*/
|
|
40
|
-
export function newestFirst(names) {
|
|
41
|
-
const key = (f) => {
|
|
42
|
-
const m = f.match(/^r(\d+)-a(\d+)(?:-t(\d+))?\.json$/);
|
|
43
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : [-1, -1, -1];
|
|
44
|
-
};
|
|
45
|
-
return [...names].sort((a, b) => {
|
|
46
|
-
const ka = key(a), kb = key(b);
|
|
47
|
-
for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i];
|
|
48
|
-
return b.localeCompare(a);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The newest green T0 verdict for one scope in one round.
|
|
54
|
-
*
|
|
55
|
-
* @param {string} cwd - Project root.
|
|
56
|
-
* @param {string} slug - Feature slug.
|
|
57
|
-
* @param {string} scopeId - Scope contract id.
|
|
58
|
-
* @param {number} [round] - Build round. Omitted, the newest green verdict of ANY round — what an
|
|
59
|
-
* evaluation with no round (a standalone single pass) has to cite.
|
|
60
|
-
* @returns {{green: boolean, path: (string|null)}} `path` is the artifact a later EVAL can cite.
|
|
61
|
-
*/
|
|
62
|
-
export function greenVerdict(cwd, slug, scopeId, round) {
|
|
63
|
-
const dir = verdictsDir(cwd, slug);
|
|
64
|
-
if (!existsSync(dir)) return { green: false, path: null };
|
|
65
|
-
// Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
|
|
66
|
-
// (round, attempt) address, and the LAST verdict is the one that stands.
|
|
67
|
-
// THIS RUN'S VERDICTS. Verdicts over one slug accumulate across runs and carry the run's key; a
|
|
68
|
-
// second run used to be told its scope was green on the first run's artifact, and the round loop
|
|
69
|
-
// skipped building it. A verdict with no key at all predates the key and is kept.
|
|
70
|
-
const runId = readRunId(cwd, slug);
|
|
71
|
-
for (const f of newestFirst(readdirSync(dir).filter((x) => x.endsWith(".json")))) {
|
|
72
|
-
const p = join(dir, f);
|
|
73
|
-
try {
|
|
74
|
-
const b = JSON.parse(readFileSync(p, "utf8"));
|
|
75
|
-
if (runId && b.run_id && b.run_id !== runId) continue;
|
|
76
|
-
if (b.scope_id === scopeId && (round == null || b.round === round) && b.overall === "green") return { green: true, path: p };
|
|
77
|
-
} catch { /* a torn artifact proves nothing; keep looking */ }
|
|
78
|
-
}
|
|
79
|
-
return { green: false, path: null };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export const ARGV_SPEC = {
|
|
83
|
-
usage: "harness.mjs probe t0 --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
|
|
84
|
-
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
85
|
-
slug: { type: "str", required: true },
|
|
86
|
-
scope: { type: "str", required: true },
|
|
87
|
-
round: { type: "int", min: 1, required: true },
|
|
88
|
-
cwd: { type: "path" },
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Report whether one scope is already T0-green for one round.
|
|
93
|
-
*
|
|
94
|
-
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
95
|
-
* @returns {void} Exits 0 when green, 1 when not — the shape a caller can branch on without parsing.
|
|
96
|
-
*/
|
|
97
|
-
export function cli(rawArgv) {
|
|
98
|
-
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
99
|
-
const cwd = resolve(args.cwd || process.cwd());
|
|
100
|
-
const { green, path } = greenVerdict(cwd, args.slug, args.scope, args.round);
|
|
101
|
-
const sha256 = green ? digestOf(path) : null;
|
|
102
|
-
console.log(JSON.stringify({ green, path, ...(sha256 ? { sha256 } : {}), scope_id: args.scope, round: args.round }));
|
|
103
|
-
process.exit(green ? 0 : 1);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The sha256 of a verdict artifact, read as `reduce ingest` re-reads it (utf8 text, then hashed) so
|
|
108
|
-
* the two can never disagree about the same bytes.
|
|
109
|
-
* @param {string} path - Absolute path of the verdict file.
|
|
110
|
-
* @returns {(string|null)} Lowercase hex digest, or null when the file cannot be read.
|
|
111
|
-
*/
|
|
112
|
-
function digestOf(path) {
|
|
113
|
-
try { return createHash("sha256").update(readFileSync(path, "utf8")).digest("hex"); }
|
|
114
|
-
catch { return null; }
|
|
115
|
-
}
|
package/kernel/reduce/board.mjs
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Board derivation — the planner's graph math, as code a model never re-derives.
|
|
3
|
-
//
|
|
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 gets wrong: hand-authored inverses drift asymmetric
|
|
6
|
-
// edges came from hand-authored `unlocks`):
|
|
7
|
-
//
|
|
8
|
-
// • `unlocks` = the depends_on inverse, recomputed over the WHOLE board (--write persists
|
|
9
|
-
// it into task frontmatter — run on every ingest; asymmetric edges become impossible)
|
|
10
|
-
// • Σ estimated_hours, package distribution, critical path (longest depends_on chain)
|
|
11
|
-
// • Appetite Guard arithmetic (--appetite-hours N → overflow flag; the HAMMER *pause* on
|
|
12
|
-
// overflow is an orchestrator gate, never resolved here)
|
|
13
|
-
// • board-vs-T0 drift check (a FINISHED scope whose tasks still read `ready`) when scope
|
|
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
|
|
18
|
-
//
|
|
19
|
-
// Zero dependencies. Usage:
|
|
20
|
-
// node kernel/harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write]
|
|
21
|
-
// [--appetite-hours N]
|
|
22
|
-
// Prints a JSON report; exit 0 (drift/overflow are flags for the caller's gate, not errors).
|
|
23
|
-
|
|
24
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
25
|
-
import { resolve, join } from "node:path";
|
|
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";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Read a list field from a frontmatter string, inline `[a, b]` or YAML block sequence alike.
|
|
32
|
-
*
|
|
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
|
|
36
|
-
* written as
|
|
37
|
-
* use_case_refs:
|
|
38
|
-
* - UC-01
|
|
39
|
-
* touched_files:
|
|
40
|
-
* - src/review/format.js
|
|
41
|
-
* lost EVERY list field. Measured on three paid `ba-pitch-analyzer` runs: `touched_files` and
|
|
42
|
-
* `use_case_refs` came back empty for all twenty tasks, `depends_on`/`unlocks` came back empty in
|
|
43
|
-
* both directions so edge-symmetry passed VACUOUSLY, and the only reason it was not entirely silent
|
|
44
|
-
* is that UC-ANCHOR went red. Downstream, `scope-architect` receives a board with no slicing input
|
|
45
|
-
* at all.
|
|
46
|
-
*
|
|
47
|
-
* The fix is to stop having two parsers rather than to patch this one: `splitFrontmatter` already
|
|
48
|
-
* reads both forms, so this delegates to it. A second implementation of a format is a second
|
|
49
|
-
* format, and these two had already drifted.
|
|
50
|
-
*
|
|
51
|
-
* @param {string} fm - The frontmatter text (without the `---` fences).
|
|
52
|
-
* @param {string} key - The list key to read.
|
|
53
|
-
* @returns {string[]} The trimmed, unquoted, non-empty members; [] when the key is absent.
|
|
54
|
-
*/
|
|
55
|
-
const listField = (fm, key) => {
|
|
56
|
-
const v = splitFrontmatter(`---\n${fm}\n---\n`).meta[key];
|
|
57
|
-
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
|
|
58
|
-
if (v === null || v === undefined || v === "") return [];
|
|
59
|
-
return [String(v).trim()].filter(Boolean);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Parse every TASK-*.md in a board directory into structured task records.
|
|
64
|
-
* @param {string} tasksDir - Absolute path to the LOCAL tasks directory.
|
|
65
|
-
* @returns {Array<{file:string, id:string, type:string, status:string, hours:number, pkg:string,
|
|
66
|
-
* depends_on:string[], unlocks:string[], use_case_refs:string[], scope_id:string, body:string}>} One record per
|
|
67
|
-
* task file; [] when the directory does not exist.
|
|
68
|
-
*/
|
|
69
|
-
export function parseBoard(tasksDir) {
|
|
70
|
-
if (!existsSync(tasksDir)) return [];
|
|
71
|
-
// SORTED, because `criticalPath` breaks ties on strict `>` and therefore keeps the FIRST chain it
|
|
72
|
-
// meets among equal-hours chains. Directory order is a filesystem detail (APFS happens to return
|
|
73
|
-
// sorted; ext4's hash order does not, and neither does a rename), so an unsorted read made a
|
|
74
|
-
// derived value depend on which machine ran it. Every sibling reader in the kernel already sorts.
|
|
75
|
-
return readdirSync(tasksDir).sort()
|
|
76
|
-
.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))
|
|
77
|
-
.map((f) => {
|
|
78
|
-
const body = readFileSync(join(tasksDir, f), "utf8");
|
|
79
|
-
const fm = (body.match(/^---\r?\n([\s\S]*?)\r?\n---/) || [, ""])[1];
|
|
80
|
-
return {
|
|
81
|
-
file: join(tasksDir, f),
|
|
82
|
-
id: (fm.match(/^id:\s*(TASK-[\w.-]+)/im) || [])[1] || f.replace(/\.md$/, ""),
|
|
83
|
-
type: (fm.match(/^type:\s*(\S+)/im) || [, ""])[1],
|
|
84
|
-
status: (fm.match(/^status:\s*(\S+)/im) || [, "unknown"])[1],
|
|
85
|
-
hours: Number((fm.match(/^estimated_hours:\s*([\d.]+)/im) || [])[1]) || 0,
|
|
86
|
-
pkg: (fm.match(/^package:\s*(.+)$/im) || [, ""])[1].trim(),
|
|
87
|
-
depends_on: listField(fm, "depends_on"),
|
|
88
|
-
unlocks: listField(fm, "unlocks"),
|
|
89
|
-
use_case_refs: listField(fm, "use_case_refs"),
|
|
90
|
-
scope_id: (fm.match(/^scope_id:\s*(\S+)/im) || [, ""])[1].trim(),
|
|
91
|
-
body,
|
|
92
|
-
};
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Compute `unlocks` as the inverse of `depends_on` over the whole board (never hand-authored).
|
|
98
|
-
* @param {Array<{id:string, depends_on:string[]}>} tasks - The parsed board.
|
|
99
|
-
* @returns {Object<string,string[]>} Map of task id → sorted ids it unlocks.
|
|
100
|
-
*/
|
|
101
|
-
export function deriveUnlocks(tasks) {
|
|
102
|
-
const unlocks = Object.fromEntries(tasks.map((t) => [t.id, []]));
|
|
103
|
-
for (const t of tasks) for (const dep of t.depends_on) if (unlocks[dep]) unlocks[dep].push(t.id);
|
|
104
|
-
for (const id of Object.keys(unlocks)) unlocks[id].sort();
|
|
105
|
-
return unlocks;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Find the critical path: the depends_on chain with the greatest summed hours.
|
|
110
|
-
* @param {Array<{id:string, depends_on:string[], hours:number}>} tasks - The parsed board.
|
|
111
|
-
* @returns {{hours:number, chain:string[]}} The longest chain's total hours and its task ids in
|
|
112
|
-
* dependency order (cycles are guarded and contribute nothing).
|
|
113
|
-
*/
|
|
114
|
-
export function criticalPath(tasks) {
|
|
115
|
-
const byId = Object.fromEntries(tasks.map((t) => [t.id, t]));
|
|
116
|
-
const memo = {};
|
|
117
|
-
/**
|
|
118
|
-
* Longest depends_on chain rooted at a task, by summed hours (memoized, cycle-guarded).
|
|
119
|
-
* @param {string} id - Task id to start from.
|
|
120
|
-
* @param {Set<string>} [seen=new Set()] - Ids on the current DFS path (cycle guard); callers omit it.
|
|
121
|
-
* @returns {{hours:number, chain:string[]}} The heaviest chain's total hours and its task ids in
|
|
122
|
-
* dependency order; {hours:0, chain:[]} for an unknown id or a detected cycle.
|
|
123
|
-
*/
|
|
124
|
-
const longest = (id, seen = new Set()) => {
|
|
125
|
-
if (memo[id]) return memo[id];
|
|
126
|
-
if (seen.has(id)) return { hours: 0, chain: [] }; // cycle guard — lint reports it separately
|
|
127
|
-
seen.add(id);
|
|
128
|
-
const t = byId[id];
|
|
129
|
-
if (!t) return { hours: 0, chain: [] };
|
|
130
|
-
let best = { hours: 0, chain: [] };
|
|
131
|
-
for (const dep of t.depends_on) {
|
|
132
|
-
const c = longest(dep, seen);
|
|
133
|
-
if (c.hours > best.hours) best = c;
|
|
134
|
-
}
|
|
135
|
-
return (memo[id] = { hours: best.hours + t.hours, chain: [...best.chain, id] });
|
|
136
|
-
};
|
|
137
|
-
// TIES BREAK ON CONTENT, NOT ON ARRIVAL. `>` alone keeps whichever equal-hours chain is MET
|
|
138
|
-
// FIRST, which is input order — so this function returned a different critical path for the same
|
|
139
|
-
// board depending only on how its task list happened to be ordered. Measured: two disjoint
|
|
140
|
-
// 5-hour chains, five permutations of one list, two different answers.
|
|
141
|
-
//
|
|
142
|
-
// Sorting the reader that feeds it (`parseBoard`) makes the input stable and therefore hides this
|
|
143
|
-
// on any one machine, but it leaves the ORDER-DEPENDENCE in place one call up — a caller with its
|
|
144
|
-
// own ordering, or a future second reader, re-opens it. A derived value has to be a function of
|
|
145
|
-
// the board, not of the walk that produced it, so the tie is resolved here: same hours, then the
|
|
146
|
-
// lexicographically smaller chain. Deterministic on every machine and for every caller.
|
|
147
|
-
/**
|
|
148
|
-
* Is chain `c` a better critical path than the incumbent `b`?
|
|
149
|
-
* @param {{hours:number, chain:string[]}} c - The candidate chain.
|
|
150
|
-
* @param {{hours:number, chain:string[]}} b - The incumbent best.
|
|
151
|
-
* @returns {boolean} True when `c` has more hours, or ties on hours and sorts first by chain
|
|
152
|
-
* content — so the answer is a function of the board rather than of the iteration order.
|
|
153
|
-
*/
|
|
154
|
-
const better = (c, b) => c.hours > b.hours
|
|
155
|
-
|| (c.hours === b.hours && c.chain.length > 0
|
|
156
|
-
&& (b.chain.length === 0 || c.chain.join("\u0000") < b.chain.join("\u0000")));
|
|
157
|
-
let best = { hours: 0, chain: [] };
|
|
158
|
-
for (const t of tasks) {
|
|
159
|
-
const c = longest(t.id);
|
|
160
|
-
if (better(c, best)) best = c;
|
|
161
|
-
}
|
|
162
|
-
return best;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Flag board-vs-T0 drift: FINISHED scopes whose tasks are not yet done (flag only, never fix).
|
|
167
|
-
*
|
|
168
|
-
* The scope's tasks are joined through its committed `use_cases`, not read off a task-id list the
|
|
169
|
-
* contract used to carry — see {@link tasksForScope}. A scope that anchors no use case contributes
|
|
170
|
-
* nothing here rather than a false clean bill: "no anchor" is SCOPE-ANCHOR's finding to report.
|
|
171
|
-
*
|
|
172
|
-
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
173
|
-
* @param {Array<{scope_id:string, use_cases?:string[], finished?:boolean}>} scopes - Scope facts.
|
|
174
|
-
* @returns {Array<{scope_id:string, task_id:string, status:string}>} One entry per drifting task; [] when none.
|
|
175
|
-
*/
|
|
176
|
-
export function driftCheck(tasks, scopes) {
|
|
177
|
-
const drift = [];
|
|
178
|
-
for (const s of scopes) {
|
|
179
|
-
if (!s.finished) continue;
|
|
180
|
-
for (const t of tasksForScope(tasks, s)) {
|
|
181
|
-
if (t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: t.id, status: t.status });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return drift;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Read every dispatched-and-answered order's scope id (Phase 3.5 / S6).
|
|
189
|
-
*
|
|
190
|
-
* "Dispatched and answered" is filename presence in both directories, the same completion signal
|
|
191
|
-
* `sandbox-guard`'s `liveOrders()` already uses — a result join on content would let a malformed
|
|
192
|
-
* result count as an answer. The scope an order belongs to comes from `payload.scope_contract.
|
|
193
|
-
* scope_id`, not the filename: a non-BUILD order's suffix puts the operation before the scope id
|
|
194
|
-
* (`compileOrder`'s `scopedRoundSuffix`), so filename-prefix matching would miss it.
|
|
195
|
-
*
|
|
196
|
-
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
197
|
-
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
198
|
-
* @returns {Set<string>} scope_ids with at least one dispatched order answered by a same-named
|
|
199
|
-
* result file; empty when neither directory has anything to read.
|
|
200
|
-
*/
|
|
201
|
-
function answeredScopeIds(orders, results) {
|
|
202
|
-
const answered = new Set();
|
|
203
|
-
if (!existsSync(orders)) return answered;
|
|
204
|
-
const done = new Set(existsSync(results) ? readdirSync(results) : []);
|
|
205
|
-
for (const f of readdirSync(orders)) {
|
|
206
|
-
if (!f.endsWith(".json") || !done.has(f)) continue;
|
|
207
|
-
try {
|
|
208
|
-
const sid = JSON.parse(readFileSync(join(orders, f), "utf8"))?.payload?.scope_contract?.scope_id;
|
|
209
|
-
if (sid) answered.add(sid);
|
|
210
|
-
} catch { /* an unreadable order answers nothing — spec-lint reports unreadable contracts, not this */ }
|
|
211
|
-
}
|
|
212
|
-
return answered;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Flag a scope whose board tasks read done but has no dispatched-and-answered order on disk
|
|
217
|
-
* (Phase 3.5 / S6) — the defect that motivated this phase: a scope cut before it was ever
|
|
218
|
-
* dispatched can still read `done` on the board because nothing compared the board's rows to what
|
|
219
|
-
* was actually dispatched, though both the scope contract and the order/result trail already exist
|
|
220
|
-
* on disk. A sibling to {@link driftCheck} (a FINISHED scope whose tasks are NOT done) — this is
|
|
221
|
-
* the opposite direction (done tasks, no dispatch trail) and both must keep running.
|
|
222
|
-
*
|
|
223
|
-
* @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
|
|
224
|
-
* @param {Array<{scope_id:string, use_cases?:string[]}>} scopes - Scope contracts.
|
|
225
|
-
* @param {string} orders - Absolute path to the run's `orders/` directory.
|
|
226
|
-
* @param {string} results - Absolute path to the run's `results/` directory.
|
|
227
|
-
* @returns {Array<{rule:string, level:string, scope_id:string, detail:string}>} One finding per
|
|
228
|
-
* undispatched-but-done scope, in this codebase's `{rule, level, detail}` finding shape
|
|
229
|
-
* (`kernel/verify/spec.mjs`); [] when every done-marked scope has a dispatch trail.
|
|
230
|
-
*/
|
|
231
|
-
export function undispatchedCheck(tasks, scopes, orders, results) {
|
|
232
|
-
const answered = answeredScopeIds(orders, results);
|
|
233
|
-
const findings = [];
|
|
234
|
-
for (const s of scopes) {
|
|
235
|
-
// Joined through the contract's committed `use_cases`, same as driftCheck — the two read the
|
|
236
|
-
// scope↔task relation from one function so they cannot disagree about which tasks a scope owns.
|
|
237
|
-
const doneTaskId = tasksForScope(tasks, s).find((t) => t.status === "done")?.id;
|
|
238
|
-
if (doneTaskId && !answered.has(s.scope_id)) {
|
|
239
|
-
findings.push({
|
|
240
|
-
rule: "BOARD-UNDISPATCHED", level: "red", scope_id: s.scope_id,
|
|
241
|
-
detail: `${doneTaskId} reads done for scope ${s.scope_id}, but no order under orders/ has a ` +
|
|
242
|
-
`matching result under results/ for this scope — a cut, never-dispatched scope cannot read done`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return findings;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Derive the full board report (unlocks, hours, packages, critical path, appetite overflow, drift).
|
|
251
|
-
* @param {{cwd:string, slug:string, appetiteHours?:(number|null)}} opts - Working root, feature
|
|
252
|
-
* slug, and optional appetite budget (in hours) that drives the overflow flag.
|
|
253
|
-
* @returns {object} The report: task_count, by_status, packages, total/keep hours, appetite
|
|
254
|
-
* overflow (or null), critical_path, unlocks + unlocks_stale, drift[], and `_tasks` (the parsed
|
|
255
|
-
* board, deleted before CLI output).
|
|
256
|
-
*/
|
|
257
|
-
export function derive({ cwd, slug, appetiteHours = null }) {
|
|
258
|
-
const boardDir = tasksDir(cwd, slug);
|
|
259
|
-
const tasks = parseBoard(boardDir);
|
|
260
|
-
const unlocks = deriveUnlocks(tasks);
|
|
261
|
-
const keepHours = tasks.filter((t) => t.status !== "cut").reduce((a, t) => a + t.hours, 0);
|
|
262
|
-
const packages = {};
|
|
263
|
-
for (const t of tasks) packages[t.pkg || "(none)"] = (packages[t.pkg || "(none)"] || 0) + 1;
|
|
264
|
-
|
|
265
|
-
// Scope facts for the drift check: the contract (its `use_cases` carry the task join)
|
|
266
|
-
// + the committed hill shard's phase.
|
|
267
|
-
const hillRoot = hillDir(cwd, slug);
|
|
268
|
-
const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map(({ contract }) => {
|
|
269
|
-
const shard = join(hillRoot, `${contract.scope_id}.yml`);
|
|
270
|
-
const finished = existsSync(shard) && /phase:\s*FINISHED/.test(readFileSync(shard, "utf8"));
|
|
271
|
-
return { ...contract, finished };
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
return {
|
|
275
|
-
slug,
|
|
276
|
-
task_count: tasks.length,
|
|
277
|
-
by_status: tasks.reduce((m, t) => ((m[t.status] = (m[t.status] || 0) + 1), m), {}),
|
|
278
|
-
packages,
|
|
279
|
-
total_hours: tasks.reduce((a, t) => a + t.hours, 0),
|
|
280
|
-
keep_hours: keepHours,
|
|
281
|
-
appetite: appetiteHours === null ? null : {
|
|
282
|
-
appetite_hours: appetiteHours,
|
|
283
|
-
overflow: keepHours > appetiteHours,
|
|
284
|
-
overflow_hours: Math.max(0, keepHours - appetiteHours),
|
|
285
|
-
},
|
|
286
|
-
critical_path: criticalPath(tasks),
|
|
287
|
-
unlocks,
|
|
288
|
-
unlocks_stale: tasks.filter((t) => JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(unlocks[t.id])).map((t) => t.id),
|
|
289
|
-
drift: driftCheck(tasks, scopes),
|
|
290
|
-
undispatched: undispatchedCheck(tasks, scopes, ordersDir(cwd, slug), resultsDir(cwd, slug)),
|
|
291
|
-
_tasks: tasks,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Persist derived `unlocks` into task frontmatter — and give a task with NO status the one status a
|
|
297
|
-
* task on a freshly written board can have.
|
|
298
|
-
*
|
|
299
|
-
* `unlocks` is derived, so writing it is safe by construction. `status: todo` is the other field a
|
|
300
|
-
* board is written with, and it is added only where the line is absent — an existing status is never
|
|
301
|
-
* touched, so a task in progress or done is never reset. A regenerated board once came back with
|
|
302
|
-
* `depends_on` and neither field on every task; spec-lint failed all of them at L1b and an unattended
|
|
303
|
-
* run had nobody to add a line the worker's own template already prescribes.
|
|
304
|
-
*
|
|
305
|
-
* @param {{_tasks:Array<object>, unlocks:Object<string,string[]>}} report - A {@link derive} report.
|
|
306
|
-
* @returns {string[]} The ids of task files actually rewritten (unchanged files are skipped).
|
|
307
|
-
* Side effect: writes those task files.
|
|
308
|
-
*/
|
|
309
|
-
export function writeUnlocks(report) {
|
|
310
|
-
const written = [];
|
|
311
|
-
for (const t of report._tasks) {
|
|
312
|
-
const want = `[${report.unlocks[t.id].join(", ")}]`;
|
|
313
|
-
const fmMatch = t.body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
314
|
-
if (!fmMatch) continue;
|
|
315
|
-
const fm = fmMatch[1];
|
|
316
|
-
let next = /^unlocks:.*$/m.test(fm) ? fm.replace(/^unlocks:.*$/m, `unlocks: ${want}`) : `${fm}\nunlocks: ${want}`;
|
|
317
|
-
if (!/^status:/m.test(next)) next = `${next}\nstatus: todo`;
|
|
318
|
-
if (next !== fm) {
|
|
319
|
-
writeFileSync(t.file, t.body.replace(fm, next));
|
|
320
|
-
written.push(t.id);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return written;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** The typed argv contract (see `kernel/lib/argv.mjs`). */
|
|
327
|
-
export const ARGV_SPEC = {
|
|
328
|
-
usage: "harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
|
|
329
|
-
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
330
|
-
slug: { type: "str", required: true },
|
|
331
|
-
cwd: { type: "path" },
|
|
332
|
-
write: { type: "flag" },
|
|
333
|
-
"appetite-hours": { type: "num", min: 0 },
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Derive the task board from the scope contracts, and optionally write it.
|
|
338
|
-
*
|
|
339
|
-
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
340
|
-
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
341
|
-
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
342
|
-
*/
|
|
343
|
-
export async function cli(rawArgv) {
|
|
344
|
-
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
345
|
-
const cwd = resolve(args.cwd || process.cwd());
|
|
346
|
-
const report = derive({ cwd, slug: args.slug, appetiteHours: args.appetiteHours ?? null });
|
|
347
|
-
if (args.write) report.unlocks_written = writeUnlocks(report);
|
|
348
|
-
delete report._tasks;
|
|
349
|
-
console.log(JSON.stringify(report, null, 2));
|
|
350
|
-
}
|