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,21 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// THE THIRD BREAKER — wall clock. Derived, never claimed; checked at every round boundary.
|
|
3
3
|
//
|
|
4
|
-
// WHY THIS EXISTS (
|
|
4
|
+
// WHY THIS EXISTS (and it corrects an earlier diagnosis).
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// was that it had stalled at a gate with no human to sign off.
|
|
6
|
+
// A run killed at an external time cap produces nothing scoreable, and the obvious reading is that
|
|
7
|
+
// it stalled at a gate with no human to sign off.
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// 19 gate markers, last gate reached L3 · narration_ratio 0.047 · stall_signals 0
|
|
15
|
-
//
|
|
16
|
-
// It was not waiting. It was the LEAST talkative shapeup run in the whole matrix and one of the
|
|
17
|
-
// busiest rows in the dataset, and it got killed mid-loop at GATE L3 — Verdict & Loop. It ran
|
|
18
|
-
// out of clock while genuinely working.
|
|
9
|
+
// Reading the transcript with transcript-level metrics often says otherwise, and not narrowly:
|
|
10
|
+
// steady turns, steady tool calls, steady file writes, gate markers advancing, a low narration
|
|
11
|
+
// ratio and zero stall signals — a run killed mid-loop at GATE L3, Verdict & Loop. It was not
|
|
12
|
+
// waiting. It ran out of clock while genuinely working.
|
|
19
13
|
//
|
|
20
14
|
// That makes the defect specific: **both existing breakers count events, not time.**
|
|
21
15
|
// `round_budget` decrements once per round; `attempt_budget` decrements once per T0 attempt.
|
|
@@ -39,8 +33,8 @@
|
|
|
39
33
|
// `off` and nothing changes. Existing runs behave exactly as before.
|
|
40
34
|
//
|
|
41
35
|
// USAGE
|
|
42
|
-
// node budget
|
|
43
|
-
// node budget
|
|
36
|
+
// node `harness verify budget` [--slug <slug>] [--cwd <root>] [--at <ISO>] # status, exit 0
|
|
37
|
+
// node `harness verify budget` --strict … # exit 6 when the breaker has tripped
|
|
44
38
|
//
|
|
45
39
|
// Output: { status, elapsed_s, budget_s, remaining_s, used_fraction, action }
|
|
46
40
|
// status ∈ off | ok | warn | trip
|
|
@@ -48,9 +42,8 @@
|
|
|
48
42
|
|
|
49
43
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
50
44
|
import { join } from "node:path";
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
import { localDir } from "./lib/paths.mjs";
|
|
45
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
46
|
+
import { localDir } from "../lib/paths.mjs";
|
|
54
47
|
|
|
55
48
|
/** Fraction of the budget at which the run should stop STARTING work it cannot finish. */
|
|
56
49
|
export const WARN_AT = 0.75;
|
|
@@ -122,7 +115,7 @@ export function findRun(cwd, slug = null) {
|
|
|
122
115
|
|
|
123
116
|
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
124
117
|
export const ARGV_SPEC = {
|
|
125
|
-
usage: "
|
|
118
|
+
usage: "harness.mjs verify budget [--slug <slug>] [--cwd <dir>] [--at <iso8601>] [--strict]",
|
|
126
119
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
127
120
|
slug: { type: "str" },
|
|
128
121
|
cwd: { type: "path" },
|
|
@@ -130,12 +123,19 @@ export const ARGV_SPEC = {
|
|
|
130
123
|
strict: { type: "flag" },
|
|
131
124
|
};
|
|
132
125
|
|
|
133
|
-
|
|
134
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Check the wall-clock budget for the open run and report the breaker's verdict.
|
|
128
|
+
*
|
|
129
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
130
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
131
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
132
|
+
*/
|
|
133
|
+
export function cli(rawArgv) {
|
|
134
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
135
135
|
const cwd = args.cwd || process.cwd();
|
|
136
136
|
const run = findRun(cwd, args.slug ?? null);
|
|
137
137
|
if (!run) {
|
|
138
|
-
console.error("no run receipt found — open the run with init
|
|
138
|
+
console.error("no run receipt found — open the run with `harness init run` first (GATE L0.1).");
|
|
139
139
|
process.exit(2);
|
|
140
140
|
}
|
|
141
141
|
const startedAt = Date.parse(run.receipt.started_at || "");
|
|
@@ -151,6 +151,3 @@ export function main() {
|
|
|
151
151
|
process.exit(args.strict && result.status === "trip" ? 6 : 0);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
if (isMain(import.meta.url)) {
|
|
155
|
-
main();
|
|
156
|
-
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Did THIS SESSION resolve that skill? — the canary's read half.
|
|
3
|
+
//
|
|
4
|
+
// `verify skills` proves the SKILL.md files exist at a root at a version. It cannot prove the
|
|
5
|
+
// session will resolve that copy, because that is a property of how the CLI was launched:
|
|
6
|
+
// `--plugin-dir`, an enabled marketplace install, the right version among several installed. Both
|
|
7
|
+
// states the diagnosis names — installed-but-disabled, and wrong-version-loaded — pass a file check
|
|
8
|
+
// green. Only a live dispatch answers the question, and this reads its evidence.
|
|
9
|
+
//
|
|
10
|
+
// THE EVIDENCE, and why it is evidence rather than a claim. A `Skill(...)` call whose name does not
|
|
11
|
+
// resolve fires NO hook at all: the host rejects the name upstream of the hook layer. Measured, in a
|
|
12
|
+
// live session with a sub-agent making the calls — not inferred. So a decision row from
|
|
13
|
+
// `dispatch-receipt` naming a skill is proof that the name resolved, and its absence after a call
|
|
14
|
+
// was attempted is proof that it did not. Nothing here trusts a sub-agent's report of what happened;
|
|
15
|
+
// the sub-agent cannot write these rows.
|
|
16
|
+
//
|
|
17
|
+
// The canary deliberately dispatches with NO `--order`. It is testing name resolution, not doing
|
|
18
|
+
// work: an order would mean a compiled order with no result sitting in `orders/`, which every reader
|
|
19
|
+
// of that directory would then have to know about, and a canary that perturbs the run it is
|
|
20
|
+
// clearing is not a preflight.
|
|
21
|
+
//
|
|
22
|
+
// Usage: node kernel/harness.mjs verify dispatch --skill <worker> [--since <iso>] [--cwd <dir>] [--json]
|
|
23
|
+
// Exit: 0 = this session dispatched that skill and the hook layer saw it · 1 = no such evidence
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
28
|
+
import { decisionsPath } from "../../hooks/lib/decision.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every decision row on disk, newest last. An unreadable or absent ledger is an empty list — an
|
|
32
|
+
* absence proves nothing on its own, and the caller decides what that means.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} cwd - Project root the ledger resolves against.
|
|
35
|
+
* @returns {object[]} Parsed rows; unparseable lines are dropped rather than throwing.
|
|
36
|
+
*/
|
|
37
|
+
export function decisionRows(cwd) {
|
|
38
|
+
const path = decisionsPath(cwd);
|
|
39
|
+
if (!existsSync(path)) return [];
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(path, "utf8").split("\n").filter((l) => l.trim())
|
|
42
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
43
|
+
} catch { return []; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find evidence that a skill resolved in this checkout.
|
|
48
|
+
*
|
|
49
|
+
* The host reports skills namespaced (`shapeup-sdlc-plugin:orient`) and orders name them bare
|
|
50
|
+
* (`orient`), so a row matches on either form — the caller should not have to know which spelling
|
|
51
|
+
* the hook happened to capture.
|
|
52
|
+
*
|
|
53
|
+
* @param {object[]} rows - Decision rows, e.g. from {@link decisionRows}.
|
|
54
|
+
* @param {string} skill - The worker name to look for, bare or namespaced.
|
|
55
|
+
* @param {string} [since] - ISO timestamp; rows older than this are ignored. Without it, evidence
|
|
56
|
+
* from any point in the checkout's history counts, which is why the canary always passes one.
|
|
57
|
+
* @returns {{resolved:boolean, rows:object[], considered:number}} The matching rows, newest last.
|
|
58
|
+
*/
|
|
59
|
+
export function dispatchEvidence(rows, skill, since = null) {
|
|
60
|
+
const bare = String(skill).slice(String(skill).lastIndexOf(":") + 1);
|
|
61
|
+
const floor = since ? Date.parse(since) : null;
|
|
62
|
+
const hits = rows.filter((r) => {
|
|
63
|
+
if (r.hook !== "dispatch-receipt" || !r.subject) return false;
|
|
64
|
+
const subj = String(r.subject);
|
|
65
|
+
const subjBare = subj.slice(subj.lastIndexOf(":") + 1).split("/")[0];
|
|
66
|
+
if (subjBare !== bare && subj !== skill) return false;
|
|
67
|
+
if (floor !== null && !Number.isNaN(floor) && !(Date.parse(r.at) >= floor)) return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
return { resolved: hits.length > 0, rows: hits, considered: rows.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
75
|
+
export const ARGV_SPEC = {
|
|
76
|
+
usage: "harness.mjs verify dispatch --skill <worker> [--since <iso> | --within <seconds>] [--cwd <dir>] [--json]",
|
|
77
|
+
_: { arity: 0, max: 0 },
|
|
78
|
+
skill: { type: "str", required: true },
|
|
79
|
+
since: { type: "str" },
|
|
80
|
+
// `--within` exists because the canary's caller cannot compute a timestamp. A Workflow script may
|
|
81
|
+
// not call `Date.now()` — it would break resume — so a control script asking "was this dispatched
|
|
82
|
+
// JUST NOW" has no clock of its own. Without a window the check answers "ever, in this checkout",
|
|
83
|
+
// which a project that once had the plugin loaded satisfies forever: the canary would pass on the
|
|
84
|
+
// strength of a run from last month. The kernel has a clock; it does the arithmetic.
|
|
85
|
+
within: { type: "int", min: 1 },
|
|
86
|
+
cwd: { type: "path" },
|
|
87
|
+
json: { type: "flag" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Report whether this session has been observed dispatching a named skill.
|
|
92
|
+
*
|
|
93
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
94
|
+
* @returns {void} Exits 0 when the evidence exists, 1 when it does not.
|
|
95
|
+
*/
|
|
96
|
+
export function cli(rawArgv) {
|
|
97
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
98
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
99
|
+
const since = args.since || (args.within ? new Date(Date.now() - args.within * 1000).toISOString() : null);
|
|
100
|
+
const ev = dispatchEvidence(decisionRows(cwd), args.skill, since);
|
|
101
|
+
if (args.json) {
|
|
102
|
+
console.log(JSON.stringify({ skill: args.skill, since, ...ev }, null, 2));
|
|
103
|
+
} else if (ev.resolved) {
|
|
104
|
+
const last = ev.rows[ev.rows.length - 1];
|
|
105
|
+
console.log(`✅ ${args.skill} resolved in this session — dispatch observed at ${last.at} (${ev.rows.length} row(s))`);
|
|
106
|
+
}
|
|
107
|
+
if (ev.resolved) process.exit(0);
|
|
108
|
+
console.error(` ✗ verify dispatch: no evidence this session resolved "${args.skill}".`);
|
|
109
|
+
console.error(` A Skill call whose name does not resolve fires no hook at all, so the absence of a`);
|
|
110
|
+
console.error(` decision row after an attempted dispatch means the skill was never reached. Either the`);
|
|
111
|
+
console.error(` plugin is not loaded in this session (\`claude --plugin-dir <repo>\`, or install and`);
|
|
112
|
+
console.error(` enable it), or the dispatch was never attempted. ${ev.considered} decision row(s) scanned${since ? ` since ${since}` : ""}.`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Envelope schema gate (pure-skill architecture v1.0, plan P0).
|
|
3
3
|
//
|
|
4
4
|
// The lesson already in the repo: structured artifacts + deterministic tooling beat prose
|
|
5
|
-
// conventions (
|
|
5
|
+
// conventions (harness verify t0 is the most reliable component in the harness). This script makes
|
|
6
6
|
// the WorkOrder/WorkResult ports mechanically checkable: a malformed order never reaches a
|
|
7
7
|
// worker, a malformed result never reaches ingest.
|
|
8
8
|
//
|
|
9
|
-
// Zero dependencies, zero network — same discipline as the oracles and
|
|
9
|
+
// Zero dependencies, zero network — same discipline as the oracles and the GATE L2 block. Implements
|
|
10
10
|
// the JSON-Schema subset the shipped schemas use (type, required, properties, items, enum,
|
|
11
11
|
// pattern, $ref) rather than pulling in a validator dependency. $ref supports two forms:
|
|
12
12
|
// #/$defs/Name — a definition in the SAME schema document
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// registry; resolved against the schema's own dir,
|
|
15
15
|
// falling back to skills/tech-lead/schemas/)
|
|
16
16
|
//
|
|
17
|
-
// Usage (CLI): node
|
|
17
|
+
// Usage (CLI): node kernel/harness.mjs verify envelope <envelope.json> <schema.json>
|
|
18
18
|
// exit 0 = valid, 1 = invalid (errors printed one per line)
|
|
19
19
|
// Usage (hook): PreToolUse on Skill|Agent — when the tool input carries `--order <path>`,
|
|
20
20
|
// the order file is validated against schemas/work-order.schema.json; an
|
|
@@ -24,12 +24,11 @@
|
|
|
24
24
|
import { readFileSync, existsSync } from "node:fs";
|
|
25
25
|
import { resolve, join, dirname } from "node:path";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { runHook, readStdin, settle } from "../../../hooks/lib/decision.mjs";
|
|
27
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
28
|
+
import { runHook, readStdin, settle } from "../../hooks/lib/decision.mjs";
|
|
30
29
|
|
|
31
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
-
export const SCHEMAS_DIR = resolve(HERE, "
|
|
31
|
+
export const SCHEMAS_DIR = resolve(HERE, "../../skills/tech-lead/schemas");
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
34
|
* Validate a value against the JSON-Schema subset the envelope schemas use (type, required,
|
|
@@ -201,15 +200,21 @@ export function validateFile(envelopePath, schemaPath) {
|
|
|
201
200
|
* the envelope arrives on stdin, so nothing here is consulted.
|
|
202
201
|
*/
|
|
203
202
|
export const ARGV_SPEC = {
|
|
204
|
-
usage: "
|
|
203
|
+
usage: "harness.mjs verify envelope <envelope.json> <schema.json> (no args → PreToolUse hook mode)",
|
|
205
204
|
_: { arity: 2, max: 2, name: "<envelope.json> <schema.json>" },
|
|
206
205
|
};
|
|
207
206
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Validate an envelope against its schema, or run as the PreToolUse gate when given no args.
|
|
209
|
+
*
|
|
210
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
211
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
212
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
213
|
+
*/
|
|
214
|
+
export async function cli(rawArgv) {
|
|
215
|
+
if (rawArgv.length) {
|
|
211
216
|
// CLI mode
|
|
212
|
-
const [envelopePath, schemaPath] = runArgs(ARGV_SPEC)._;
|
|
217
|
+
const [envelopePath, schemaPath] = runArgs(ARGV_SPEC, rawArgv)._;
|
|
213
218
|
try {
|
|
214
219
|
const { valid, errors } = validateFile(resolve(envelopePath), resolve(schemaPath));
|
|
215
220
|
if (valid) {
|
|
@@ -223,7 +228,7 @@ if (isMainModule) {
|
|
|
223
228
|
process.exit(1);
|
|
224
229
|
}
|
|
225
230
|
} else {
|
|
226
|
-
// Hook mode (PreToolUse). Deny contract identical to
|
|
231
|
+
// Hook mode (PreToolUse). Deny contract identical to the GATE L2 block, and — since v1.5 — the same
|
|
227
232
|
// receipt: `allow` carries evidence, so "validated the order and permitted it" is no longer
|
|
228
233
|
// byte-identical to "this hook never ran" (hooks/lib/decision.mjs).
|
|
229
234
|
await runHook("validate-envelope", async () => {
|
|
@@ -266,12 +271,12 @@ if (isMainModule) {
|
|
|
266
271
|
},
|
|
267
272
|
});
|
|
268
273
|
if (!existsSync(orderPath)) {
|
|
269
|
-
deny(`WorkOrder gate — order file not found: ${orderPath}. Compile it first (compile
|
|
274
|
+
deny(`WorkOrder gate — order file not found: ${orderPath}. Compile it first (harness compile) — a worker must never be dispatched against a dangling order.`, "order-missing");
|
|
270
275
|
}
|
|
271
276
|
try {
|
|
272
277
|
const { valid, errors } = validateFile(orderPath, join(SCHEMAS_DIR, "work-order.schema.json"));
|
|
273
278
|
if (!valid) {
|
|
274
|
-
deny(`WorkOrder gate — ${orderPath} fails schema validation: ${errors.slice(0, 5).join("; ")}. A malformed order never reaches a worker; fix the order (or compile
|
|
279
|
+
deny(`WorkOrder gate — ${orderPath} fails schema validation: ${errors.slice(0, 5).join("; ")}. A malformed order never reaches a worker; fix the order (or harness compile) and re-dispatch.`, "schema-invalid");
|
|
275
280
|
}
|
|
276
281
|
} catch (e) {
|
|
277
282
|
if (e?.name === "HookDecision") throw e;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// from code it did not write, cannot see the history of, and is told about only through error
|
|
12
12
|
// strings. The paper's `revert(commit)` is the MORE COMMON branch, and it was the absent one.
|
|
13
13
|
//
|
|
14
|
-
// Under a ratchet there is one rule for both (see
|
|
14
|
+
// Under a ratchet there is one rule for both (see ``harness verify t0`` → `decideStatus`): keep the tree
|
|
15
15
|
// when the score strictly improved, restore the last kept tree when it did not.
|
|
16
16
|
//
|
|
17
17
|
// NO COMMITS ON THE BRANCH UNDER TEST. The harness's standing convention is that it never writes
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// SHADOW REF — `refs/shapeup/<scope_id>/kept` — which is invisible to `git log`, `git status` and
|
|
20
20
|
// every branch operation, and is reachable for `git restore` and for forensics afterwards.
|
|
21
21
|
//
|
|
22
|
-
// NON-REGRESSION
|
|
22
|
+
// NON-REGRESSION. Every operation here is best-effort: outside a git work tree, with no
|
|
23
23
|
// `git` on PATH, or with no snapshot yet taken, the functions report `{ ok: false, reason }` and
|
|
24
24
|
// the caller proceeds exactly as it does today. A ratchet that can break a build by failing to
|
|
25
25
|
// take a snapshot would get the whole mechanism disabled, which costs more than it saves.
|
|
@@ -62,11 +62,16 @@ export function isRepo(cwd) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* `keep` — publish the current working tree
|
|
65
|
+
* `keep` — publish the current TRACKED working tree as this scope's kept tree.
|
|
66
66
|
*
|
|
67
67
|
* `git stash create` builds the stash commit WITHOUT touching the index, the working tree, or the
|
|
68
68
|
* stash list; `update-ref` then points the shadow ref at it. Nothing the user can see changes.
|
|
69
69
|
*
|
|
70
|
+
* TRACKED ONLY, and the limit is real rather than a nicety: `stash create` takes no `-u`, so a file
|
|
71
|
+
* the attempt CREATED is not in the snapshot. {@link restore} therefore cannot remove it — which is
|
|
72
|
+
* the behaviour that function already documents, stated here too so the pair reads honestly from
|
|
73
|
+
* either end.
|
|
74
|
+
*
|
|
70
75
|
* @param {string} scopeId - Scope id the snapshot belongs to.
|
|
71
76
|
* @param {string} cwd - Repository working directory.
|
|
72
77
|
* @returns {{ok:boolean, ref?:string, sha?:string, reason?:string}} `ok:true` with the ref and the
|
|
@@ -88,25 +93,42 @@ export function snapshot(scopeId, cwd) {
|
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/**
|
|
91
|
-
* `revert` — restore
|
|
96
|
+
* `revert` — restore THIS SCOPE'S OWN FILES from its last kept snapshot.
|
|
97
|
+
*
|
|
98
|
+
* `git restore --source=<ref> --worktree -- <pathspec>`: it rewrites tracked files back to the kept
|
|
99
|
+
* state and leaves the index and HEAD alone. It does NOT delete files created since the snapshot —
|
|
100
|
+
* removing a file the harness cannot prove it created is not a revert, it is data loss, and the
|
|
101
|
+
* attempt's own fixtures are what decide whether the leftover matters.
|
|
102
|
+
*
|
|
103
|
+
* THE PATHSPEC IS THE WHOLE POINT, and it used to be `.`. A scope's snapshot is a stash of the
|
|
104
|
+
* ENTIRE tree, so a repo-wide revert rolled every other scope back to whatever state it happened to
|
|
105
|
+
* be in when THIS scope last went green. With scopes building side by side that is silent
|
|
106
|
+
* cross-scope data destruction, and no other control can see it: `sandbox-guard` fences the Edit and
|
|
107
|
+
* Write tools, and this is a `git` subprocess. Measured directly — one scope's red attempt reverted
|
|
108
|
+
* a neighbour's committed file back to the baseline while the neighbour was still working in it.
|
|
92
109
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* and
|
|
110
|
+
* Substrate globs are usable as git pathspecs unchanged (`src/parse/**`, `apps/web/cart/*.tsx`).
|
|
111
|
+
* `allowed` only, never `shared`: a shared path is by definition one another scope may also be
|
|
112
|
+
* writing, so reverting it is the very thing this bound exists to prevent. An empty pathspec
|
|
113
|
+
* restores NOTHING and says so, rather than falling back to a repo-wide revert — a scope that
|
|
114
|
+
* declares no writable surface has nothing of its own to roll back.
|
|
97
115
|
*
|
|
98
116
|
* @param {string} scopeId - Scope id whose kept tree should be restored.
|
|
99
117
|
* @param {string} cwd - Repository working directory.
|
|
100
|
-
* @
|
|
101
|
-
*
|
|
102
|
-
*
|
|
118
|
+
* @param {string[]} [pathspec=[]] - The scope's `substrate.allowed` globs; the bound on what may be
|
|
119
|
+
* rewritten.
|
|
120
|
+
* @returns {{ok:boolean, ref?:string, sha?:string, paths?:number, reason?:string}} `ok:true` when
|
|
121
|
+
* the tree was restored; `ok:false` with a reason when there is no snapshot yet (the first trial,
|
|
122
|
+
* by definition), no pathspec to bound the revert, or git refused.
|
|
103
123
|
*/
|
|
104
|
-
export function restore(scopeId, cwd) {
|
|
124
|
+
export function restore(scopeId, cwd, pathspec = []) {
|
|
105
125
|
if (!isRepo(cwd)) return { ok: false, reason: "not a git work tree" };
|
|
126
|
+
const paths = (pathspec || []).filter((p) => typeof p === "string" && p.trim());
|
|
127
|
+
if (!paths.length) return { ok: false, reason: `no substrate pathspec for ${scopeId} — refusing a repo-wide revert` };
|
|
106
128
|
const ref = keptRef(scopeId);
|
|
107
129
|
const resolved = git(["rev-parse", "--verify", "--quiet", ref], cwd);
|
|
108
130
|
if (!resolved.ok || !resolved.stdout) return { ok: false, reason: `no snapshot at ${ref}` };
|
|
109
|
-
const restored = git(["restore", `--source=${resolved.stdout}`, "--worktree", "--",
|
|
131
|
+
const restored = git(["restore", `--source=${resolved.stdout}`, "--worktree", "--", ...paths], cwd);
|
|
110
132
|
if (!restored.ok) return { ok: false, reason: restored.stderr || "git restore failed" };
|
|
111
|
-
return { ok: true, ref, sha: resolved.stdout };
|
|
133
|
+
return { ok: true, ref, sha: resolved.stdout, paths: paths.length };
|
|
112
134
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Worker-roster preflight — refuse a run whose workers are not on disk, before any spend.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT CATCHES. A dispatch against a plugin that is absent, disabled or a different version
|
|
5
|
+
// returns `<tool_use_error>Unknown skill</tool_use_error>`, and the sub-agent then does the craft
|
|
6
|
+
// itself from the prose already in its own prompt. `hooks/dispatch-receipt.mjs` makes that visible
|
|
7
|
+
// at ingest; this makes it visible at `init run`, before a single sub-agent has been paid for.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT HONESTLY CANNOT DO, stated here because a check that overclaims is worse than no check.
|
|
10
|
+
// It proves *these files exist at this root at this version*. It cannot prove the SESSION will
|
|
11
|
+
// resolve that copy — that is a property of how the CLI was launched (`--plugin-dir`, an enabled
|
|
12
|
+
// marketplace install, the right version among several), and only a live dispatch answers it. The
|
|
13
|
+
// orchestrator's first leg is a canary dispatch whose receipt is required for exactly that reason;
|
|
14
|
+
// this check and that canary are two halves, not two attempts at the same half.
|
|
15
|
+
//
|
|
16
|
+
// THE ROSTER IS DERIVED, NEVER SPELLED. It comes from `domain.schema.json#/$defs/WorkerName` — the
|
|
17
|
+
// same enum that decides which workers a WorkOrder may be addressed to. A hand-written list beside
|
|
18
|
+
// a schema enum is the drift this project exists to prevent: eight names beside a ten-member enum
|
|
19
|
+
// reads perfectly and is wrong, and the two it would omit (`translator`, `coach`) are exactly the
|
|
20
|
+
// ones a short run never reaches, so the omission would surface months later on the one run that did.
|
|
21
|
+
//
|
|
22
|
+
// THE ROOT IS DERIVED FROM THIS MODULE'S OWN LOCATION, not from `CLAUDE_PLUGIN_ROOT`: the env var is
|
|
23
|
+
// not reliably exported into a sub-agent's shell, and the kernel that is executing is already inside
|
|
24
|
+
// the plugin root. `--plugin-root <dir>` exists for fixtures, which must be able to describe a
|
|
25
|
+
// broken installation without breaking the installation under test.
|
|
26
|
+
//
|
|
27
|
+
// Usage: node kernel/harness.mjs verify skills [--plugin-root <dir>] [--json] [--quiet]
|
|
28
|
+
// Exit: 0 = every worker resolved · 1 = one or more missing (named on stderr)
|
|
29
|
+
|
|
30
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
31
|
+
import { resolve, join, dirname } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
/** The plugin root, resolved from where this file actually is (`kernel/verify/` → repo root). */
|
|
37
|
+
export const PLUGIN_ROOT = resolve(HERE, "../..");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The complete worker roster, read from the schema that defines it.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} [root=PLUGIN_ROOT] - Plugin root the schema is read from.
|
|
43
|
+
* @returns {string[]} Every member of `WorkerName`, in schema order.
|
|
44
|
+
* @throws {Error} When the schema is missing or does not carry the enum — a kernel that cannot find
|
|
45
|
+
* its own domain registry has a broken installation, which is the very thing being checked.
|
|
46
|
+
*/
|
|
47
|
+
export function roster(root = PLUGIN_ROOT) {
|
|
48
|
+
const schemaPath = join(root, "skills/tech-lead/schemas/domain.schema.json");
|
|
49
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
50
|
+
const names = schema?.$defs?.WorkerName?.enum;
|
|
51
|
+
if (!Array.isArray(names) || !names.length) {
|
|
52
|
+
throw new Error(`${schemaPath} carries no $defs/WorkerName enum — the roster cannot be derived`);
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve every worker in the roster against a plugin root.
|
|
59
|
+
*
|
|
60
|
+
* A worker is present when its `SKILL.md` is readable. That is the file the host loads to answer a
|
|
61
|
+
* `Skill(...)` call, so its absence is the closest on-disk fact to "this dispatch will fail".
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [root=PLUGIN_ROOT] - Plugin root to check.
|
|
64
|
+
* @returns {{root:string, version:(string|null), name:(string|null), workers:Array<{worker:string,
|
|
65
|
+
* path:string, present:boolean}>, missing:string[]}} The full picture, including the version, so a
|
|
66
|
+
* wrong-version run is legible in the trace instead of silently green.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveWorkers(root = PLUGIN_ROOT) {
|
|
69
|
+
let version = null, name = null;
|
|
70
|
+
try {
|
|
71
|
+
const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin/plugin.json"), "utf8"));
|
|
72
|
+
version = manifest.version ?? null;
|
|
73
|
+
name = manifest.name ?? null;
|
|
74
|
+
} catch { /* an unreadable manifest is reported as null, never as a failure of its own */ }
|
|
75
|
+
const workers = roster(root).map((worker) => {
|
|
76
|
+
const path = join(root, "skills", worker, "SKILL.md");
|
|
77
|
+
return { worker, path, present: existsSync(path) };
|
|
78
|
+
});
|
|
79
|
+
return { root, version, name, workers, missing: workers.filter((w) => !w.present).map((w) => w.worker) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
84
|
+
export const ARGV_SPEC = {
|
|
85
|
+
usage: "harness.mjs verify skills [--plugin-root <dir>] [--json] [--quiet]",
|
|
86
|
+
_: { arity: 0, max: 0 },
|
|
87
|
+
"plugin-root": { type: "path" },
|
|
88
|
+
json: { type: "flag" },
|
|
89
|
+
quiet: { type: "flag" },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check the worker roster resolves, and say which copy of the plugin answered.
|
|
94
|
+
*
|
|
95
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
96
|
+
* @returns {void} Exits 0 when every worker resolved, 1 when any is missing.
|
|
97
|
+
*/
|
|
98
|
+
export function cli(rawArgv) {
|
|
99
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
100
|
+
let report;
|
|
101
|
+
try {
|
|
102
|
+
report = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : PLUGIN_ROOT);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(` ✗ verify skills: ${e.message}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
if (args.json) {
|
|
108
|
+
console.log(JSON.stringify(report, null, 2));
|
|
109
|
+
} else if (!args.quiet) {
|
|
110
|
+
// The root and the version are printed on the SUCCESS path too. A run against the wrong copy
|
|
111
|
+
// of the plugin is green by every other measure; printing which copy answered is the only way
|
|
112
|
+
// that shows up in a trace someone reads later.
|
|
113
|
+
console.log(`plugin: ${report.name ?? "unknown"} ${report.version ?? "unknown version"}`);
|
|
114
|
+
console.log(`root: ${report.root}`);
|
|
115
|
+
console.log(`workers: ${report.workers.length - report.missing.length}/${report.workers.length} resolved`);
|
|
116
|
+
}
|
|
117
|
+
if (report.missing.length) {
|
|
118
|
+
console.error(` ✗ verify skills: ${report.missing.length} worker skill(s) missing under ${report.root}:`);
|
|
119
|
+
for (const w of report.workers.filter((x) => !x.present)) console.error(` ${w.worker} — no SKILL.md at ${w.path}`);
|
|
120
|
+
console.error(` This plugin copy cannot run the pipeline. Load the working copy with`);
|
|
121
|
+
console.error(` \`claude --plugin-dir <repo>\`, or install/enable the plugin, then retry.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|