shapeup-sdlc 1.6.2
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 +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.shapeup.example +14 -0
- package/AGENTS.md +133 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +72 -0
- package/bin/init.mjs +329 -0
- package/commands/build.md +14 -0
- package/commands/eval.md +15 -0
- package/commands/hammer.md +12 -0
- package/commands/orient.md +11 -0
- package/commands/qa.md +12 -0
- package/commands/retro.md +13 -0
- package/commands/scopes.md +14 -0
- package/commands/shape.md +12 -0
- package/commands/ship.md +53 -0
- package/commands/wire.md +11 -0
- package/hooks/anti-rationalization.mjs +244 -0
- package/hooks/compact-snapshot.mjs +47 -0
- package/hooks/gate-deadline.mjs +151 -0
- package/hooks/gate-intake.mjs +110 -0
- package/hooks/gate-l2.mjs +161 -0
- package/hooks/gate-zerowork.mjs +264 -0
- package/hooks/hooks.json +118 -0
- package/hooks/lib/decision.mjs +183 -0
- package/hooks/safety-spine.mjs +296 -0
- package/hooks/sandbox-guard.mjs +172 -0
- package/hooks/session-rehydrate.mjs +109 -0
- package/hooks/slop-cleaner.mjs +176 -0
- package/oracles/_shared.mjs +46 -0
- package/oracles/http-oracle.mjs +155 -0
- package/oracles/index.mjs +36 -0
- package/oracles/process-oracle.mjs +146 -0
- package/oracles/snapshot-oracle.mjs +119 -0
- package/oracles/test-oracle.mjs +138 -0
- package/package.json +49 -0
- package/skills/advisor-protocol/SKILL.md +171 -0
- package/skills/ba-pitch-analyzer/SKILL.md +175 -0
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
- package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
- package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
- package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
- package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
- package/skills/coach/SKILL.md +202 -0
- package/skills/orient/SKILL.md +262 -0
- package/skills/qa-edge-hunter/SKILL.md +369 -0
- package/skills/scope-architect/SKILL.md +111 -0
- package/skills/scope-hammer/SKILL.md +186 -0
- package/skills/shapeup/SKILL.md +403 -0
- package/skills/shapeup/resources/breadboard-reflection.md +88 -0
- package/skills/shapeup/resources/breadboarding.md +334 -0
- package/skills/shapeup/resources/context-compaction.md +240 -0
- package/skills/shapeup/resources/framing-doc.md +92 -0
- package/skills/shapeup/resources/kickoff-doc.md +95 -0
- package/skills/shapeup/resources/shaping.md +194 -0
- package/skills/shapeup/resources/spike.md +86 -0
- package/skills/solution-architect/SKILL.md +129 -0
- package/skills/spec-evaluator/README.md +93 -0
- package/skills/spec-evaluator/SKILL.md +213 -0
- package/skills/spec-evaluator/references/anti-leniency.md +50 -0
- package/skills/spec-evaluator/references/dimension-contract.md +126 -0
- package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
- package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
- package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
- package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
- package/skills/spec-evaluator/references/dimensions/security.md +60 -0
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
- package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
- package/skills/spec-evaluator/references/probing.md +177 -0
- package/skills/spec-evaluator/references/report-schema.md +101 -0
- package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
- package/skills/task-executor/SKILL.md +194 -0
- package/skills/tech-lead/README.md +71 -0
- package/skills/tech-lead/SKILL.md +459 -0
- package/skills/tech-lead/references/delegation.md +254 -0
- package/skills/tech-lead/references/gates.md +379 -0
- package/skills/tech-lead/references/invocation.md +45 -0
- package/skills/tech-lead/references/ledger-schema.md +214 -0
- package/skills/tech-lead/references/round-protocol.md +184 -0
- package/skills/tech-lead/references/state-model.md +66 -0
- package/skills/tech-lead/references/tiny-lane.md +52 -0
- package/skills/tech-lead/schemas/domain.schema.json +2294 -0
- package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
- package/skills/tech-lead/schemas/work-order.schema.json +21 -0
- package/skills/tech-lead/schemas/work-result.schema.json +40 -0
- package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
- package/skills/tech-lead/scripts/budget-check.mjs +156 -0
- package/skills/tech-lead/scripts/compile-order.mjs +518 -0
- package/skills/tech-lead/scripts/fit-check.mjs +196 -0
- package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
- package/skills/tech-lead/scripts/init-run.mjs +326 -0
- package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
- package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
- package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
- package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
- package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
- package/skills/tech-lead/scripts/ship-report.mjs +275 -0
- package/skills/tech-lead/scripts/stats.mjs +423 -0
- package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
- package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
- package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
- package/skills/translator/README.md +66 -0
- package/skills/translator/SKILL.md +258 -0
- package/skills/translator/references/preservation-rules.md +102 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Traceability oracle (spine v1.3, plan docs/internal/plan/ratchet-and-receipt-plan.md §1.4 + §2).
|
|
3
|
+
//
|
|
4
|
+
// ONE oracle, TWO mechanically-checkable assertions — nothing that merely *asserts* quality:
|
|
5
|
+
//
|
|
6
|
+
// 1. Covers-closure (§1) — every SHARED requirement with status `covered` must be named by
|
|
7
|
+
// ≥1 acceptance criterion's `(covers: REQ-…)` clause. A REQ that is neither covered nor
|
|
8
|
+
// CUT (PO-approved) is RED. This catches the *dropped clause* (a customer requirement that
|
|
9
|
+
// silently vanished in translation), not a *contradiction* (§1 honest boundary → §4.2).
|
|
10
|
+
//
|
|
11
|
+
// 2. Reachability (§2) — a use-case whose engine module does not reach the project profile's
|
|
12
|
+
// `entry_point` via the import graph (0 import sites) is RED. This catches the *dead module*
|
|
13
|
+
// (631 lines, 26 passing tests, zero call sites), not a *dead data-path* (§2 honest boundary
|
|
14
|
+
// → §4.4). Entry point is PROFILE-GATED, never hardcoded (main.js for a game is not the seam
|
|
15
|
+
// for a web-service).
|
|
16
|
+
//
|
|
17
|
+
// Governing rule: if a script can't check it, it's decoration. This script checks a deletion and
|
|
18
|
+
// an orphan — both provable from files, zero LLM tokens. What it deliberately does NOT assert:
|
|
19
|
+
// it does not count or grade tests (§4.1 — a green test that asserts nothing real would satisfy
|
|
20
|
+
// a "≥1 test" arm, so that arm was cut).
|
|
21
|
+
//
|
|
22
|
+
// Staged severity (§1.4 / §6): ADVISORY (warn-only, exit 0) by default — it goes ~100% red on a
|
|
23
|
+
// board with no covers: yet, and that's the intended demonstration, not a gate. Promote to a
|
|
24
|
+
// blocking gate with --gate only once covers: is populated, or it breaks every legacy run.
|
|
25
|
+
//
|
|
26
|
+
// Reads SHARED shapeup/<slug>/requirements.md (RequirementClause registry)
|
|
27
|
+
// SHARED shapeup/<slug>/wiring-map.md (WiringMap — optional)
|
|
28
|
+
// SHARED shapeup/<slug>/project-profile.md (ProjectProfile — optional)
|
|
29
|
+
// LOCAL .shapeup/<slug>/tasks/TASK-*.md (board AC covers[])
|
|
30
|
+
// Writes LOCAL .shapeup/<slug>/trace/report.json (regenerated each run)
|
|
31
|
+
// LOCAL .shapeup/<slug>/trace/wiring.mmd (Mermaid view of the checked graph)
|
|
32
|
+
//
|
|
33
|
+
// Zero dependencies, zero network — same discipline as t0-verify.mjs / compile-order.mjs.
|
|
34
|
+
//
|
|
35
|
+
// Usage: node skills/tech-lead/scripts/trace-lint.mjs --slug <slug> [--cwd <dir>] [--gate] [--quiet]
|
|
36
|
+
// Exit: advisory (default) → always 0. --gate → 1 when overall is red.
|
|
37
|
+
|
|
38
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
39
|
+
import { resolve, join, dirname, relative, isAbsolute } from "node:path";
|
|
40
|
+
import { readBoard } from "./compile-order.mjs";
|
|
41
|
+
import { isMain } from "./lib/is-main.mjs";
|
|
42
|
+
import { runArgs } from "./lib/argv.mjs";
|
|
43
|
+
import { sharedRoot, traceDir, relLocal } from "./lib/paths.mjs";
|
|
44
|
+
import { readContract, unreadableReason, WIRING_MAP, PROJECT_PROFILE } from "./lib/contract-md.mjs";
|
|
45
|
+
|
|
46
|
+
// --- requirements.md registry parser -----------------------------------------
|
|
47
|
+
// A committed markdown table: | REQ-id | clause (verbatim) | source | status | note |
|
|
48
|
+
// The first two columns are load-bearing; source/note are optional. Status is normalized so
|
|
49
|
+
// "CUT (PO-approved)", "cut", "CUT" all read as cut and anything else non-covered stays covered.
|
|
50
|
+
/**
|
|
51
|
+
* Parse the committed `requirements.md` table into RequirementClause rows.
|
|
52
|
+
* @param {string} md - The registry Markdown ("" / null → []). Table columns:
|
|
53
|
+
* | REQ-id | clause | source | status | note |.
|
|
54
|
+
* @returns {Array<{id:string, clause:string, source:string, status:("covered"|"CUT (PO-approved)"),
|
|
55
|
+
* note:string}>} One row per `REQ-\d+`; any status containing "cut" normalizes to CUT, all else
|
|
56
|
+
* to covered. Header/separator/prose rows are skipped.
|
|
57
|
+
*/
|
|
58
|
+
export function parseRequirements(md) {
|
|
59
|
+
const clauses = [];
|
|
60
|
+
if (!md) return clauses;
|
|
61
|
+
for (const raw of md.split(/\r?\n/)) {
|
|
62
|
+
const line = raw.trim();
|
|
63
|
+
if (!line.startsWith("|")) continue;
|
|
64
|
+
const cells = line.split("|").slice(1, -1).map((c) => c.trim()); // drop the leading/trailing empties
|
|
65
|
+
if (!cells.length) continue;
|
|
66
|
+
const id = (cells[0].match(/REQ-\d+/) || [])[0];
|
|
67
|
+
if (!id) continue; // header row ("REQ-id"), separator row ("---"), or prose — skip
|
|
68
|
+
const statusRaw = (cells[3] || cells[cells.length - 1] || "").toLowerCase();
|
|
69
|
+
const status = /\bcut\b/.test(statusRaw) ? "CUT (PO-approved)" : "covered";
|
|
70
|
+
clauses.push({
|
|
71
|
+
id,
|
|
72
|
+
clause: cells[1] || "",
|
|
73
|
+
source: cells[2] || "",
|
|
74
|
+
status,
|
|
75
|
+
note: cells[4] || "",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return clauses;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Collect every REQ-id named by a `covers:` clause across the board's acceptance criteria.
|
|
83
|
+
* @param {Array<object>} board - Task entries (see compile-order's parseTaskFile).
|
|
84
|
+
* @returns {Set<string>} The set of `REQ-\d+` ids referenced by any AC's covers[].
|
|
85
|
+
*/
|
|
86
|
+
export function coveredReqIds(board) {
|
|
87
|
+
const covered = new Set();
|
|
88
|
+
for (const task of board) {
|
|
89
|
+
for (const ac of task.acceptance_criteria || []) {
|
|
90
|
+
const covers = typeof ac === "object" && Array.isArray(ac.covers) ? ac.covers : [];
|
|
91
|
+
for (const id of covers) if (/^REQ-\d+$/.test(id)) covered.add(id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return covered;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- import-graph reachability -----------------------------------------------
|
|
98
|
+
const SOURCE_EXTS = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"];
|
|
99
|
+
const IMPORT_RE = /(?:\bimport\b[^'"]*?from\s*|\bimport\s*|\bexport\b[^'"]*?from\s*|\brequire\s*\(\s*|\bimport\s*\()\s*['"]([^'"]+)['"]/g;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a relative import specifier to a repo-relative source file.
|
|
103
|
+
* @param {string} fromFileAbs - Absolute path of the importing file.
|
|
104
|
+
* @param {string} spec - The import specifier string.
|
|
105
|
+
* @param {string} cwd - Repo root the result is made relative to.
|
|
106
|
+
* @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
|
|
107
|
+
* or null for a bare specifier (node_modules) or an unresolved path.
|
|
108
|
+
*/
|
|
109
|
+
function resolveSpecifier(fromFileAbs, spec, cwd) {
|
|
110
|
+
if (!spec.startsWith(".")) return null; // bare specifier → node_modules, out of the app graph
|
|
111
|
+
const baseAbs = resolve(dirname(fromFileAbs), spec);
|
|
112
|
+
const candidates = [baseAbs, ...SOURCE_EXTS.map((e) => baseAbs + e), ...SOURCE_EXTS.map((e) => join(baseAbs, "index" + e))];
|
|
113
|
+
for (const c of candidates) {
|
|
114
|
+
if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize a declared path (entry_point / engine) to an existing repo-relative source file.
|
|
121
|
+
* @param {string} p - The declared path (absolute or cwd-relative).
|
|
122
|
+
* @param {string} cwd - Repo root the result is made relative to.
|
|
123
|
+
* @returns {(string|null)} The repo-relative source path (trying source extensions and `/index`),
|
|
124
|
+
* or null when nothing on disk matches.
|
|
125
|
+
*/
|
|
126
|
+
function resolveFile(p, cwd) {
|
|
127
|
+
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
128
|
+
const candidates = [abs, ...SOURCE_EXTS.map((e) => abs + e), ...SOURCE_EXTS.map((e) => join(abs, "index" + e))];
|
|
129
|
+
for (const c of candidates) {
|
|
130
|
+
if (existsSync(c) && statSync(c).isFile()) return relative(cwd, c).split("\\").join("/");
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract every import/require/dynamic-import specifier string from a file.
|
|
137
|
+
* @param {string} fileAbs - Absolute path of the source file.
|
|
138
|
+
* @returns {string[]} The raw specifier strings; [] when the file is unreadable.
|
|
139
|
+
*/
|
|
140
|
+
function importsOf(fileAbs) {
|
|
141
|
+
let src;
|
|
142
|
+
try { src = readFileSync(fileAbs, "utf8"); } catch { return []; }
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const m of src.matchAll(IMPORT_RE)) out.push(m[1]);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* BFS the import graph from an entry point.
|
|
150
|
+
* @param {string} entryRel - The entry-point path (declared form; resolved on disk).
|
|
151
|
+
* @param {string} cwd - Repo root.
|
|
152
|
+
* @returns {{reachable:Set<string>, entryResolved:(string|null)}} The set of repo-relative files
|
|
153
|
+
* reachable from the entry, and the resolved entry path (null when the entry is not on disk, in
|
|
154
|
+
* which case `reachable` is empty).
|
|
155
|
+
*/
|
|
156
|
+
export function reachableFrom(entryRel, cwd) {
|
|
157
|
+
const reachable = new Set();
|
|
158
|
+
const start = resolveFile(entryRel, cwd);
|
|
159
|
+
if (!start) return { reachable, entryResolved: null };
|
|
160
|
+
const queue = [start];
|
|
161
|
+
reachable.add(start);
|
|
162
|
+
while (queue.length) {
|
|
163
|
+
const cur = queue.shift();
|
|
164
|
+
const curAbs = resolve(cwd, cur);
|
|
165
|
+
for (const spec of importsOf(curAbs)) {
|
|
166
|
+
const dep = resolveSpecifier(curAbs, spec, cwd);
|
|
167
|
+
if (dep && !reachable.has(dep)) { reachable.add(dep); queue.push(dep); }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { reachable, entryResolved: start };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Mermaid view (a view of the checked graph, so it cannot drift — §2) ------
|
|
174
|
+
/**
|
|
175
|
+
* Render a Mermaid flowchart of the wiring map, marking orphaned engines (a view of the checked
|
|
176
|
+
* graph, so it cannot drift from the reachability result).
|
|
177
|
+
* @param {(object|null)} wiringMap - The WiringMap ({entries:[{use_case,engine,affordance}]}).
|
|
178
|
+
* @param {Set<string>} unreachableSet - Use-case ids found unreachable (rendered as dashed/dead).
|
|
179
|
+
* @returns {string} The Mermaid `flowchart LR` source.
|
|
180
|
+
*/
|
|
181
|
+
export function wiringMermaid(wiringMap, unreachableSet) {
|
|
182
|
+
const lines = ["flowchart LR", " entry([entry point])"];
|
|
183
|
+
/**
|
|
184
|
+
* Make a Mermaid-safe node id from an arbitrary string.
|
|
185
|
+
* @param {*} s - Any value (engine path / use-case id); coerced to string.
|
|
186
|
+
* @returns {string} A `n_`-prefixed id with every non-alphanumeric char replaced by `_`.
|
|
187
|
+
*/
|
|
188
|
+
const id = (s) => "n_" + String(s).replace(/[^A-Za-z0-9]/g, "_");
|
|
189
|
+
for (const e of wiringMap?.entries || []) {
|
|
190
|
+
const dead = unreachableSet.has(e.use_case);
|
|
191
|
+
const eng = id(e.engine);
|
|
192
|
+
lines.push(` ${eng}["${e.engine}"]${dead ? ":::dead" : ""}`);
|
|
193
|
+
lines.push(` entry ${dead ? "-.->|orphan| " : "--> "}${eng}`);
|
|
194
|
+
if (e.affordance) lines.push(` ${eng} --> ${id(e.use_case + "_aff")}(["${e.affordance}"])`);
|
|
195
|
+
}
|
|
196
|
+
lines.push(" classDef dead stroke:#d33,stroke-width:2px,color:#d33;");
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- the oracle --------------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Run the covers-closure + reachability oracle for a slug.
|
|
203
|
+
* @param {string} slug - Feature slug.
|
|
204
|
+
* @param {{cwd:string, gate?:boolean}} opts - cwd (root the SHARED/LOCAL paths resolve against),
|
|
205
|
+
* gate (records mode; the CLI, not this function, turns gate+red into a non-zero exit).
|
|
206
|
+
* @returns {{report:object, mermaid:(string|null)}} The trace report (covers_closure, reachability,
|
|
207
|
+
* findings[], overall "green"|"red") and a Mermaid view when a wiring map exists. Each arm
|
|
208
|
+
* self-skips (checked=false) when its artifact is absent — non-regression on pre-spine specs.
|
|
209
|
+
*/
|
|
210
|
+
export function traceLint(slug, { cwd, gate = false }) {
|
|
211
|
+
const shared = sharedRoot(cwd, slug);
|
|
212
|
+
const findings = [];
|
|
213
|
+
|
|
214
|
+
// 1. Covers-closure.
|
|
215
|
+
const reqPath = join(shared, "requirements.md");
|
|
216
|
+
const clauses = existsSync(reqPath) ? parseRequirements(readFileSync(reqPath, "utf8")) : [];
|
|
217
|
+
const board = readBoard(cwd, slug);
|
|
218
|
+
const covered = coveredReqIds(board);
|
|
219
|
+
const knownIds = new Set(clauses.map((c) => c.id));
|
|
220
|
+
|
|
221
|
+
const wantCovered = clauses.filter((c) => c.status === "covered");
|
|
222
|
+
const cut = clauses.filter((c) => c.status !== "covered");
|
|
223
|
+
const uncovered = wantCovered.filter((c) => !covered.has(c.id)).map((c) => c.id);
|
|
224
|
+
const dangling = [...covered].filter((id) => !knownIds.has(id));
|
|
225
|
+
|
|
226
|
+
for (const id of uncovered) {
|
|
227
|
+
const c = clauses.find((x) => x.id === id);
|
|
228
|
+
findings.push({ severity: "red", code: "REQ-UNCOVERED", req: id,
|
|
229
|
+
message: `${id} (status: covered) is named by no AC's covers: — the clause "${(c?.clause || "").slice(0, 60)}" would silently vanish. Cover it with an AC, or mark it CUT (PO-approved).` });
|
|
230
|
+
}
|
|
231
|
+
for (const id of dangling) {
|
|
232
|
+
findings.push({ severity: "red", code: "COVERS-DANGLING", req: id,
|
|
233
|
+
message: `an AC declares (covers: ${id}) but ${id} is not in requirements.md — a covers: link must resolve to a registered REQ.` });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const closureChecked = existsSync(reqPath);
|
|
237
|
+
const coversClosure = {
|
|
238
|
+
checked: closureChecked,
|
|
239
|
+
requirements_total: clauses.length,
|
|
240
|
+
covered_status: wantCovered.length,
|
|
241
|
+
cut_status: cut.length,
|
|
242
|
+
covered_by_ac: [...covered].filter((id) => knownIds.has(id)).length,
|
|
243
|
+
uncovered,
|
|
244
|
+
dangling_covers: dangling,
|
|
245
|
+
pass: uncovered.length === 0 && dangling.length === 0,
|
|
246
|
+
skipped_reason: closureChecked ? null : "no requirements.md registry — covers-closure not applicable (non-regression on pre-spine specs).",
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// 2. Reachability (profile-gated). Both artifacts are markdown since ADR-0001; `readContract`
|
|
250
|
+
// still accepts the legacy `.json` so a project mid-migration is checked rather than skipped.
|
|
251
|
+
const wiringPath = join(shared, "wiring-map.md");
|
|
252
|
+
const profilePath = join(shared, "project-profile.md");
|
|
253
|
+
let reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "no wiring-map — reachability not applicable." };
|
|
254
|
+
let wiringMap = null;
|
|
255
|
+
|
|
256
|
+
let wiringFound = null;
|
|
257
|
+
try { wiringFound = readContract(wiringPath, WIRING_MAP); }
|
|
258
|
+
catch (e) {
|
|
259
|
+
findings.push({ severity: "red", code: "WIRING-UNREADABLE", message: `wiring-map is not readable (${e.message}).` });
|
|
260
|
+
}
|
|
261
|
+
if (wiringFound) wiringMap = wiringFound.contract;
|
|
262
|
+
|
|
263
|
+
// HD-001. A map whose `## Wiring` table is under a heading the parser does not claim reads as
|
|
264
|
+
// zero entries, and the loop below then walks nothing and reports `0/0 engines reach <entry>` —
|
|
265
|
+
// GREEN, for a committed file holding six correct rows. The gate whose entire purpose is that no
|
|
266
|
+
// engine ships orphaned failing open, on a file that looks right to every human who reviews it.
|
|
267
|
+
// An unreadable contract is now RED and reachability is not claimed, because none was checked.
|
|
268
|
+
const wiringUnreadable = unreadableReason(wiringMap);
|
|
269
|
+
if (wiringUnreadable) {
|
|
270
|
+
findings.push({ severity: "red", code: "WIRING-UNREADABLE", message: `wiring-map.md could not be read as a WiringMap: ${wiringUnreadable}. Reachability was NOT checked — a map this parser cannot see is not a map with nothing in it.` });
|
|
271
|
+
wiringMap = null;
|
|
272
|
+
reachability = { checked: false, pass: false, unreachable: [],
|
|
273
|
+
skipped_reason: `wiring-map.md is present but unreadable (${wiringUnreadable}) — reachability cannot be claimed.` };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (wiringMap) {
|
|
277
|
+
let profileFound = null;
|
|
278
|
+
try { profileFound = readContract(profilePath, PROJECT_PROFILE); }
|
|
279
|
+
catch (e) { findings.push({ severity: "red", code: "PROFILE-UNREADABLE", message: `project-profile is not readable (${e.message}).` }); }
|
|
280
|
+
if (!profileFound) {
|
|
281
|
+
reachability = { checked: false, pass: true, unreachable: [],
|
|
282
|
+
skipped_reason: "wiring-map present but no project-profile — reachability needs an entry_point; declare the profile at L0." };
|
|
283
|
+
findings.push({ severity: "warn", code: "PROFILE-MISSING", message: "wiring-map exists but project-profile does not — reachability is skipped until the entry point is declared." });
|
|
284
|
+
} else {
|
|
285
|
+
const profile = profileFound.contract;
|
|
286
|
+
const entryPoint = profile?.entry_point;
|
|
287
|
+
if (!entryPoint) {
|
|
288
|
+
reachability = { checked: false, pass: true, unreachable: [], skipped_reason: "project-profile has no entry_point." };
|
|
289
|
+
} else {
|
|
290
|
+
const { reachable, entryResolved } = reachableFrom(entryPoint, cwd);
|
|
291
|
+
if (!entryResolved) {
|
|
292
|
+
reachability = { checked: false, pass: true, unreachable: [], entry_point: entryPoint,
|
|
293
|
+
skipped_reason: `entry_point "${entryPoint}" does not resolve to a source file on disk — reachability skipped.` };
|
|
294
|
+
findings.push({ severity: "warn", code: "ENTRY-MISSING", message: `project-profile.md entry_point "${entryPoint}" is not on disk — reachability cannot run.` });
|
|
295
|
+
} else {
|
|
296
|
+
const unreachable = [];
|
|
297
|
+
for (const e of wiringMap.entries || []) {
|
|
298
|
+
const engResolved = resolveFile(e.engine, cwd);
|
|
299
|
+
const ok = engResolved ? reachable.has(engResolved) : false;
|
|
300
|
+
if (!ok) {
|
|
301
|
+
unreachable.push({ use_case: e.use_case, engine: e.engine, reason: engResolved ? "not imported from the entry point" : "engine file not on disk" });
|
|
302
|
+
findings.push({ severity: "red", code: "UC-UNREACHABLE", uc: e.use_case,
|
|
303
|
+
message: `${e.use_case}: engine "${e.engine}" is ${engResolved ? "never imported from" : "missing under"} entry_point "${entryPoint}" — the module ships orphaned from the running app.` });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
reachability = { checked: true, entry_point: entryPoint, entry_resolved: entryResolved,
|
|
307
|
+
reachable_files: reachable.size, engines_total: (wiringMap.entries || []).length, unreachable, pass: unreachable.length === 0 };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const overall = findings.some((f) => f.severity === "red") ? "red" : "green";
|
|
314
|
+
const report = {
|
|
315
|
+
schema_version: 1,
|
|
316
|
+
slug,
|
|
317
|
+
at: new Date().toISOString(),
|
|
318
|
+
mode: gate ? "gate" : "advisory",
|
|
319
|
+
advisory: !gate,
|
|
320
|
+
covers_closure: coversClosure,
|
|
321
|
+
reachability,
|
|
322
|
+
findings,
|
|
323
|
+
overall,
|
|
324
|
+
};
|
|
325
|
+
const mermaid = wiringMap ? wiringMermaid(wiringMap, new Set((reachability.unreachable || []).map((u) => u.use_case))) : null;
|
|
326
|
+
return { report, mermaid };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
331
|
+
export const ARGV_SPEC = {
|
|
332
|
+
usage: "trace-lint.mjs --slug <slug> [--cwd <dir>] [--gate] [--quiet]",
|
|
333
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
334
|
+
slug: { type: "str", required: true },
|
|
335
|
+
cwd: { type: "path" },
|
|
336
|
+
gate: { type: "flag" },
|
|
337
|
+
quiet: { type: "flag" },
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const isMainModule = isMain(import.meta.url);
|
|
341
|
+
if (isMainModule) {
|
|
342
|
+
const args = runArgs(ARGV_SPEC);
|
|
343
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
344
|
+
const slug = args.slug;
|
|
345
|
+
const gate = !!args.gate;
|
|
346
|
+
|
|
347
|
+
const { report, mermaid } = traceLint(slug, { cwd, gate });
|
|
348
|
+
|
|
349
|
+
const outDir = traceDir(cwd, slug);
|
|
350
|
+
mkdirSync(outDir, { recursive: true });
|
|
351
|
+
writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2) + "\n");
|
|
352
|
+
if (mermaid) writeFileSync(join(outDir, "wiring.mmd"), mermaid + "\n");
|
|
353
|
+
|
|
354
|
+
if (!args.quiet) {
|
|
355
|
+
const cc = report.covers_closure, rc = report.reachability;
|
|
356
|
+
const badge = report.overall === "green" ? "🟢 green" : (gate ? "🔴 red" : "🟠 red (advisory)");
|
|
357
|
+
console.log(`trace-lint ${slug} — ${badge} [${report.mode}]`);
|
|
358
|
+
if (cc.checked) console.log(` covers-closure: ${cc.covered_by_ac}/${cc.covered_status} covered · ${cc.cut_status} cut · uncovered [${cc.uncovered.join(", ")}]${cc.dangling_covers.length ? ` · dangling [${cc.dangling_covers.join(", ")}]` : ""}`);
|
|
359
|
+
else console.log(` covers-closure: skipped — ${cc.skipped_reason}`);
|
|
360
|
+
if (rc.checked) console.log(` reachability: ${rc.engines_total - rc.unreachable.length}/${rc.engines_total} engines reach ${rc.entry_point} · unreachable [${rc.unreachable.map((u) => u.use_case).join(", ")}]`);
|
|
361
|
+
else console.log(` reachability: skipped — ${rc.skipped_reason}`);
|
|
362
|
+
for (const f of report.findings) console.log(` ${f.severity === "red" ? "✗" : "⚠"} [${f.code}] ${f.message}`);
|
|
363
|
+
console.log(` → ${relLocal(slug, "trace", "report.json")}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
process.exit(gate && report.overall === "red" ? 1 : 0);
|
|
367
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Envelope schema gate (pure-skill architecture v1.0, plan P0).
|
|
3
|
+
//
|
|
4
|
+
// The lesson already in the repo: structured artifacts + deterministic tooling beat prose
|
|
5
|
+
// conventions (t0-verify.mjs is the most reliable component in the harness). This script makes
|
|
6
|
+
// the WorkOrder/WorkResult ports mechanically checkable: a malformed order never reaches a
|
|
7
|
+
// worker, a malformed result never reaches ingest.
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies, zero network — same discipline as the oracles and gate-l2.mjs. Implements
|
|
10
|
+
// the JSON-Schema subset the shipped schemas use (type, required, properties, items, enum,
|
|
11
|
+
// pattern, $ref) rather than pulling in a validator dependency. $ref supports two forms:
|
|
12
|
+
// #/$defs/Name — a definition in the SAME schema document
|
|
13
|
+
// domain.schema.json#/$defs/Name — a definition in a SIBLING file (the central domain
|
|
14
|
+
// registry; resolved against the schema's own dir,
|
|
15
|
+
// falling back to skills/tech-lead/schemas/)
|
|
16
|
+
//
|
|
17
|
+
// Usage (CLI): node skills/tech-lead/scripts/validate-envelope.mjs <envelope.json> <schema.json>
|
|
18
|
+
// exit 0 = valid, 1 = invalid (errors printed one per line)
|
|
19
|
+
// Usage (hook): PreToolUse on Skill|Agent — when the tool input carries `--order <path>`,
|
|
20
|
+
// the order file is validated against schemas/work-order.schema.json; an
|
|
21
|
+
// invalid or missing order DENIES the dispatch (fail-closed on a malformed
|
|
22
|
+
// order, fail-open when no --order is present — standalone runs stay free).
|
|
23
|
+
|
|
24
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
25
|
+
import { resolve, join, dirname } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { isMain } from "./lib/is-main.mjs";
|
|
28
|
+
import { runArgs } from "./lib/argv.mjs";
|
|
29
|
+
import { runHook, readStdin, settle } from "../../../hooks/lib/decision.mjs";
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
export const SCHEMAS_DIR = resolve(HERE, "../schemas");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate a value against the JSON-Schema subset the envelope schemas use (type, required,
|
|
36
|
+
* properties, items, enum, pattern, anyOf, $ref).
|
|
37
|
+
* @param {*} data - The value to validate.
|
|
38
|
+
* @param {object} schema - The (sub)schema to validate against; may itself be a bare `{$ref}`.
|
|
39
|
+
* @param {string} [schemaDir=SCHEMAS_DIR] - Directory cross-file $refs (e.g.
|
|
40
|
+
* "domain.schema.json#/$defs/X") resolve from; defaults to the shipped schemas dir so in-memory
|
|
41
|
+
* schemas keep working.
|
|
42
|
+
* @returns {{valid:boolean, errors:string[]}} valid=true with [] when data conforms; otherwise
|
|
43
|
+
* valid=false and one "`<path>: <message>`" string per violation.
|
|
44
|
+
*/
|
|
45
|
+
export function validate(data, schema, schemaDir = SCHEMAS_DIR) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
walk(data, schema, "$", errors, { doc: schema, dir: schemaDir }, 0);
|
|
48
|
+
return { valid: errors.length === 0, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- $ref resolution ---------------------------------------------------------
|
|
52
|
+
// ctx = { doc, dir }: the schema DOCUMENT the current subschema belongs to (same-doc
|
|
53
|
+
// `#/...` pointers resolve inside it) and the directory sibling files load from.
|
|
54
|
+
const REF_DOC_CACHE = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* Load and cache a sibling schema document for a cross-file $ref.
|
|
57
|
+
* @param {string} file - Schema filename referenced by the $ref (e.g. "domain.schema.json").
|
|
58
|
+
* @param {string} dir - Primary directory to look in (falls back to the shipped schemas dir).
|
|
59
|
+
* @param {string} path - Current data path, for error messages.
|
|
60
|
+
* @param {string[]} errors - Error accumulator, appended to on a missing/unparseable file.
|
|
61
|
+
* @returns {{doc:object, dir:string}|null} The parsed document and its directory (cached), or null
|
|
62
|
+
* when the file is absent or not readable JSON (an error is pushed in that case).
|
|
63
|
+
*/
|
|
64
|
+
function loadRefDoc(file, dir, path, errors) {
|
|
65
|
+
for (const base of [dir, SCHEMAS_DIR]) {
|
|
66
|
+
const abs = resolve(base, file);
|
|
67
|
+
if (REF_DOC_CACHE.has(abs)) return REF_DOC_CACHE.get(abs);
|
|
68
|
+
if (existsSync(abs)) {
|
|
69
|
+
try {
|
|
70
|
+
const entry = { doc: JSON.parse(readFileSync(abs, "utf8")), dir: dirname(abs) };
|
|
71
|
+
REF_DOC_CACHE.set(abs, entry);
|
|
72
|
+
return entry;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
errors.push(`${path}: $ref file ${file} is not readable JSON (${e.message})`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
errors.push(`${path}: $ref file not found: ${file}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a `$ref` (same-doc `#/…` or `file#/…`) to its target subschema.
|
|
85
|
+
* @param {string} ref - The $ref string.
|
|
86
|
+
* @param {{doc:object, dir:string}} ctx - The document the ref belongs to and the dir siblings load from.
|
|
87
|
+
* @param {string} path - Current data path, for error messages.
|
|
88
|
+
* @param {string[]} errors - Error accumulator, appended to when the ref resolves to nothing.
|
|
89
|
+
* @returns {{schema:object, ctx:{doc:object,dir:string}}|null} The target subschema and the context
|
|
90
|
+
* it lives in (so nested refs resolve correctly), or null on an unresolved ref.
|
|
91
|
+
*/
|
|
92
|
+
function resolveRef(ref, ctx, path, errors) {
|
|
93
|
+
const [file, pointer = ""] = ref.split("#");
|
|
94
|
+
const target = file ? loadRefDoc(file, ctx.dir, path, errors) : ctx;
|
|
95
|
+
if (!target) return null;
|
|
96
|
+
let node = target.doc;
|
|
97
|
+
for (const seg of pointer.split("/").filter(Boolean)) {
|
|
98
|
+
node = node && typeof node === "object" ? node[seg.replace(/~1/g, "/").replace(/~0/g, "~")] : undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!node || typeof node !== "object") {
|
|
101
|
+
errors.push(`${path}: $ref "${ref}" resolves to nothing`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return { schema: node, ctx: target };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Classify a value using JSON-Schema type names.
|
|
109
|
+
* @param {*} v - Any value.
|
|
110
|
+
* @returns {("array"|"null"|"integer"|"number"|"object"|"string"|"boolean"|"undefined"|"function")}
|
|
111
|
+
* The schema-type name; integral numbers report "integer", non-integral "number".
|
|
112
|
+
*/
|
|
113
|
+
function typeOf(v) {
|
|
114
|
+
if (Array.isArray(v)) return "array";
|
|
115
|
+
if (v === null) return "null";
|
|
116
|
+
if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
|
|
117
|
+
return typeof v;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Recursively check `data` against `schema`, pushing one message per violation (the validation core).
|
|
122
|
+
* @param {*} data - The value (sub)tree being checked.
|
|
123
|
+
* @param {object} schema - The (sub)schema to apply.
|
|
124
|
+
* @param {string} path - JSON-path label for messages (e.g. "$.payload.tasks[0]").
|
|
125
|
+
* @param {string[]} errors - Error accumulator, appended to in place.
|
|
126
|
+
* @param {{doc:object, dir:string}} ctx - Schema document + directory for $ref resolution.
|
|
127
|
+
* @param {number} refDepth - Current $ref chain depth; a chain deeper than 16 is reported as a cycle.
|
|
128
|
+
* @returns {void} Nothing; violations are recorded on `errors`.
|
|
129
|
+
*/
|
|
130
|
+
function walk(data, schema, path, errors, ctx, refDepth) {
|
|
131
|
+
if (!schema || typeof schema !== "object") return;
|
|
132
|
+
if (schema.$ref) {
|
|
133
|
+
if (refDepth > 16) { errors.push(`${path}: $ref chain too deep (cycle?) at "${schema.$ref}"`); return; }
|
|
134
|
+
const resolved = resolveRef(schema.$ref, ctx, path, errors);
|
|
135
|
+
if (resolved) walk(data, resolved.schema, path, errors, resolved.ctx, refDepth + 1);
|
|
136
|
+
return; // $ref replaces the subschema (draft 2020-12 sibling keywords not used here)
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(schema.anyOf)) {
|
|
139
|
+
// Additive-union support (spine v1.3: acceptance_criteria = string | {text, covers?}).
|
|
140
|
+
// Valid iff ≥1 branch validates with zero errors; each branch is tried against a throwaway
|
|
141
|
+
// error collection so a failing branch never leaks into the report.
|
|
142
|
+
const matched = schema.anyOf.some((sub) => {
|
|
143
|
+
const branchErrors = [];
|
|
144
|
+
walk(data, sub, path, branchErrors, ctx, refDepth);
|
|
145
|
+
return branchErrors.length === 0;
|
|
146
|
+
});
|
|
147
|
+
if (!matched) errors.push(`${path}: ${JSON.stringify(data)} matches none of the ${schema.anyOf.length} allowed shapes (anyOf)`);
|
|
148
|
+
return; // anyOf is terminal here (no sibling keywords combined with it in the shipped schemas)
|
|
149
|
+
}
|
|
150
|
+
if (schema.type) {
|
|
151
|
+
const t = typeOf(data);
|
|
152
|
+
// `type` may be a single name or a JSON-Schema union (`["integer", "null"]`). A nullable
|
|
153
|
+
// field is a real thing in this registry — `T0Score.db_probe` is null when no probe is
|
|
154
|
+
// DECLARED, which is an absence and never a failure — and a validator that cannot express it
|
|
155
|
+
// would force every such field to be written in a shape it can only pretend to check.
|
|
156
|
+
const accepted = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
157
|
+
const okType = accepted.some((want) => (want === "number" ? t === "number" || t === "integer" : t === want));
|
|
158
|
+
if (!okType) {
|
|
159
|
+
errors.push(`${path}: expected ${accepted.join("|")}, got ${t}`);
|
|
160
|
+
return; // deeper checks are meaningless on the wrong type
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (schema.enum && !schema.enum.some((v) => v === data)) {
|
|
164
|
+
errors.push(`${path}: value ${JSON.stringify(data)} not in enum [${schema.enum.join(", ")}]`);
|
|
165
|
+
}
|
|
166
|
+
if (schema.pattern && typeof data === "string" && !new RegExp(schema.pattern).test(data)) {
|
|
167
|
+
errors.push(`${path}: "${data}" does not match pattern ${schema.pattern}`);
|
|
168
|
+
}
|
|
169
|
+
if (typeOf(data) === "object") {
|
|
170
|
+
for (const req of schema.required || []) {
|
|
171
|
+
if (!(req in data)) errors.push(`${path}: missing required field "${req}"`);
|
|
172
|
+
}
|
|
173
|
+
for (const [key, sub] of Object.entries(schema.properties || {})) {
|
|
174
|
+
if (key in data) walk(data[key], sub, `${path}.${key}`, errors, ctx, 0);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typeOf(data) === "array" && schema.items) {
|
|
178
|
+
data.forEach((item, i) => walk(item, schema.items, `${path}[${i}]`, errors, ctx, 0));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Load an envelope file and a schema file from disk and validate one against the other.
|
|
184
|
+
* @param {string} envelopePath - Path to the JSON envelope.
|
|
185
|
+
* @param {string} schemaPath - Path to the JSON schema; cross-file $refs resolve from its own dir.
|
|
186
|
+
* @returns {{valid:boolean, errors:string[]}} Same shape as {@link validate}.
|
|
187
|
+
* @throws {SyntaxError} If either file is not valid JSON.
|
|
188
|
+
* @throws {Error} If either file is not readable.
|
|
189
|
+
*/
|
|
190
|
+
export function validateFile(envelopePath, schemaPath) {
|
|
191
|
+
const data = JSON.parse(readFileSync(envelopePath, "utf8"));
|
|
192
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
193
|
+
return validate(data, schema, dirname(resolve(schemaPath)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Entry point: CLI when args are given, PreToolUse hook when fed stdin JSON.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* The typed argv contract (see `./lib/argv.mjs`) — CLI mode only. In hook mode argv is empty and
|
|
201
|
+
* the envelope arrives on stdin, so nothing here is consulted.
|
|
202
|
+
*/
|
|
203
|
+
export const ARGV_SPEC = {
|
|
204
|
+
usage: "validate-envelope.mjs <envelope.json> <schema.json> (no args → PreToolUse hook mode)",
|
|
205
|
+
_: { arity: 2, max: 2, name: "<envelope.json> <schema.json>" },
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const isMainModule = isMain(import.meta.url);
|
|
209
|
+
if (isMainModule) {
|
|
210
|
+
if (process.argv[2]) {
|
|
211
|
+
// CLI mode
|
|
212
|
+
const [envelopePath, schemaPath] = runArgs(ARGV_SPEC)._;
|
|
213
|
+
try {
|
|
214
|
+
const { valid, errors } = validateFile(resolve(envelopePath), resolve(schemaPath));
|
|
215
|
+
if (valid) {
|
|
216
|
+
console.log(`✅ ${envelopePath} is a valid ${JSON.parse(readFileSync(resolve(schemaPath), "utf8")).title || "envelope"}`);
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
for (const e of errors) console.error(` ✗ ${e}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
console.error(` ✗ ${e.message}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
// Hook mode (PreToolUse). Deny contract identical to gate-l2.mjs, and — since v1.5 — the same
|
|
227
|
+
// receipt: `allow` carries evidence, so "validated the order and permitted it" is no longer
|
|
228
|
+
// byte-identical to "this hook never ran" (hooks/lib/decision.mjs).
|
|
229
|
+
await runHook("validate-envelope", async () => {
|
|
230
|
+
/**
|
|
231
|
+
* Fail-open with the reason on the record: the tool call proceeds, gating nothing.
|
|
232
|
+
* @param {string} reason - Why this dispatch was not gated.
|
|
233
|
+
* @param {string} [rule] - Which fail-open condition matched.
|
|
234
|
+
* @returns {never} Does not return — settles the hook.
|
|
235
|
+
*/
|
|
236
|
+
const defer = (reason, rule) => settle({
|
|
237
|
+
verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, cwd: p?.cwd, reason, rule,
|
|
238
|
+
});
|
|
239
|
+
const raw = await readStdin();
|
|
240
|
+
let p;
|
|
241
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
242
|
+
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
243
|
+
if (p.tool_name !== "Skill" && p.tool_name !== "Agent") {
|
|
244
|
+
defer(`${p.tool_name ?? "no tool_name"} is not a dispatch tool — out of scope`);
|
|
245
|
+
}
|
|
246
|
+
const haystack = [p.tool_input?.skill_args, p.tool_input?.args, p.tool_input?.prompt]
|
|
247
|
+
.filter(Boolean).join(" ");
|
|
248
|
+
const m = haystack.match(/--order(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
249
|
+
// no order threaded → not an orchestrated dispatch, nothing to gate
|
|
250
|
+
if (!m) defer("no --order threaded — not an orchestrated dispatch", "no-order");
|
|
251
|
+
const orderPath = resolve(p.cwd || process.cwd(), m[1] || m[2] || m[3]);
|
|
252
|
+
/**
|
|
253
|
+
* Emit a PreToolUse deny decision for the dispatch.
|
|
254
|
+
* @param {string} reason - Human-readable explanation surfaced to the caller.
|
|
255
|
+
* @param {string} rule - Which denial rule fired.
|
|
256
|
+
* @returns {never} Does not return — settles the hook with the deny payload.
|
|
257
|
+
*/
|
|
258
|
+
const deny = (reason, rule) => settle({
|
|
259
|
+
verdict: "deny", event: "PreToolUse", tool: p.tool_name, cwd: p.cwd, subject: orderPath, rule, reason,
|
|
260
|
+
payload: {
|
|
261
|
+
hookSpecificOutput: {
|
|
262
|
+
hookEventName: "PreToolUse",
|
|
263
|
+
permissionDecision: "deny",
|
|
264
|
+
permissionDecisionReason: reason,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
if (!existsSync(orderPath)) {
|
|
269
|
+
deny(`WorkOrder gate — order file not found: ${orderPath}. Compile it first (compile-order.mjs) — a worker must never be dispatched against a dangling order.`, "order-missing");
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const { valid, errors } = validateFile(orderPath, join(SCHEMAS_DIR, "work-order.schema.json"));
|
|
273
|
+
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-order.mjs) and re-dispatch.`, "schema-invalid");
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e?.name === "HookDecision") throw e;
|
|
278
|
+
deny(`WorkOrder gate — ${orderPath} is not readable JSON (${e.message}).`, "order-unreadable");
|
|
279
|
+
}
|
|
280
|
+
defer(`order validated against work-order.schema.json — permitted`, "order-valid");
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|