@juzser/blacksmith 0.1.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/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { SmithError } from './errors.js';
|
|
2
|
+
export class UnsupportedRuntimeError extends SmithError {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The Node major the repo is pinned to. Kept in lockstep with `.nvmrc` and
|
|
6
|
+
* the root package.json `engines` field — `runtime.test.ts` asserts all three
|
|
7
|
+
* agree, so this constant cannot drift away from the pin silently.
|
|
8
|
+
*/
|
|
9
|
+
export const MIN_NODE_MAJOR = 22;
|
|
10
|
+
/**
|
|
11
|
+
* The N-API version the native dependencies are built against.
|
|
12
|
+
* `better-sqlite3` ships one prebuilt binary per platform, compiled for
|
|
13
|
+
* N-API 10 (Node 22+). Node 20 is N-API 9: the binding still *loads*, and
|
|
14
|
+
* then the process dies at `new Database()` with SIGSEGV — no ABI error, no
|
|
15
|
+
* message, both streams empty. That silence is why this guard exists (D-47).
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_NAPI = 10;
|
|
18
|
+
export function currentRuntime() {
|
|
19
|
+
return { version: process.version, napi: process.versions.napi };
|
|
20
|
+
}
|
|
21
|
+
export function nodeMajor(version) {
|
|
22
|
+
// Tolerates a bare major so the same parser reads `.nvmrc` ("22") and
|
|
23
|
+
// `process.version` ("v22.23.1") — one definition of "the pinned major".
|
|
24
|
+
const match = /^v?(\d+)(?:\.|$)/.exec(version.trim());
|
|
25
|
+
if (match === null)
|
|
26
|
+
return null;
|
|
27
|
+
const major = Number(match[1]);
|
|
28
|
+
return Number.isFinite(major) ? major : null;
|
|
29
|
+
}
|
|
30
|
+
const REMEDY = 'Run `nvm use` (the repo pins the major in .nvmrc) or install Node ' +
|
|
31
|
+
`${MIN_NODE_MAJOR}+, then reinstall dependencies.`;
|
|
32
|
+
const WHY = 'The prebuilt better-sqlite3 native binding is built for N-API ' +
|
|
33
|
+
`${MIN_NAPI}; on an older runtime it loads and then segfaults at the first ` +
|
|
34
|
+
'database open, so you get a crash with empty output instead of an error.';
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether this runtime can actually run the orchestrator. Unknown is
|
|
37
|
+
* treated as unsupported: the failure mode being guarded against is a silent
|
|
38
|
+
* crash, so guessing in favour of "probably fine" reintroduces exactly the
|
|
39
|
+
* bug (D-47).
|
|
40
|
+
*/
|
|
41
|
+
export function checkRuntime(info = currentRuntime()) {
|
|
42
|
+
const major = nodeMajor(info.version);
|
|
43
|
+
if (major === null) {
|
|
44
|
+
return {
|
|
45
|
+
supported: false,
|
|
46
|
+
reason: `Could not parse a Node major version out of "${info.version}", so this runtime ` +
|
|
47
|
+
`cannot be verified against the required Node >=${MIN_NODE_MAJOR}. ${WHY} ${REMEDY}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (major < MIN_NODE_MAJOR) {
|
|
51
|
+
const napi = info.napi === undefined ? 'unknown' : info.napi;
|
|
52
|
+
return {
|
|
53
|
+
supported: false,
|
|
54
|
+
reason: `Node ${info.version} (N-API ${napi}) is too old: black-smith requires Node ` +
|
|
55
|
+
`>=${MIN_NODE_MAJOR} (N-API >=${MIN_NAPI}). ${WHY} ${REMEDY}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (info.napi === undefined) {
|
|
59
|
+
return {
|
|
60
|
+
supported: false,
|
|
61
|
+
reason: `Node ${info.version} reports no N-API version, so the native bindings cannot be ` +
|
|
62
|
+
`matched against the required N-API >=${MIN_NAPI}. ${WHY} ${REMEDY}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const napi = Number(info.napi);
|
|
66
|
+
if (!Number.isFinite(napi) || napi < MIN_NAPI) {
|
|
67
|
+
return {
|
|
68
|
+
supported: false,
|
|
69
|
+
reason: `Node ${info.version} provides N-API ${info.napi}, below the required N-API ` +
|
|
70
|
+
`>=${MIN_NAPI}. ${WHY} ${REMEDY}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { supported: true, reason: null };
|
|
74
|
+
}
|
|
75
|
+
/** `checkRuntime`, but fatal — for the CLI entrypoint. */
|
|
76
|
+
export function assertRuntimeSupported(info = currentRuntime()) {
|
|
77
|
+
const result = checkRuntime(info);
|
|
78
|
+
if (result.supported)
|
|
79
|
+
return;
|
|
80
|
+
throw new UnsupportedRuntimeError('unsupported-runtime', result.reason ?? 'unsupported runtime', {
|
|
81
|
+
version: info.version,
|
|
82
|
+
napi: info.napi ?? null,
|
|
83
|
+
minNodeMajor: MIN_NODE_MAJOR,
|
|
84
|
+
minNapi: MIN_NAPI,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { canEscalate, isFileScoped } from './severity.js';
|
|
2
|
+
export function lessonReach(lessons) {
|
|
3
|
+
const categories = new Set();
|
|
4
|
+
let escalating = 0;
|
|
5
|
+
let withoutCategory = 0;
|
|
6
|
+
let nonFileScoped = 0;
|
|
7
|
+
for (const lesson of lessons) {
|
|
8
|
+
if (canEscalate(lesson)) {
|
|
9
|
+
escalating += 1;
|
|
10
|
+
categories.add(lesson.category);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
// Both reasons are recorded for an entry that has both, because each is a
|
|
14
|
+
// different edit to make the entry live.
|
|
15
|
+
if (lesson.category === '')
|
|
16
|
+
withoutCategory += 1;
|
|
17
|
+
if (!isFileScoped(lesson))
|
|
18
|
+
nonFileScoped += 1;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
total: lessons.length,
|
|
22
|
+
escalating,
|
|
23
|
+
withoutCategory,
|
|
24
|
+
nonFileScoped,
|
|
25
|
+
categoriesCovered: [...categories].sort(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function emptyDay() {
|
|
29
|
+
return {
|
|
30
|
+
intakes: 0,
|
|
31
|
+
emptyIntakes: 0,
|
|
32
|
+
decisions: 0,
|
|
33
|
+
sameMistake: 0,
|
|
34
|
+
quorumSameMistake: 0,
|
|
35
|
+
uninstrumentedDecisions: 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function decide(windows, provenRises, reach, holes) {
|
|
39
|
+
// Order matters, and it is the asymmetry in code. A repeat the log already
|
|
40
|
+
// recorded proves the instrument fired, so a rise between measured windows
|
|
41
|
+
// survives any hole elsewhere in the record rather than being downgraded to
|
|
42
|
+
// "we cannot tell" by an unrelated unknown. Only when nothing has risen does
|
|
43
|
+
// the quality of the instrument decide between "on target" and "unreadable".
|
|
44
|
+
//
|
|
45
|
+
// "Elsewhere" is the load-bearing word, and it used to be unchecked: a rise
|
|
46
|
+
// has two operands, and a hole in the earlier one is not elsewhere. Only a
|
|
47
|
+
// rise the baseline could have contradicted is a fact about the work (D-174).
|
|
48
|
+
if (provenRises.length > 0)
|
|
49
|
+
return 'off-target';
|
|
50
|
+
if (reach.escalating === 0)
|
|
51
|
+
return 'unverifiable';
|
|
52
|
+
if (holes)
|
|
53
|
+
return 'unverifiable';
|
|
54
|
+
if (windows.length === 0)
|
|
55
|
+
return 'unverifiable';
|
|
56
|
+
if (windows.length === 1)
|
|
57
|
+
return 'insufficient-history';
|
|
58
|
+
return 'on-target';
|
|
59
|
+
}
|
|
60
|
+
function pct(rate) {
|
|
61
|
+
return `${(rate * 100).toFixed(1)}%`;
|
|
62
|
+
}
|
|
63
|
+
function describe(report) {
|
|
64
|
+
const head = `${report.totalSameMistake} same-mistake of ${report.totalDecisions} decision(s) across ` +
|
|
65
|
+
`${report.windows.length} measured window(s)` +
|
|
66
|
+
(report.windows.length > 0
|
|
67
|
+
? ` — ${report.windows.map((w) => `${w.day} ${pct(w.rate)}`).join(', ')}.`
|
|
68
|
+
: '.');
|
|
69
|
+
const holes = [];
|
|
70
|
+
if (report.reach.escalating === 0) {
|
|
71
|
+
holes.push(`none of the ${report.reach.total} compiled lesson(s) can escalate anything ` +
|
|
72
|
+
`(${report.reach.withoutCategory} name no finding_category, ` +
|
|
73
|
+
`${report.reach.nonFileScoped} are not file-scoped), so a zero here is a property ` +
|
|
74
|
+
`of the corpus, not of the work`);
|
|
75
|
+
}
|
|
76
|
+
if (report.intakesWithoutInstrumentRecord > 0) {
|
|
77
|
+
holes.push(`${report.intakesWithoutInstrumentRecord} intake(s) record no lessons_escalating count, ` +
|
|
78
|
+
`so the log does not say whether the gate held any lesson at all`);
|
|
79
|
+
}
|
|
80
|
+
if (report.blindIntakes > 0) {
|
|
81
|
+
holes.push(`${report.blindIntakes} intake(s) ran with no escalating lesson loaded and could not ` +
|
|
82
|
+
`have found a repeat`);
|
|
83
|
+
}
|
|
84
|
+
if (report.unprovenRises.length > 0) {
|
|
85
|
+
holes.push(`the rate rose on ${report.unprovenRises.join(', ')} from a window whose decisions came ` +
|
|
86
|
+
`from a gate that could not have recorded a repeat, so the rise measures the corpus ` +
|
|
87
|
+
`rather than the work`);
|
|
88
|
+
}
|
|
89
|
+
if (report.traceDisagreements.length > 0) {
|
|
90
|
+
holes.push(`the quorum trace saw a same mistake the severity trace did not on ` +
|
|
91
|
+
`${report.traceDisagreements.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
if (report.silentDays.length > 0) {
|
|
94
|
+
holes.push(`${report.silentDays.length} day(s) recorded intakes but no decision ` +
|
|
95
|
+
`(${report.silentDays.join(', ')}) and are excluded rather than counted as 0%`);
|
|
96
|
+
}
|
|
97
|
+
const holeText = holes.length > 0 ? ` Holes: ${holes.join('; ')}.` : '';
|
|
98
|
+
switch (report.status) {
|
|
99
|
+
case 'off-target':
|
|
100
|
+
return (`${head} The rate rose between measured windows, against §9.7's monotonically ` +
|
|
101
|
+
`decreasing target. The window it rose from recorded every decision under a gate ` +
|
|
102
|
+
`equipped to escalate, so this stands whatever else the record is missing.${holeText}`);
|
|
103
|
+
case 'insufficient-history':
|
|
104
|
+
return (`${head} One measured window is a reading, not a trend; §9.7's target is about ` +
|
|
105
|
+
`direction and needs a second.${holeText}`);
|
|
106
|
+
case 'unverifiable':
|
|
107
|
+
return report.unprovenRises.length > 0
|
|
108
|
+
? `${head} The rate rose, but not from a window that could have read anything else: ` +
|
|
109
|
+
`the baseline's zeros belong to the corpus, not to the work.${holeText}`
|
|
110
|
+
: `${head} Nothing here shows the rate rising, but neither can it show the rate ` +
|
|
111
|
+
`falling: the number could not have been other than what it is.${holeText}`;
|
|
112
|
+
default:
|
|
113
|
+
return (`${head} Non-increasing across every measured window, against a corpus that can ` +
|
|
114
|
+
`escalate ${report.reach.escalating} lesson(s) in ` +
|
|
115
|
+
`${report.reach.categoriesCovered.join(', ')}.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The same-mistake rate read as §9.7's KPI: is it going down, and is the number
|
|
120
|
+
* one that could have gone up.
|
|
121
|
+
*
|
|
122
|
+
* Read-only over the log. Fails the report on anything but `on-target` — an
|
|
123
|
+
* unreadable instrument is not a clean one, for the same reason
|
|
124
|
+
* dispatchAudit.ts emits a synthetic check for an empty pair set.
|
|
125
|
+
*/
|
|
126
|
+
export function checkSameMistakeKpi(events, lessons, options) {
|
|
127
|
+
const reach = lessonReach(lessons);
|
|
128
|
+
const byDay = new Map();
|
|
129
|
+
const dayFor = (day) => {
|
|
130
|
+
let acc = byDay.get(day);
|
|
131
|
+
if (acc === undefined) {
|
|
132
|
+
acc = emptyDay();
|
|
133
|
+
byDay.set(day, acc);
|
|
134
|
+
}
|
|
135
|
+
return acc;
|
|
136
|
+
};
|
|
137
|
+
let intakesWithoutInstrumentRecord = 0;
|
|
138
|
+
let blindIntakes = 0;
|
|
139
|
+
for (const { record } of events) {
|
|
140
|
+
const day = record.ts.slice(0, 10);
|
|
141
|
+
if (record.event_type === 'quorum-decision') {
|
|
142
|
+
if (record.payload.trigger_reason === 'same-mistake')
|
|
143
|
+
dayFor(day).quorumSameMistake += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (record.event_type !== 'severity-decisions')
|
|
147
|
+
continue;
|
|
148
|
+
const payload = record.payload;
|
|
149
|
+
const acc = dayFor(day);
|
|
150
|
+
acc.intakes += 1;
|
|
151
|
+
const loaded = payload.lessons_escalating;
|
|
152
|
+
if (typeof loaded !== 'number')
|
|
153
|
+
intakesWithoutInstrumentRecord += 1;
|
|
154
|
+
else if (loaded === 0)
|
|
155
|
+
blindIntakes += 1;
|
|
156
|
+
const decisions = payload.decisions ?? [];
|
|
157
|
+
if (decisions.length === 0)
|
|
158
|
+
acc.emptyIntakes += 1;
|
|
159
|
+
let fired = false;
|
|
160
|
+
for (const decision of decisions) {
|
|
161
|
+
acc.decisions += 1;
|
|
162
|
+
if (decision.same_mistake) {
|
|
163
|
+
acc.sameMistake += 1;
|
|
164
|
+
fired = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// What this intake's decisions are worth as a baseline. A recorded repeat
|
|
168
|
+
// is its own attestation — `same_mistake: true` cannot be written by a gate
|
|
169
|
+
// holding nothing to escalate against — so an intake that fired needs no
|
|
170
|
+
// separate count, which is why an event predating `lessons_escalating` can
|
|
171
|
+
// still anchor a rise. An intake that did not fire needs the count, and
|
|
172
|
+
// without it its zeros are the corpus's zeros, not the work's.
|
|
173
|
+
const shownEquipped = fired || (typeof loaded === 'number' && loaded > 0);
|
|
174
|
+
if (!shownEquipped)
|
|
175
|
+
acc.uninstrumentedDecisions += decisions.length;
|
|
176
|
+
}
|
|
177
|
+
const days = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
178
|
+
const windows = days
|
|
179
|
+
.filter(([, acc]) => acc.decisions > 0)
|
|
180
|
+
.map(([day, acc]) => ({
|
|
181
|
+
day,
|
|
182
|
+
intakes: acc.intakes,
|
|
183
|
+
emptyIntakes: acc.emptyIntakes,
|
|
184
|
+
decisions: acc.decisions,
|
|
185
|
+
sameMistake: acc.sameMistake,
|
|
186
|
+
rate: acc.sameMistake / acc.decisions,
|
|
187
|
+
quorumSameMistake: acc.quorumSameMistake,
|
|
188
|
+
uninstrumentedDecisions: acc.uninstrumentedDecisions,
|
|
189
|
+
}));
|
|
190
|
+
// A day with intakes but no decision at all. Not a rate of zero — there is no
|
|
191
|
+
// denominator to divide by, and inventing one is the whole bug.
|
|
192
|
+
const silentDays = days
|
|
193
|
+
.filter(([, acc]) => acc.decisions === 0 && acc.intakes > 0)
|
|
194
|
+
.map(([day]) => day);
|
|
195
|
+
// The quorum trace is written by a different code path (gate.ts:487) and only
|
|
196
|
+
// when an external provider is enabled, so it is normally silent. What it can
|
|
197
|
+
// never be is *more* clean than the severity trace: a same-mistake quorum on
|
|
198
|
+
// a day the decisions call clean means one of the two traces is wrong.
|
|
199
|
+
const traceDisagreements = days
|
|
200
|
+
.filter(([, acc]) => acc.quorumSameMistake > acc.sameMistake)
|
|
201
|
+
.map(([day]) => day);
|
|
202
|
+
// Split every rise by whether the window it rose FROM could have read
|
|
203
|
+
// anything but low. A baseline holding decisions no gate was equipped to
|
|
204
|
+
// escalate is one of the three zeros in this file's header, and a difference
|
|
205
|
+
// taken against it measures the corpus rather than the factory's conduct —
|
|
206
|
+
// the substitution `reach` already refuses for the level, refused here for
|
|
207
|
+
// the trend.
|
|
208
|
+
const provenRises = [];
|
|
209
|
+
const unprovenRises = [];
|
|
210
|
+
for (let i = 1; i < windows.length; i += 1) {
|
|
211
|
+
const window = windows[i];
|
|
212
|
+
const previous = windows[i - 1];
|
|
213
|
+
if (window.rate <= previous.rate)
|
|
214
|
+
continue;
|
|
215
|
+
if (previous.uninstrumentedDecisions === 0)
|
|
216
|
+
provenRises.push(window.day);
|
|
217
|
+
else
|
|
218
|
+
unprovenRises.push(window.day);
|
|
219
|
+
}
|
|
220
|
+
const monotonic = provenRises.length === 0 && unprovenRises.length === 0;
|
|
221
|
+
const totalDecisions = windows.reduce((sum, w) => sum + w.decisions, 0);
|
|
222
|
+
const totalSameMistake = windows.reduce((sum, w) => sum + w.sameMistake, 0);
|
|
223
|
+
const holes = intakesWithoutInstrumentRecord > 0 ||
|
|
224
|
+
blindIntakes > 0 ||
|
|
225
|
+
unprovenRises.length > 0 ||
|
|
226
|
+
traceDisagreements.length > 0;
|
|
227
|
+
const partial = {
|
|
228
|
+
sessionId: options.sessionId,
|
|
229
|
+
reach,
|
|
230
|
+
windows,
|
|
231
|
+
silentDays,
|
|
232
|
+
totalDecisions,
|
|
233
|
+
totalSameMistake,
|
|
234
|
+
intakesWithoutInstrumentRecord,
|
|
235
|
+
blindIntakes,
|
|
236
|
+
traceDisagreements,
|
|
237
|
+
monotonic,
|
|
238
|
+
provenRises,
|
|
239
|
+
unprovenRises,
|
|
240
|
+
status: decide(windows, provenRises, reach, holes),
|
|
241
|
+
};
|
|
242
|
+
return { ...partial, detail: describe(partial), ok: partial.status === 'on-target' };
|
|
243
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { SANDBOX_LEASE_DIR } from './paths.js';
|
|
6
|
+
import { sandboxRoles } from './policy.js';
|
|
7
|
+
/**
|
|
8
|
+
* The judge sandbox: what turns "judges are read-only" from a sentence an
|
|
9
|
+
* agent reads into a rule the machine applies while the judge is running.
|
|
10
|
+
*
|
|
11
|
+
* Before this module the guarantee was `immutability.ts` — fingerprint the
|
|
12
|
+
* worktree, run the judge, compare after. That check is real and it stays
|
|
13
|
+
* (see `checkWorktreeImmutable`), but it is a *detector*, not a guard: by the
|
|
14
|
+
* time it fires the write has happened, the judge's verdict is already
|
|
15
|
+
* untrustworthy, and the task has to go round again. Worse, it can only see
|
|
16
|
+
* what git can see — a judge that curls an endpoint, installs a package, or
|
|
17
|
+
* writes outside the worktree leaves no fingerprint at all, and the six judge
|
|
18
|
+
* roles hold `Bash` in fact even though they are read-only in prose
|
|
19
|
+
* (agent-interviews.md N-10).
|
|
20
|
+
*
|
|
21
|
+
* A lease closes that gap at the only moment where refusing is free: before
|
|
22
|
+
* the command runs. The orchestrator opens one against the worktree it is
|
|
23
|
+
* about to hand a judge; `smith policy hook` resolves the lease from the
|
|
24
|
+
* PreToolUse payload's `cwd` and, while one is open, evaluates the judge
|
|
25
|
+
* rules in guardrails.yml on top of the six that always apply. Closing the
|
|
26
|
+
* lease is the orchestrator's move, not the judge's — `judge-sandbox-escape`
|
|
27
|
+
* exists precisely because `smith sandbox close` is itself a Bash command.
|
|
28
|
+
*
|
|
29
|
+
* Why a file rather than an environment variable: the hook does not run in
|
|
30
|
+
* the judge's process, or even necessarily on the same side of a subagent
|
|
31
|
+
* boundary. `cwd` is the one thing the PreToolUse payload is guaranteed to
|
|
32
|
+
* carry that identifies *where* the command will run, and a worktree path is
|
|
33
|
+
* what a judge dispatch is scoped to. So the lease is keyed by worktree and
|
|
34
|
+
* kept in the main clone, which is also the answer to "could the judge just
|
|
35
|
+
* delete it": that path is outside its worktree, and reaching it is a write
|
|
36
|
+
* the same rule set denies.
|
|
37
|
+
*
|
|
38
|
+
* What this deliberately is NOT: a container, a seccomp filter, or a
|
|
39
|
+
* read-only mount. Those are the real thing and this is not them — a judge
|
|
40
|
+
* that never issues a Bash tool call cannot be watched here at all, and a
|
|
41
|
+
* sufficiently creative command line will get past a matcher, exactly as
|
|
42
|
+
* documented for the six branch/deploy rules. This raises the floor from
|
|
43
|
+
* "detected afterwards" to "refused up front" on every path a judge actually
|
|
44
|
+
* has, and `checkWorktreeImmutable` still runs behind it. Both are stated
|
|
45
|
+
* plainly in docs/standards/guardrails.md rather than sold as a sandbox.
|
|
46
|
+
*/
|
|
47
|
+
export class SandboxError extends SmithError {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One file per lease, named by a hash of the worktree path.
|
|
51
|
+
*
|
|
52
|
+
* Per-file rather than one shared JSON document because judges for different
|
|
53
|
+
* tasks run at the same time by design: two waves in flight means two opens
|
|
54
|
+
* and two closes racing, and a read-modify-write of a shared file loses one
|
|
55
|
+
* of them. A hash rather than the path itself because a worktree path is not
|
|
56
|
+
* a legal filename on any platform this runs on.
|
|
57
|
+
*/
|
|
58
|
+
export function leasePathFor(worktreeDir, dir = SANDBOX_LEASE_DIR) {
|
|
59
|
+
const key = createHash('sha256').update(path.resolve(worktreeDir)).digest('hex').slice(0, 32);
|
|
60
|
+
return path.join(dir, `${key}.json`);
|
|
61
|
+
}
|
|
62
|
+
function assertLease(value, file) {
|
|
63
|
+
const lease = value;
|
|
64
|
+
if (lease === null ||
|
|
65
|
+
typeof lease !== 'object' ||
|
|
66
|
+
typeof lease.worktreeDir !== 'string' ||
|
|
67
|
+
typeof lease.role !== 'string' ||
|
|
68
|
+
typeof lease.taskId !== 'string' ||
|
|
69
|
+
typeof lease.sessionId !== 'string' ||
|
|
70
|
+
typeof lease.openedAt !== 'string') {
|
|
71
|
+
throw new SandboxError('sandbox.invalid-lease', `${file} is not a sandbox lease ({ worktreeDir, role, taskId, sessionId, openedAt }).`, { file });
|
|
72
|
+
}
|
|
73
|
+
return lease;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open a lease over `worktreeDir`.
|
|
77
|
+
*
|
|
78
|
+
* Refuses when one is already open, and that refusal is the point rather than
|
|
79
|
+
* bookkeeping tidiness: two judges sharing a worktree means the second one's
|
|
80
|
+
* writes are attributed to the first one's lease and neither verdict can be
|
|
81
|
+
* trusted. The orchestrator's answer is a second worktree, not a second
|
|
82
|
+
* judge in the same tree.
|
|
83
|
+
*
|
|
84
|
+
* Refuses an unknown `role` for a related reason. The role is not a label on
|
|
85
|
+
* the lease, it *selects* which rule set policy.ts applies — the judge rules
|
|
86
|
+
* or a role write scope — so a typo used to be a silent downgrade to whatever
|
|
87
|
+
* the fallback happened to be. Guessing is not an option here and neither is
|
|
88
|
+
* a warning: the lease is written once and read by a hook that has no way to
|
|
89
|
+
* ask what was meant.
|
|
90
|
+
*/
|
|
91
|
+
export function openSandbox(input, dir = SANDBOX_LEASE_DIR) {
|
|
92
|
+
const roles = sandboxRoles();
|
|
93
|
+
if (!roles.includes(input.role)) {
|
|
94
|
+
throw new SandboxError('sandbox.unknown-role', `"${input.role}" is not a role guardrails.yml has rules for. Open the lease as one of: ${roles.join(', ')}.`, { role: input.role, roles });
|
|
95
|
+
}
|
|
96
|
+
const worktreeDir = path.resolve(input.worktreeDir);
|
|
97
|
+
const file = leasePathFor(worktreeDir, dir);
|
|
98
|
+
const existing = readSandbox(worktreeDir, dir);
|
|
99
|
+
if (existing) {
|
|
100
|
+
throw new SandboxError('sandbox.already-open', `A ${existing.role} sandbox is already open on ${worktreeDir} (task ${existing.taskId}, since ${existing.openedAt}). Close it, or give this judge its own worktree.`, { worktreeDir, role: existing.role, taskId: existing.taskId });
|
|
101
|
+
}
|
|
102
|
+
const lease = {
|
|
103
|
+
worktreeDir,
|
|
104
|
+
role: input.role,
|
|
105
|
+
taskId: input.taskId,
|
|
106
|
+
sessionId: input.sessionId,
|
|
107
|
+
openedAt: input.openedAt,
|
|
108
|
+
};
|
|
109
|
+
mkdirSync(dir, { recursive: true });
|
|
110
|
+
writeFileSync(file, `${JSON.stringify(lease, null, 2)}\n`, 'utf8');
|
|
111
|
+
return lease;
|
|
112
|
+
}
|
|
113
|
+
/** The lease on exactly this worktree, or null. Does not walk up — see `activeSandboxFor`. */
|
|
114
|
+
export function readSandbox(worktreeDir, dir = SANDBOX_LEASE_DIR) {
|
|
115
|
+
const file = leasePathFor(worktreeDir, dir);
|
|
116
|
+
if (!existsSync(file))
|
|
117
|
+
return null;
|
|
118
|
+
return assertLease(JSON.parse(readFileSync(file, 'utf8')), file);
|
|
119
|
+
}
|
|
120
|
+
/** Close the lease on `worktreeDir`. Returns false when there was none — closing twice is not an error. */
|
|
121
|
+
export function closeSandbox(worktreeDir, dir = SANDBOX_LEASE_DIR) {
|
|
122
|
+
const file = leasePathFor(worktreeDir, dir);
|
|
123
|
+
if (!existsSync(file))
|
|
124
|
+
return false;
|
|
125
|
+
rmSync(file);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/** Every open lease, for `smith sandbox status` and for the resolver below. */
|
|
129
|
+
export function listSandboxes(dir = SANDBOX_LEASE_DIR) {
|
|
130
|
+
if (!existsSync(dir))
|
|
131
|
+
return [];
|
|
132
|
+
const leases = [];
|
|
133
|
+
for (const name of readdirSync(dir).sort()) {
|
|
134
|
+
if (!name.endsWith('.json'))
|
|
135
|
+
continue;
|
|
136
|
+
const file = path.join(dir, name);
|
|
137
|
+
leases.push(assertLease(JSON.parse(readFileSync(file, 'utf8')), file));
|
|
138
|
+
}
|
|
139
|
+
return leases;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The lease covering a command about to run in `cwd`, or null.
|
|
143
|
+
*
|
|
144
|
+
* Matches on containment, not equality: a judge running `pnpm -C ui test`
|
|
145
|
+
* from `<worktree>/ui` is inside the same sandbox as one standing at the
|
|
146
|
+
* root, and a rule that only fired at the exact worktree path would be a
|
|
147
|
+
* `cd` away from being off. When leases nest (they should not, but a
|
|
148
|
+
* worktree inside a worktree is not forbidden by anything) the longest
|
|
149
|
+
* matching path wins, which is the innermost and therefore the most specific
|
|
150
|
+
* claim on the command.
|
|
151
|
+
*/
|
|
152
|
+
export function activeSandboxFor(cwd, dir = SANDBOX_LEASE_DIR) {
|
|
153
|
+
const here = path.resolve(cwd);
|
|
154
|
+
let best = null;
|
|
155
|
+
for (const lease of listSandboxes(dir)) {
|
|
156
|
+
const relative = path.relative(lease.worktreeDir, here);
|
|
157
|
+
const contained = relative === '' ||
|
|
158
|
+
(!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
159
|
+
if (!contained)
|
|
160
|
+
continue;
|
|
161
|
+
if (best === null || lease.worktreeDir.length > best.worktreeDir.length)
|
|
162
|
+
best = lease;
|
|
163
|
+
}
|
|
164
|
+
return best;
|
|
165
|
+
}
|