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,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Session rehydrate — SessionStart hook, matcher "startup|compact|resume|clear".
|
|
3
|
+
//
|
|
4
|
+
// Fires when a session begins with a harness run already in flight, and injects the RunSnapshot's
|
|
5
|
+
// rehydrate_hint as additionalContext: re-derive round/attempt/hill from the files, never from
|
|
6
|
+
// memory. This is the reflex that makes the file-first run-state load-bearing at the exact moment
|
|
7
|
+
// the in-context copy is absent or degraded: without it the orchestrator can re-dispatch an
|
|
8
|
+
// already-ingested order, miscount attempts off a lossy summary, or — measured, below — re-open a
|
|
9
|
+
// run that is already open and rebuild the whole pipeline from phase 1.
|
|
10
|
+
//
|
|
11
|
+
// v1.4.1 — `startup` ADDED TO THE MATCHER, and this is a measured correction, not a widening.
|
|
12
|
+
//
|
|
13
|
+
// The matcher was `compact|resume`. Both of those are continuations of a conversation that still
|
|
14
|
+
// exists. The commonest continuity event in real use has neither: you close the terminal and come
|
|
15
|
+
// back tomorrow, or a teammate picks the work up in a fresh checkout. The CLI reports that as
|
|
16
|
+
// `SessionStart:startup`, and the reflex whose entire purpose is "trust the files, not your
|
|
17
|
+
// memory" did not fire in the one case where there IS no memory to distrust.
|
|
18
|
+
//
|
|
19
|
+
// The SDD harness benchmark measured the cost. Its F4 handoff design is exactly this scenario — a
|
|
20
|
+
// fresh `claude -p` in a workspace where a prior session was cut mid-build — and across three
|
|
21
|
+
// Sonnet rows every one recorded `hooks_fired: ["SessionStart:startup"]` (the plugin's load echo,
|
|
22
|
+
// not this hook). With no pointer to the run in flight, the orchestrator re-entered at phase 1:
|
|
23
|
+
// 82–120 turns before its first write, $4.57–$10.36 for the session, and **0/3 of the gap closed**
|
|
24
|
+
// while the run receipt and board sat on disk the whole time. One row reached GATE L4 — ship
|
|
25
|
+
// sign-off — having advanced the deliverable by zero criteria.
|
|
26
|
+
//
|
|
27
|
+
// Firing on `startup` is free when there is nothing to say: `findRun` returns a run only for an
|
|
28
|
+
// `active-scope` pointer or a `harness-run.md` whose status is mid-run, so an ordinary session in a
|
|
29
|
+
// repo with no run — or with a shipped one — gets silence and exit 0, exactly as before.
|
|
30
|
+
//
|
|
31
|
+
// `clear` is included for the same reason: it discards the conversation and keeps the workspace,
|
|
32
|
+
// which is the same problem wearing a different name.
|
|
33
|
+
//
|
|
34
|
+
// Derivation is always FRESH (files beat any persisted copy); the run-snapshot.json written
|
|
35
|
+
// by hooks/compact-snapshot.mjs is only the fallback if live derivation throws. Fail-open:
|
|
36
|
+
// no active run, or any error → silent exit 0.
|
|
37
|
+
//
|
|
38
|
+
// Contract: SessionStart stdin JSON { cwd, source: "startup"|"resume"|"clear"|"compact" }.
|
|
39
|
+
// Inject via { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext } }.
|
|
40
|
+
|
|
41
|
+
import { readFileSync } from "node:fs";
|
|
42
|
+
import { deriveSnapshot, snapshotPath } from "../skills/tech-lead/scripts/run-snapshot.mjs";
|
|
43
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
44
|
+
import { runHook, readStdin } from "./lib/decision.mjs";
|
|
45
|
+
import { activeScope } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
46
|
+
|
|
47
|
+
// The snapshot's own hint says "trust the files, not the conversation summary" — correct after a
|
|
48
|
+
// compaction, and slightly wrong on a cold start, where there is no summary and no conversation
|
|
49
|
+
// either. The two sources also fail in DIFFERENT directions, so they need different first sentences:
|
|
50
|
+
//
|
|
51
|
+
// compact / resume — the risk is acting on a lossy summary of work you remember doing.
|
|
52
|
+
// startup / clear — the risk is not knowing the run exists, and OPENING IT AGAIN. That is the
|
|
53
|
+
// failure the benchmark measured: a fresh session re-entered at phase 1 and
|
|
54
|
+
// spent 82–120 turns rebuilding a pipeline that was already on disk.
|
|
55
|
+
//
|
|
56
|
+
// Naming the right failure is the whole value of the injection. A generic pointer to the files is
|
|
57
|
+
// what a competent agent finds anyway; "there is a run open, do not re-open it" is not.
|
|
58
|
+
function lead(source) {
|
|
59
|
+
if (source === "startup" || source === "clear") {
|
|
60
|
+
return "A shapeup-sdlc run is ALREADY OPEN in this workspace and you have no memory of it. " +
|
|
61
|
+
"Do NOT open a new run, re-run intake, or restart the pipeline from phase 1 — the receipt, " +
|
|
62
|
+
"the board and the ledger already exist. RESUME from the phase the files say the run is in. ";
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main() {
|
|
68
|
+
await runHook("session-rehydrate", async () => {
|
|
69
|
+
const raw = await readStdin();
|
|
70
|
+
let p;
|
|
71
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
72
|
+
catch (e) { return { verdict: "error", event: "SessionStart", reason: `unparseable payload: ${e.message}` }; }
|
|
73
|
+
const cwd = p.cwd || process.cwd();
|
|
74
|
+
|
|
75
|
+
let snapshot = null;
|
|
76
|
+
let source = "live";
|
|
77
|
+
try {
|
|
78
|
+
snapshot = deriveSnapshot(cwd);
|
|
79
|
+
} catch {
|
|
80
|
+
// Live derivation failed → fall back to the pre-compaction anchor, best effort.
|
|
81
|
+
source = "anchor";
|
|
82
|
+
try {
|
|
83
|
+
const pointer = JSON.parse(readFileSync(activeScope(cwd), "utf8"));
|
|
84
|
+
if (pointer?.slug) snapshot = JSON.parse(readFileSync(snapshotPath(cwd, pointer.slug), "utf8"));
|
|
85
|
+
} catch { /* no anchor either → stay silent */ }
|
|
86
|
+
}
|
|
87
|
+
if (!snapshot?.rehydrate_hint) {
|
|
88
|
+
return { verdict: "allow", event: "SessionStart", cwd, subject: p.source ?? null, reason: "no active run — nothing to rehydrate" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
verdict: "allow", event: "SessionStart", cwd, rule: `injected:${source}`,
|
|
93
|
+
subject: snapshot.slug ?? p.source ?? null,
|
|
94
|
+
reason: `rehydrate_hint injected on SessionStart:${p.source ?? "?"}`,
|
|
95
|
+
// additionalContext is not a deny, but it IS output, so it rides the same channel.
|
|
96
|
+
payload: {
|
|
97
|
+
hookSpecificOutput: {
|
|
98
|
+
hookEventName: "SessionStart",
|
|
99
|
+
additionalContext: `${lead(p.source)}${snapshot.rehydrate_hint}\n${JSON.stringify(snapshot)}`,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
emit: true,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isMain(import.meta.url)) {
|
|
108
|
+
main();
|
|
109
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Slop cleaner — advisory Stop hook (v1.2, absorb-audit P2).
|
|
3
|
+
//
|
|
4
|
+
// Scans what actually changed this session (git diff of the working tree; fallback: the
|
|
5
|
+
// newest WorkResult's files_touched) for the classic leftovers — TODO/FIXME markers,
|
|
6
|
+
// console.log/debugger, blocks of commented-out code, one file swallowing hundreds of added
|
|
7
|
+
// lines — and mentions them to the user on stop.
|
|
8
|
+
//
|
|
9
|
+
// ADVISORY ONLY, same contract as anti-rationalization.mjs: exit 0 always, at most
|
|
10
|
+
// { systemMessage }, never { decision:"block" }, never exit 2 ("QA is a level-up, not a
|
|
11
|
+
// gate"). Harness-scoped: silent unless a run is active.
|
|
12
|
+
//
|
|
13
|
+
// Contract: Stop stdin JSON { cwd, stop_hook_active }.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { activeSlug } from "./anti-rationalization.mjs";
|
|
19
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
20
|
+
import { LOCAL, SHARED, resultsDir } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
21
|
+
|
|
22
|
+
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
23
|
+
|
|
24
|
+
// Harness bookkeeping is never "slop" — skip both storage roots, whichever names they carry.
|
|
25
|
+
const SKIP_PATH = new RegExp(`^(${[LOCAL, SHARED].map((r) => r.replace(/[.\\]/g, "\\$&")).join("|")})/`);
|
|
26
|
+
const MAX_FILES = 30;
|
|
27
|
+
const MAX_ADDED_LINES_PER_FILE = 400;
|
|
28
|
+
|
|
29
|
+
const MARKERS = [
|
|
30
|
+
{ name: "TODO/FIXME", re: /\b(TODO|FIXME|XXX|HACK)\b/ },
|
|
31
|
+
{ name: "console.log", re: /\bconsole\.(log|debug)\s*\(/ },
|
|
32
|
+
{ name: "debugger", re: /^\s*debugger\b/ },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Scan a unified diff for slop in ADDED lines only.
|
|
36
|
+
* Returns [{ file, markers: {name: count}, added, big, commented_code }]. */
|
|
37
|
+
export function scanDiff(diffText) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
let current = null;
|
|
40
|
+
let commentRun = 0;
|
|
41
|
+
let filesSeen = 0;
|
|
42
|
+
|
|
43
|
+
const push = () => {
|
|
44
|
+
if (!current) return;
|
|
45
|
+
const dirty = current.added > MAX_ADDED_LINES_PER_FILE ||
|
|
46
|
+
current.commented_code ||
|
|
47
|
+
Object.keys(current.markers).length > 0;
|
|
48
|
+
current.big = current.added > MAX_ADDED_LINES_PER_FILE;
|
|
49
|
+
if (dirty) findings.push(current);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const line of (diffText || "").split("\n")) {
|
|
53
|
+
if (line.startsWith("+++ ")) {
|
|
54
|
+
push();
|
|
55
|
+
commentRun = 0;
|
|
56
|
+
const file = line.replace(/^\+\+\+ (b\/)?/, "").trim();
|
|
57
|
+
if (file === "/dev/null" || SKIP_PATH.test(file) || ++filesSeen > MAX_FILES) {
|
|
58
|
+
current = null;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
current = { file, markers: {}, added: 0, big: false, commented_code: false };
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!current || !line.startsWith("+") || line.startsWith("+++")) { commentRun = 0; continue; }
|
|
65
|
+
const added = line.slice(1);
|
|
66
|
+
current.added++;
|
|
67
|
+
for (const { name, re } of MARKERS) {
|
|
68
|
+
if (re.test(added)) current.markers[name] = (current.markers[name] || 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
// ≥4 consecutive added comment lines that look like code = a commented-out block
|
|
71
|
+
if (/^\s*(\/\/|#)/.test(added) && /[;{}()=]/.test(added)) {
|
|
72
|
+
if (++commentRun >= 4) current.commented_code = true;
|
|
73
|
+
} else {
|
|
74
|
+
commentRun = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
push();
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One human-readable fragment per finding. */
|
|
82
|
+
export function summarize(findings) {
|
|
83
|
+
return findings.slice(0, 6).map((f) => {
|
|
84
|
+
const bits = Object.entries(f.markers).map(([name, n]) => `${name} ×${n}`);
|
|
85
|
+
if (f.commented_code) bits.push("commented-out code block");
|
|
86
|
+
if (f.big) bits.push(`+${f.added} lines in one file`);
|
|
87
|
+
return `${f.file}: ${bits.join(", ")}`;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function git(cwd, args) {
|
|
92
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
|
93
|
+
return r.status === 0 ? r.stdout : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The session's change set as a unified diff — git first, WorkResult fallback. */
|
|
97
|
+
function collectDiff(cwd, slug) {
|
|
98
|
+
const tracked = git(cwd, ["diff", "HEAD"]);
|
|
99
|
+
if (tracked !== null) {
|
|
100
|
+
// Untracked files never show in `diff HEAD`; synthesize +lines for small text ones.
|
|
101
|
+
let extra = "";
|
|
102
|
+
const status = git(cwd, ["status", "--porcelain"]) || "";
|
|
103
|
+
for (const line of status.split("\n")) {
|
|
104
|
+
const m = /^\?\?\s+(.+)$/.exec(line);
|
|
105
|
+
if (!m || SKIP_PATH.test(m[1])) continue;
|
|
106
|
+
const p = join(cwd, m[1]);
|
|
107
|
+
try {
|
|
108
|
+
if (statSync(p).size > 200 * 1024) continue;
|
|
109
|
+
const body = readFileSync(p, "utf8");
|
|
110
|
+
if (body.includes("\u0000")) continue; // binary
|
|
111
|
+
extra += `+++ b/${m[1]}\n` + body.split("\n").map((l) => `+${l}`).join("\n") + "\n";
|
|
112
|
+
} catch { /* unreadable → skip */ }
|
|
113
|
+
}
|
|
114
|
+
return tracked + extra;
|
|
115
|
+
}
|
|
116
|
+
// Not a git repo → fall back to the newest WorkResult's files_touched.
|
|
117
|
+
const resultsPath = resultsDir(cwd, slug);
|
|
118
|
+
if (!existsSync(resultsPath)) return null;
|
|
119
|
+
const newest = readdirSync(resultsPath).filter((f) => f.endsWith(".json"))
|
|
120
|
+
.map((f) => join(resultsPath, f))
|
|
121
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
122
|
+
if (!newest) return null;
|
|
123
|
+
let diff = "";
|
|
124
|
+
try {
|
|
125
|
+
const result = JSON.parse(readFileSync(newest, "utf8"));
|
|
126
|
+
for (const t of (result.files_touched || []).slice(0, MAX_FILES)) {
|
|
127
|
+
const rel = t?.path;
|
|
128
|
+
if (!rel || SKIP_PATH.test(rel)) continue;
|
|
129
|
+
const p = join(cwd, rel);
|
|
130
|
+
try {
|
|
131
|
+
if (statSync(p).size > 200 * 1024) continue;
|
|
132
|
+
const body = readFileSync(p, "utf8");
|
|
133
|
+
if (body.includes("\u0000")) continue;
|
|
134
|
+
diff += `+++ b/${rel}\n` + body.split("\n").map((l) => `+${l}`).join("\n") + "\n";
|
|
135
|
+
} catch { /* unreadable → skip */ }
|
|
136
|
+
}
|
|
137
|
+
} catch { return null; }
|
|
138
|
+
return diff || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main() {
|
|
142
|
+
await runHook("slop-cleaner", async () => {
|
|
143
|
+
const raw = await readStdin();
|
|
144
|
+
let p;
|
|
145
|
+
/** Stay silent — with the reason on the record (hooks/lib/decision.mjs). */
|
|
146
|
+
const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
|
|
147
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
148
|
+
catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
|
|
149
|
+
|
|
150
|
+
if (p.stop_hook_active) defer("stop_hook_active — never participate in a stop-hook loop", "loop-guard");
|
|
151
|
+
|
|
152
|
+
const cwd = p.cwd || process.cwd();
|
|
153
|
+
const slug = activeSlug(cwd);
|
|
154
|
+
if (!slug) defer("no active run — stay silent", "no-run");
|
|
155
|
+
|
|
156
|
+
const diff = collectDiff(cwd, slug);
|
|
157
|
+
if (!diff) defer("no diff to scan", "no-diff");
|
|
158
|
+
|
|
159
|
+
const findings = scanDiff(diff);
|
|
160
|
+
if (findings.length === 0) defer("diff scanned, no leftovers found — inspected and permitted", "diff-clean");
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
verdict: "allow", event: "Stop", cwd, subject: slug, rule: "slop-found", emit: true,
|
|
164
|
+
reason: `${findings.length} leftover(s) in the recent diff — advisory note emitted, not a block`,
|
|
165
|
+
payload: {
|
|
166
|
+
systemMessage:
|
|
167
|
+
`slop-cleaner (advisory): recent edits carry leftovers — ${summarize(findings).join("; ")}. ` +
|
|
168
|
+
`Not blocking — consider a cleanup pass.`,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (isMain(import.meta.url)) {
|
|
175
|
+
main();
|
|
176
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared helpers for evaluation-contract oracle runners (Stage G of the audit).
|
|
2
|
+
//
|
|
3
|
+
// The `process` oracle (step 1–3) inlined these; the test/snapshot/http oracles (steps 4–5)
|
|
4
|
+
// import them so the matching grammar is identical across the registry. Keeping one definition
|
|
5
|
+
// of `exit`/regex matching means a contract author learns the grammar once.
|
|
6
|
+
|
|
7
|
+
// Stack-trace / panic signature shared by every oracle's crash check.
|
|
8
|
+
export const CRASH_RE = /at\s+.*:\d+:\d+|Traceback|panic:|unhandled|Segmentation fault/i;
|
|
9
|
+
|
|
10
|
+
// Interpret an `exit`/`status` spec against an observed number.
|
|
11
|
+
// undefined | "*" → any (no constraint)
|
|
12
|
+
// number → strict equality
|
|
13
|
+
// "==N" "!=N" ">N" "<N" ">=N" "<=N"
|
|
14
|
+
export function matchNum(spec, n) {
|
|
15
|
+
if (spec === undefined || spec === "*") return true;
|
|
16
|
+
if (typeof spec === "number") return n === spec;
|
|
17
|
+
const m = String(spec).match(/^(==|!=|>=|<=|>|<)\s*(-?\d+)$/);
|
|
18
|
+
if (!m) return false;
|
|
19
|
+
const want = Number(m[2]);
|
|
20
|
+
switch (m[1]) {
|
|
21
|
+
case "==": return n === want;
|
|
22
|
+
case "!=": return n !== want;
|
|
23
|
+
case ">": return n > want;
|
|
24
|
+
case "<": return n < want;
|
|
25
|
+
case ">=": return n >= want;
|
|
26
|
+
case "<=": return n <= want;
|
|
27
|
+
default: return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "/pattern/flags" → RegExp; a bare string → literal-ish RegExp.
|
|
32
|
+
export function toRegExp(spec) {
|
|
33
|
+
const m = String(spec).match(/^\/(.*)\/([a-z]*)$/);
|
|
34
|
+
return m ? new RegExp(m[1], m[2]) : new RegExp(spec);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Uniform report formatter so every oracle prints the same PASS/FAIL shape.
|
|
38
|
+
export function formatReport(label, { fails, results }) {
|
|
39
|
+
const lines = [`\nEvaluation report — ${label}\n${"=".repeat(60)}`];
|
|
40
|
+
for (const r of results) {
|
|
41
|
+
lines.push(`${r.pass ? "PASS" : "FAIL"} ${r.id} ${r.desc || ""}\n ⇒ ${r.evidence}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push("=".repeat(60));
|
|
44
|
+
lines.push(fails === 0 ? `✅ all ${results.length} criteria PASS` : `❌ ${fails}/${results.length} criteria FAIL`);
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `http` oracle for the evaluation contract (Stage G, step 5 of the audit).
|
|
3
|
+
//
|
|
4
|
+
// Deliverable: a service / API. The oracle starts the server, waits until it is reachable,
|
|
5
|
+
// sends each criterion's request, and grades the OBSERVED response (status + body) — never the
|
|
6
|
+
// source. It then tears the server down. This reuses the `process` sandbox idea (controlled
|
|
7
|
+
// spawn) but observes over HTTP instead of stdout.
|
|
8
|
+
//
|
|
9
|
+
// Invariants (carried from the evaluator's design):
|
|
10
|
+
// • Probe behavior, not code presence — the verdict cites response status + body.
|
|
11
|
+
// • Absence of evidence = FAIL — if the server never becomes reachable, EVERY criterion FAILs
|
|
12
|
+
// (a service you cannot reach does not pass), and a request that errors out FAILs.
|
|
13
|
+
// • One verdict per criterion.
|
|
14
|
+
//
|
|
15
|
+
// Contract shape:
|
|
16
|
+
// { "oracle": "http",
|
|
17
|
+
// "server": { "cmd": "node ./server.mjs", "ready_path": "/health", "ready_timeout_ms": 4000 },
|
|
18
|
+
// "criteria": [
|
|
19
|
+
// { "id": "H1", "desc": "health is ok",
|
|
20
|
+
// "probe": { "method": "GET", "path": "/health" },
|
|
21
|
+
// "expect": { "status": 200, "body": "/ok/i" } },
|
|
22
|
+
// { "id": "H2", "desc": "echo returns the message as json",
|
|
23
|
+
// "probe": { "method": "POST", "path": "/echo", "json": { "msg": "hi" } },
|
|
24
|
+
// "expect": { "status": 200, "json": { "msg": "hi" } } } ] }
|
|
25
|
+
//
|
|
26
|
+
// The server is spawned with env.PORT set to a free port the runner picks, so fixtures must read
|
|
27
|
+
// `process.env.PORT`. `status` uses the shared numeric grammar (200 | "!=500" | ">=200" ...).
|
|
28
|
+
//
|
|
29
|
+
// Library use: const { fails, results } = await runContract({ server, criteria })
|
|
30
|
+
// CLI use: node http-oracle.mjs <contract.json> ["override server command"]
|
|
31
|
+
// exit 0 = all PASS, 1 = ≥1 FAIL, 2 = usage/contract error.
|
|
32
|
+
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { createServer } from "node:net";
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { matchNum, toRegExp, formatReport } from "./_shared.mjs";
|
|
37
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
38
|
+
|
|
39
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
|
|
41
|
+
function freePort() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const srv = createServer();
|
|
44
|
+
srv.unref();
|
|
45
|
+
srv.on("error", reject);
|
|
46
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
47
|
+
const { port } = srv.address();
|
|
48
|
+
srv.close(() => resolve(port));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function reachable(base, path, timeoutMs) {
|
|
54
|
+
const deadline = Date.now() + timeoutMs;
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
try {
|
|
57
|
+
const ctrl = AbortSignal.timeout(500);
|
|
58
|
+
await fetch(base + path, { signal: ctrl });
|
|
59
|
+
return true; // any HTTP response (even 404) means the port is serving
|
|
60
|
+
} catch {
|
|
61
|
+
await sleep(100);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function probe(base, p) {
|
|
68
|
+
const init = { method: p.method || "GET", headers: { ...(p.headers || {}) } };
|
|
69
|
+
if (p.json !== undefined) { init.body = JSON.stringify(p.json); init.headers["content-type"] = "application/json"; }
|
|
70
|
+
else if (p.body !== undefined) { init.body = p.body; }
|
|
71
|
+
const res = await fetch(base + (p.path || "/"), { ...init, signal: AbortSignal.timeout(p.timeout_ms || 5000) });
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
return { status: res.status, text };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jsonSubsetMatches(want, gotText) {
|
|
77
|
+
let got;
|
|
78
|
+
try { got = JSON.parse(gotText); } catch { return false; }
|
|
79
|
+
for (const [k, v] of Object.entries(want)) {
|
|
80
|
+
if (JSON.stringify(got?.[k]) !== JSON.stringify(v)) return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function grade(expect, r) {
|
|
86
|
+
const reasons = [];
|
|
87
|
+
let pass = true;
|
|
88
|
+
if (!matchNum(expect.status ?? "*", r.status)) { pass = false; reasons.push(`status ${r.status} ≠ expected ${expect.status}`); }
|
|
89
|
+
if (expect.body !== undefined && !toRegExp(expect.body).test(r.text)) { pass = false; reasons.push(`body does not match ${expect.body}`); }
|
|
90
|
+
if (expect.json !== undefined && !jsonSubsetMatches(expect.json, r.text)) { pass = false; reasons.push(`json does not contain ${JSON.stringify(expect.json)}`); }
|
|
91
|
+
const evidence = `status ${r.status}, body=${JSON.stringify(r.text.trim().slice(0, 120))}` + (reasons.length ? ` [${reasons.join("; ")}]` : "");
|
|
92
|
+
return { pass, evidence };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runContract({ server, criteria }) {
|
|
96
|
+
const results = [];
|
|
97
|
+
let fails = 0;
|
|
98
|
+
const port = await freePort();
|
|
99
|
+
const base = `http://127.0.0.1:${port}`;
|
|
100
|
+
const [bin, ...args] = String(server.cmd).split(/\s+/).filter(Boolean);
|
|
101
|
+
const child = spawn(bin, args, {
|
|
102
|
+
env: { ...process.env, PORT: String(port) },
|
|
103
|
+
cwd: server.cwd,
|
|
104
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const up = await reachable(base, server.ready_path || "/", server.ready_timeout_ms || 4000);
|
|
109
|
+
if (!up) {
|
|
110
|
+
// Absence of evidence = FAIL for every criterion: an unreachable service does not pass.
|
|
111
|
+
for (const c of criteria) {
|
|
112
|
+
results.push({ id: c.id, desc: c.desc, pass: false, evidence: `server never became reachable at ${base}${server.ready_path || "/"} within ${server.ready_timeout_ms || 4000}ms` });
|
|
113
|
+
fails++;
|
|
114
|
+
}
|
|
115
|
+
return { fails, results };
|
|
116
|
+
}
|
|
117
|
+
for (const c of criteria) {
|
|
118
|
+
let g;
|
|
119
|
+
try { g = grade(c.expect || {}, await probe(base, c.probe || {})); }
|
|
120
|
+
catch (e) { g = { pass: false, evidence: `request failed: ${e.message}` }; }
|
|
121
|
+
if (!g.pass) fails++;
|
|
122
|
+
results.push({ id: c.id, desc: c.desc, ...g });
|
|
123
|
+
}
|
|
124
|
+
return { fails, results };
|
|
125
|
+
} finally {
|
|
126
|
+
child.kill("SIGTERM");
|
|
127
|
+
// give it a beat, then hard-kill if still alive
|
|
128
|
+
await sleep(100);
|
|
129
|
+
if (!child.killed) { try { child.kill("SIGKILL"); } catch { /* already gone */ } }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { formatReport };
|
|
134
|
+
|
|
135
|
+
// --- CLI entry ---------------------------------------------------------------
|
|
136
|
+
if (isMain(import.meta.url)) {
|
|
137
|
+
const contractPath = process.argv[2];
|
|
138
|
+
const overrideCmd = process.argv[3];
|
|
139
|
+
if (!contractPath) {
|
|
140
|
+
console.error('usage: node http-oracle.mjs <contract.json> ["override server command"]');
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
let contract;
|
|
144
|
+
try { contract = JSON.parse(readFileSync(contractPath, "utf8")); }
|
|
145
|
+
catch (e) { console.error(`cannot read contract ${contractPath}: ${e.message}`); process.exit(2); }
|
|
146
|
+
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
|
147
|
+
console.error(`contract ${contractPath} has no criteria[]`); process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
const server = { ...(contract.server || {}) };
|
|
150
|
+
if (overrideCmd) server.cmd = overrideCmd;
|
|
151
|
+
if (!server.cmd) { console.error(`contract ${contractPath} has no server.cmd`); process.exit(2); }
|
|
152
|
+
const summary = await runContract({ server, criteria: contract.criteria });
|
|
153
|
+
console.log(formatReport(`http oracle: ${contractPath}`, summary));
|
|
154
|
+
process.exit(summary.fails === 0 ? 0 : 1);
|
|
155
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Oracle registry for the evaluation contract (Stage G of the audit).
|
|
2
|
+
//
|
|
3
|
+
// The single entry point `spec-evaluator` dispatches through when a criterion / Test-Surface row
|
|
4
|
+
// carries an `oracle:` tag. Adding a new oracle type = registering one runner here (the spec's
|
|
5
|
+
// "new oracle types are added by registering a probe runner"). The contract interface is fixed —
|
|
6
|
+
// `{ fails, results }` where each result is `{ id, desc, pass, evidence }` — implementations grow.
|
|
7
|
+
//
|
|
8
|
+
// import { runOracle, ORACLES } from ".../oracles/index.mjs";
|
|
9
|
+
// const { fails, results } = await runOracle("process", { cmd, criteria });
|
|
10
|
+
//
|
|
11
|
+
// `runOracle` is async so a sync runner (process/test/snapshot) and an async one (http) share one
|
|
12
|
+
// call shape; awaiting a non-promise is a no-op.
|
|
13
|
+
|
|
14
|
+
import { runContract as runProcess } from "./process-oracle.mjs";
|
|
15
|
+
import { runContract as runTest } from "./test-oracle.mjs";
|
|
16
|
+
import { runContract as runSnapshot } from "./snapshot-oracle.mjs";
|
|
17
|
+
import { runContract as runHttp } from "./http-oracle.mjs";
|
|
18
|
+
|
|
19
|
+
// The registry is the source of truth for "which oracles exist". Docs (probing.md,
|
|
20
|
+
// test-surface.md, evaluation-contract-spec.md) and structural test #8 are checked against it.
|
|
21
|
+
export const ORACLES = {
|
|
22
|
+
process: runProcess, // CLI / script — spawn, grade exit + stdout
|
|
23
|
+
test: runTest, // library/module — run the suite, grade exit + failing names
|
|
24
|
+
snapshot: runSnapshot, // generator/refactor — diff stdout vs golden
|
|
25
|
+
http: runHttp, // service / API — start server, request, grade status + body
|
|
26
|
+
// ui: handled in-skill by the Playwright CLI loop (not a Node runner) — see probing.md.
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function runOracle(oracle, args) {
|
|
30
|
+
const fn = ORACLES[oracle];
|
|
31
|
+
if (!fn) throw new Error(`unknown oracle "${oracle}" (known: ${Object.keys(ORACLES).join(", ")}, plus "ui" in-skill)`);
|
|
32
|
+
return await fn(args);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The full set the evaluator understands, including the in-skill `ui` path.
|
|
36
|
+
export const ORACLE_NAMES = [...Object.keys(ORACLES), "ui"];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared `process` oracle for the evaluation contract (Stage G of the audit).
|
|
3
|
+
//
|
|
4
|
+
// This is the promoted, parameterized form of the todo-cli prototype: instead of hard-coding
|
|
5
|
+
// criteria, it reads a *declarative contract* (a list of criteria, each with a `probe` and an
|
|
6
|
+
// `expect`) and grades the OBSERVED exit code + stdout/stderr of a spawned process — never the
|
|
7
|
+
// source. It is the runner `spec-evaluator` calls for any criterion tagged `oracle: process`
|
|
8
|
+
// (CLI / script deliverables, where there is no browser to drive).
|
|
9
|
+
//
|
|
10
|
+
// Invariants carried over from the evaluator's design:
|
|
11
|
+
// • Probe behavior, not code presence — every verdict cites runtime output.
|
|
12
|
+
// • Absence of evidence = FAIL — a probe that throws or cannot run FAILs, never silently passes.
|
|
13
|
+
// • One verdict per criterion.
|
|
14
|
+
//
|
|
15
|
+
// Library use (what the evaluator calls):
|
|
16
|
+
// import { runContract } from ".../oracles/process-oracle.mjs";
|
|
17
|
+
// const { fails, results } = runContract({ cmd: "node ./todo.js", criteria });
|
|
18
|
+
//
|
|
19
|
+
// CLI use (smoke / CI):
|
|
20
|
+
// node process-oracle.mjs <contract.json> "<command to run the deliverable>"
|
|
21
|
+
// e.g. node process-oracle.mjs todo.contract.json "node ./todo.js"
|
|
22
|
+
// Exit 0 = all criteria PASS, 1 = at least one FAIL, 2 = usage/contract error.
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
29
|
+
|
|
30
|
+
const CRASH_RE = /at\s+.*:\d+:\d+|Traceback|panic:|unhandled|Segmentation fault/i;
|
|
31
|
+
|
|
32
|
+
// Spawn the deliverable with controlled argv and a controlled store file.
|
|
33
|
+
// `store` semantics: a string seeds $TODO_STORE with that content; null = no store file
|
|
34
|
+
// (exercises first-run/missing-store behavior). The probe never touches the real cwd.
|
|
35
|
+
function spawnProbe(cmd, probe) {
|
|
36
|
+
const [bin, ...baseArgs] = cmd.split(/\s+/).filter(Boolean);
|
|
37
|
+
const argv = probe.argv || [];
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "process-oracle-"));
|
|
39
|
+
const store = join(dir, "store.json");
|
|
40
|
+
const env = { ...process.env };
|
|
41
|
+
if (probe.store !== undefined && probe.store !== null) {
|
|
42
|
+
writeFileSync(store, probe.store);
|
|
43
|
+
env.TODO_STORE = store;
|
|
44
|
+
} else {
|
|
45
|
+
env.TODO_STORE = store; // path that does not exist yet → exercises missing-store path
|
|
46
|
+
}
|
|
47
|
+
const res = spawnSync(bin, [...baseArgs, ...argv], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
env,
|
|
50
|
+
timeout: probe.timeout_ms || 10_000,
|
|
51
|
+
input: probe.stdin,
|
|
52
|
+
});
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
const out = (res.stdout || "") + (res.stderr || "");
|
|
55
|
+
return { code: res.status, out, crashed: CRASH_RE.test(out), spawnError: res.error?.message };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Interpret one `expect` clause against an observed result. Returns true/false.
|
|
59
|
+
// exit: number | "==0" | "!=0" | ">0" | "<N" | ">=N" | "*" (default: any)
|
|
60
|
+
// stdout: "/regex/flags" — must match combined stdout+stderr
|
|
61
|
+
// stderr: "/regex/flags" — alias kept for readability; same combined stream
|
|
62
|
+
// no_crash: true — require no stack-trace/panic signature in output
|
|
63
|
+
function matchExit(spec, code) {
|
|
64
|
+
if (spec === undefined || spec === "*") return true;
|
|
65
|
+
if (typeof spec === "number") return code === spec;
|
|
66
|
+
const m = String(spec).match(/^(==|!=|>=|<=|>|<)\s*(-?\d+)$/);
|
|
67
|
+
if (!m) return false;
|
|
68
|
+
const n = Number(m[2]);
|
|
69
|
+
switch (m[1]) {
|
|
70
|
+
case "==": return code === n;
|
|
71
|
+
case "!=": return code !== n;
|
|
72
|
+
case ">": return code > n;
|
|
73
|
+
case "<": return code < n;
|
|
74
|
+
case ">=": return code >= n;
|
|
75
|
+
case "<=": return code <= n;
|
|
76
|
+
default: return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function toRegExp(spec) {
|
|
80
|
+
const m = String(spec).match(/^\/(.*)\/([a-z]*)$/);
|
|
81
|
+
return m ? new RegExp(m[1], m[2]) : new RegExp(spec);
|
|
82
|
+
}
|
|
83
|
+
function grade(expect, r) {
|
|
84
|
+
const reasons = [];
|
|
85
|
+
let pass = true;
|
|
86
|
+
if (!matchExit(expect.exit, r.code)) { pass = false; reasons.push(`exit ${r.code} ≠ expected ${expect.exit}`); }
|
|
87
|
+
for (const key of ["stdout", "stderr"]) {
|
|
88
|
+
if (expect[key] !== undefined && !toRegExp(expect[key]).test(r.out)) {
|
|
89
|
+
pass = false; reasons.push(`output does not match ${key} ${expect[key]}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (expect.no_crash && r.crashed) { pass = false; reasons.push("crashed (stack trace / panic in output)"); }
|
|
93
|
+
const evidence =
|
|
94
|
+
`exit ${r.code}, crashed=${r.crashed}` +
|
|
95
|
+
(r.spawnError ? `, spawnError=${r.spawnError}` : "") +
|
|
96
|
+
`, out=${JSON.stringify(r.out.trim().slice(0, 100))}` +
|
|
97
|
+
(reasons.length ? ` [${reasons.join("; ")}]` : "");
|
|
98
|
+
return { pass, evidence };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Run every criterion in a contract against `cmd`. Pure of I/O except spawning the deliverable.
|
|
102
|
+
export function runContract({ cmd, criteria }) {
|
|
103
|
+
const results = [];
|
|
104
|
+
let fails = 0;
|
|
105
|
+
for (const c of criteria) {
|
|
106
|
+
let g;
|
|
107
|
+
try {
|
|
108
|
+
const r = spawnProbe(cmd, c.probe || {});
|
|
109
|
+
g = grade(c.expect || {}, r);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
g = { pass: false, evidence: `probe threw: ${e.message}` }; // absence of evidence = FAIL
|
|
112
|
+
}
|
|
113
|
+
if (!g.pass) fails++;
|
|
114
|
+
results.push({ id: c.id, desc: c.desc, ...g });
|
|
115
|
+
}
|
|
116
|
+
return { fails, results };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function formatReport(cmd, { fails, results }) {
|
|
120
|
+
const lines = [`\nEvaluation report — process oracle for: ${cmd}\n${"=".repeat(60)}`];
|
|
121
|
+
for (const r of results) {
|
|
122
|
+
lines.push(`${r.pass ? "PASS" : "FAIL"} ${r.id} ${r.desc}\n ⇒ ${r.evidence}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("=".repeat(60));
|
|
125
|
+
lines.push(fails === 0 ? `✅ all ${results.length} criteria PASS` : `❌ ${fails}/${results.length} criteria FAIL`);
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- CLI entry (only when run directly, not when imported) -------------------
|
|
130
|
+
if (isMain(import.meta.url)) {
|
|
131
|
+
const contractPath = process.argv[2];
|
|
132
|
+
const cmd = process.argv[3];
|
|
133
|
+
if (!contractPath || !cmd) {
|
|
134
|
+
console.error('usage: node process-oracle.mjs <contract.json> "<command to run the deliverable>"');
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
let contract;
|
|
138
|
+
try { contract = JSON.parse(readFileSync(contractPath, "utf8")); }
|
|
139
|
+
catch (e) { console.error(`cannot read contract ${contractPath}: ${e.message}`); process.exit(2); }
|
|
140
|
+
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
|
141
|
+
console.error(`contract ${contractPath} has no criteria[]`); process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
const summary = runContract({ cmd, criteria: contract.criteria });
|
|
144
|
+
console.log(formatReport(cmd, summary));
|
|
145
|
+
process.exit(summary.fails === 0 ? 0 : 1);
|
|
146
|
+
}
|