@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.
Files changed (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Running every test on every merge is honest and slow. This narrows it.
3
+ *
4
+ * The serial merge queue rebases each task onto the integration branch and
5
+ * runs the operator's whole test command before merging. That is correct and
6
+ * it is quadratic: N tasks, each paying for the full cumulative suite. The
7
+ * graph `symbols.ts` already builds answers a cheaper question — which tests
8
+ * can even reach this change — by walking `dependents` (imported file -> its
9
+ * importers) out from the changed files until the frontier stops growing.
10
+ *
11
+ * The whole value of a test gate is that it fails when the code is wrong, so
12
+ * every ambiguity here resolves to running everything:
13
+ *
14
+ * - a changed file the scanner cannot read, or has never seen, or that is not
15
+ * source at all (a `.yml`, a lockfile, a fixture) can affect anything;
16
+ * - a change no test reaches is more likely a stale graph than a change with
17
+ * no coverage, and guessing wrong means merging untested code;
18
+ * - a module with a computed `import()` or an import that never resolved is
19
+ * treated as reached by every change, because the scanner cannot prove it
20
+ * is not. That keeps one opaque module from collapsing the whole selection
21
+ * to `full`, while still never dropping the test that module lives in.
22
+ *
23
+ * Two things this deliberately does NOT do. It never narrows a typecheck —
24
+ * `tsc -p` is a whole-program question and a subset of files is a different,
25
+ * weaker question. And it never invents the command: the operator supplies a
26
+ * template with a `{files}` placeholder, and a template without one is an
27
+ * error rather than a silent full run reported as a selective one.
28
+ */
29
+ import { extname } from 'node:path';
30
+ import { SmithError } from './errors.js';
31
+ import { DEFAULT_SOURCE_EXTENSIONS } from './symbols.js';
32
+ export class TestSelectError extends SmithError {
33
+ }
34
+ /** `foo.test.ts`, `foo.spec.tsx`, `foo.test.mjs` — the conventional shapes. */
35
+ const DEFAULT_TEST_PATTERN = /(^|\/)[^/]+\.(test|spec)\.[cm]?[jt]sx?$/;
36
+ function isTest(file, options) {
37
+ return options.isTestFile ? options.isTestFile(file) : DEFAULT_TEST_PATTERN.test(file);
38
+ }
39
+ /** Modules the scanner admits it cannot fully see through. */
40
+ function alwaysReached(graph) {
41
+ const opaque = new Set();
42
+ for (const [file, facts] of graph.modules) {
43
+ if (facts.opaqueImports.length > 0)
44
+ opaque.add(file);
45
+ }
46
+ for (const { from } of graph.unresolved)
47
+ opaque.add(from);
48
+ return opaque;
49
+ }
50
+ /**
51
+ * Which tests can reach `changedFiles`, or `full` with the reason it gave up.
52
+ * Pure: the graph and the change set come from the caller.
53
+ */
54
+ export function selectTests(graph, changedFiles, options = {}) {
55
+ const allTests = [...graph.modules.keys()].filter((file) => isTest(file, options)).sort();
56
+ const full = (reason) => ({
57
+ status: 'full',
58
+ tests: allTests,
59
+ allTests,
60
+ reasons: [reason],
61
+ });
62
+ if (allTests.length === 0)
63
+ return full('the symbol graph knows no test files');
64
+ if (changedFiles.length === 0)
65
+ return full('no changed files were reported');
66
+ const sourceExtensions = new Set(DEFAULT_SOURCE_EXTENSIONS);
67
+ const unanalyzed = new Set(graph.unanalyzed);
68
+ for (const file of changedFiles) {
69
+ if (!sourceExtensions.has(extname(file))) {
70
+ return full(`${file} is not an analyzable source file, so it could affect any test`);
71
+ }
72
+ if (unanalyzed.has(file)) {
73
+ return full(`${file} is a file the scanner refused to read`);
74
+ }
75
+ if (!graph.modules.has(file)) {
76
+ return full(`${file} is not in the symbol graph`);
77
+ }
78
+ }
79
+ const reasons = [];
80
+ const opaque = alwaysReached(graph);
81
+ if (opaque.size > 0) {
82
+ reasons.push(`${opaque.size} module(s) with a computed import or an unresolved specifier are treated as reached`);
83
+ }
84
+ const reached = new Set();
85
+ const frontier = [...changedFiles, ...opaque];
86
+ while (frontier.length > 0) {
87
+ const file = frontier.pop();
88
+ if (file === undefined || reached.has(file))
89
+ continue;
90
+ reached.add(file);
91
+ for (const edge of graph.dependents.get(file) ?? []) {
92
+ if (!reached.has(edge.from))
93
+ frontier.push(edge.from);
94
+ }
95
+ }
96
+ const tests = [...reached].filter((file) => isTest(file, options)).sort();
97
+ if (tests.length === 0) {
98
+ return full('no test reaches the change, which is more likely a stale graph than a gap');
99
+ }
100
+ if (tests.length >= allTests.length) {
101
+ return full('every test is reachable from the change');
102
+ }
103
+ reasons.unshift(`${tests.length} of ${allTests.length} tests can reach the change`);
104
+ return { status: 'selected', tests, allTests, reasons };
105
+ }
106
+ /** POSIX single-quoting: the only string a shell will not reinterpret. */
107
+ function shellQuote(value) {
108
+ return `'${value.replaceAll("'", String.raw `'\''`)}'`;
109
+ }
110
+ const FILES_PLACEHOLDER = '{files}';
111
+ /**
112
+ * Reject a template with no placeholder. Called once at the CLI boundary so an
113
+ * unusable template fails before any git runs, and again at render time: that
114
+ * would run the full suite while the report said `selected`, which is the one
115
+ * failure mode this module must not have.
116
+ */
117
+ export function assertSelectableTestCmd(template) {
118
+ if (!template.includes(FILES_PLACEHOLDER)) {
119
+ throw new TestSelectError('test-select.no-files-placeholder', `select test command has no ${FILES_PLACEHOLDER} placeholder: ${template}`, { template });
120
+ }
121
+ }
122
+ /** Substitute a selection into the operator's template. */
123
+ export function renderSelectedTestCmd(template, tests) {
124
+ assertSelectableTestCmd(template);
125
+ if (tests.length === 0) {
126
+ throw new TestSelectError('test-select.empty-selection', 'refusing to render a test command for an empty selection', { template });
127
+ }
128
+ return template.replaceAll(FILES_PLACEHOLDER, tests.map(shellQuote).join(' '));
129
+ }
@@ -0,0 +1,264 @@
1
+ import { readDispatchRecords } from './dispatchAudit.js';
2
+ import { eventTaskId } from './events.js';
3
+ import { taskIdsMatch } from './taskId.js';
4
+ /**
5
+ * The event this audit is anchored to. `testgate-result` is emitted once per
6
+ * gate run (gate.ts), and it is the moment the question becomes answerable:
7
+ * before it, a task whose tester has not been dispatched yet is merely early.
8
+ */
9
+ const TEST_GATE_EVENT = 'testgate-result';
10
+ /**
11
+ * How a dispatched agent's turn is recorded as over, and which payload field
12
+ * names the role that ended it. Falling back to the record's `actor` is not a
13
+ * nicety: the older half of the log names the role there and nowhere else,
14
+ * and reading only the payload would report those runs as testers that never
15
+ * reported — an `unverifiable` about evidence the log is holding.
16
+ */
17
+ const TERMINAL_ROLE_EVENTS = {
18
+ 'task-result-recorded': 'agent',
19
+ 'error-logged': 'agent',
20
+ 'judge-reported': 'agent_role',
21
+ 'judge-verdict': 'agent',
22
+ };
23
+ function payloadString(payload, key) {
24
+ const value = payload[key];
25
+ return typeof value === 'string' && value.length > 0 ? value : null;
26
+ }
27
+ /**
28
+ * Whether two task scopes can answer for each other. dispatchAudit.ts's rule,
29
+ * for its reasons: a record with no task id answers for anything (pre-task-id
30
+ * history is on disk and unrewritable), and "differ" is taskIdsMatch's
31
+ * question, not `===`'s, because the log writes one task both qualified and
32
+ * bare (D-181).
33
+ */
34
+ function scopesAgree(a, b) {
35
+ return a === null || b === null || taskIdsMatch(a, b);
36
+ }
37
+ /**
38
+ * Flatten a session's terminal events, in log order.
39
+ *
40
+ * "Terminal" is TERMINAL_ROLE_EVENTS: the events that say an agent's turn
41
+ * ended. A dispatch that never reaches one of these is a dispatch whose
42
+ * outcome the log does not hold, and this audit says so rather than assuming
43
+ * the tester ran to completion.
44
+ */
45
+ export function readTerminalRoleRecords(events) {
46
+ const records = [];
47
+ for (const stored of events) {
48
+ const record = stored.record;
49
+ const field = TERMINAL_ROLE_EVENTS[record.event_type];
50
+ if (!field)
51
+ continue;
52
+ const payload = (record.payload ?? {});
53
+ const role = payloadString(payload, field) ?? record.actor;
54
+ if (!role)
55
+ continue;
56
+ records.push({
57
+ eventId: stored.event_id,
58
+ ts: record.ts ?? '',
59
+ taskId: eventTaskId(record),
60
+ role,
61
+ eventType: record.event_type,
62
+ });
63
+ }
64
+ return records;
65
+ }
66
+ function readGateRecords(events) {
67
+ const records = [];
68
+ for (const stored of events) {
69
+ const record = stored.record;
70
+ if (record.event_type !== TEST_GATE_EVENT)
71
+ continue;
72
+ const payload = (record.payload ?? {});
73
+ records.push({
74
+ eventId: stored.event_id,
75
+ ts: record.ts ?? '',
76
+ taskId: eventTaskId(record),
77
+ results: payload.results,
78
+ });
79
+ }
80
+ return records;
81
+ }
82
+ /**
83
+ * The latest dispatch of `role` at or before `ts`, on a task scope that
84
+ * agrees.
85
+ *
86
+ * "At or before" is dispatchAudit.ts's argument on this axis too: a tester
87
+ * dispatched after the gate ran cannot have written what the gate graded, and
88
+ * taking the newest dispatch in the session would let exactly that launder a
89
+ * violation into a pass. The task axis is the same argument on the other
90
+ * coordinate — evidence about one task vouches for nothing about another
91
+ * (D-172).
92
+ */
93
+ function latestDispatchBefore(dispatches, role, ts, scope) {
94
+ let best = null;
95
+ for (const dispatch of dispatches) {
96
+ if (dispatch.role !== role)
97
+ continue;
98
+ if (dispatch.ts > ts)
99
+ continue;
100
+ if (!scopesAgree(dispatch.taskId, scope))
101
+ continue;
102
+ if (!best || dispatch.ts >= best.ts)
103
+ best = dispatch;
104
+ }
105
+ return best;
106
+ }
107
+ function noPairsCheck() {
108
+ return {
109
+ taskId: null,
110
+ worker: '*',
111
+ auditor: '*',
112
+ status: 'unverifiable',
113
+ gateEventId: null,
114
+ checksRun: 0,
115
+ workerEventId: null,
116
+ auditorEventId: null,
117
+ detail: 'crosscheck.yml declares no role_isolation.pairs, so this audit asserts nothing. A policy that names no roles cannot be satisfied or violated, and reporting that as a pass would advertise a check nobody configured.',
118
+ };
119
+ }
120
+ /**
121
+ * Assert crosscheck.yml's `role_isolation` against what the log records: for
122
+ * every test gate, was the auditor role dispatched in a turn of its own,
123
+ * before the gate, on this task, and did it report?
124
+ *
125
+ * Every gate is checked, not just the last one. A task that got it wrong once
126
+ * and right on the re-run did get it wrong once.
127
+ */
128
+ export function checkTesterIsolation(events, pairs, options) {
129
+ const taskId = options.taskId ?? null;
130
+ // D-181: `--task` is answered across both spellings of the id, so a gate
131
+ // logged qualified is not dropped from a bare-spelled scope.
132
+ const inScope = (scope) => (r) => r.taskId !== null && taskIdsMatch(r.taskId, scope);
133
+ const allDispatches = readDispatchRecords(events);
134
+ const dispatches = taskId ? allDispatches.filter(inScope(taskId)) : allDispatches;
135
+ const allGates = readGateRecords(events);
136
+ const gates = taskId ? allGates.filter(inScope(taskId)) : allGates;
137
+ const terminals = readTerminalRoleRecords(events);
138
+ const base = {
139
+ sessionId: options.sessionId,
140
+ taskId,
141
+ gatesExamined: gates.length,
142
+ dispatchesExamined: dispatches.length,
143
+ };
144
+ if (pairs.length === 0) {
145
+ return { ...base, checks: [noPairsCheck()], ok: false };
146
+ }
147
+ const checks = [];
148
+ for (const gate of gates) {
149
+ for (const pair of pairs) {
150
+ checks.push(checkOneGate(pair, gate, dispatches, terminals));
151
+ }
152
+ }
153
+ // No gate at all is not a clean run. It is a session that never reached the
154
+ // moment this audit reads, and saying `ok` about it would answer a question
155
+ // the log never posed.
156
+ if (gates.length === 0) {
157
+ for (const pair of pairs) {
158
+ checks.push({
159
+ taskId,
160
+ worker: pair.worker,
161
+ auditor: pair.auditor,
162
+ status: 'unverifiable',
163
+ gateEventId: null,
164
+ checksRun: 0,
165
+ workerEventId: null,
166
+ auditorEventId: null,
167
+ detail: `No ${TEST_GATE_EVENT} event in scope, so nothing here says whether ${pair.auditor} graded ${pair.worker}'s work.`,
168
+ });
169
+ }
170
+ }
171
+ return {
172
+ ...base,
173
+ checks,
174
+ ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
175
+ };
176
+ }
177
+ function checkOneGate(pair, gate, dispatches, terminals) {
178
+ const base = {
179
+ taskId: gate.taskId,
180
+ worker: pair.worker,
181
+ auditor: pair.auditor,
182
+ gateEventId: gate.eventId,
183
+ workerEventId: null,
184
+ auditorEventId: null,
185
+ };
186
+ if (gate.taskId === null) {
187
+ return {
188
+ ...base,
189
+ status: 'unverifiable',
190
+ checksRun: 0,
191
+ detail: `The ${TEST_GATE_EVENT} at ${gate.ts} names no task, so no dispatch can be paired with it.`,
192
+ };
193
+ }
194
+ if (!Array.isArray(gate.results)) {
195
+ return {
196
+ ...base,
197
+ status: 'unverifiable',
198
+ checksRun: 0,
199
+ detail: `The ${TEST_GATE_EVENT} for ${gate.taskId} records no result list, so how much was graded is unknown.`,
200
+ };
201
+ }
202
+ const checksRun = gate.results.length;
203
+ if (checksRun === 0) {
204
+ // Nothing ran, so nothing was self-graded. This is the one status that
205
+ // passes without evidence, and it is safe precisely because there is no
206
+ // verdict here for a coder to have written in its own favour.
207
+ return {
208
+ ...base,
209
+ status: 'not-applicable',
210
+ checksRun: 0,
211
+ detail: `The ${TEST_GATE_EVENT} for ${gate.taskId} ran no test checks, so there was no verdict to grade.`,
212
+ };
213
+ }
214
+ const auditor = latestDispatchBefore(dispatches, pair.auditor, gate.ts, gate.taskId);
215
+ if (!auditor) {
216
+ return {
217
+ ...base,
218
+ status: 'violation',
219
+ checksRun,
220
+ detail: `No ${pair.auditor} was dispatched at or before the test gate for ${gate.taskId}, so the ${checksRun} test check(s) it graded were written in some other role's turn — on this pipeline, ${pair.worker}'s.`,
221
+ };
222
+ }
223
+ base.auditorEventId = auditor.eventId;
224
+ const worker = latestDispatchBefore(dispatches, pair.worker, auditor.ts, auditor.taskId);
225
+ if (!worker) {
226
+ return {
227
+ ...base,
228
+ status: 'unverifiable',
229
+ checksRun,
230
+ detail: `${pair.auditor} was dispatched for ${gate.taskId} with no preceding ${pair.worker} dispatch to have been isolated from.`,
231
+ };
232
+ }
233
+ base.workerEventId = worker.eventId;
234
+ // A shared agent id is one turn wearing two role names, which is the exact
235
+ // thing the pair forbids. A MISSING id is not evidence of anything: the
236
+ // dispatch contract does not require `agent_id`, so demanding it would make
237
+ // every real log unverifiable and teach the operator to ignore this audit.
238
+ if (auditor.agentId && worker.agentId && auditor.agentId === worker.agentId) {
239
+ return {
240
+ ...base,
241
+ status: 'violation',
242
+ checksRun,
243
+ detail: `The ${pair.worker} and ${pair.auditor} dispatches for ${gate.taskId} share agent id ${auditor.agentId}, so the tests were graded in the turn that wrote the code.`,
244
+ };
245
+ }
246
+ const reported = terminals.find((t) => t.role === pair.auditor &&
247
+ t.ts >= auditor.ts &&
248
+ t.ts <= gate.ts &&
249
+ scopesAgree(t.taskId, gate.taskId));
250
+ if (!reported) {
251
+ return {
252
+ ...base,
253
+ status: 'unverifiable',
254
+ checksRun,
255
+ detail: `${pair.auditor} was dispatched for ${gate.taskId} and never reported before the gate ran, so the log does not say whose work the gate graded.`,
256
+ };
257
+ }
258
+ return {
259
+ ...base,
260
+ status: 'ok',
261
+ checksRun,
262
+ detail: `${pair.auditor} was dispatched for ${gate.taskId} after ${pair.worker} and reported (${reported.eventType}) before the gate graded ${checksRun} test check(s).`,
263
+ };
264
+ }
@@ -0,0 +1,171 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as osConstants } from 'node:os';
3
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
4
+ const TAIL_LINES = 50;
5
+ /**
6
+ * How much of a check's output survives, in UTF-16 code units.
7
+ *
8
+ * `TAIL_LINES` bounds lines, and a bound on lines is not a bound on size: one
9
+ * `--reporter=json` run, one minified bundle, one long `set -x` trace with no
10
+ * newline in it is a single "line", so the line bound returns the whole thing
11
+ * (D-189). Two things then have no ceiling. The `tail` goes verbatim into the
12
+ * `testgate-result` event, and the event log is append-only — an unbounded
13
+ * field there is written once and kept forever. And the output is accumulated
14
+ * in the orchestrator's own heap while the check runs, which the per-command
15
+ * timeout does not bound: a check that floods fills memory long before five
16
+ * minutes are up, and an orchestrator that dies of it writes no gate event at
17
+ * all. Generous on purpose — 50 lines of real test output fit inside this with
18
+ * room to spare, and output that does not was never going to be read as lines.
19
+ */
20
+ const TAIL_MAX_CHARS = 64 * 1024;
21
+ const DROPPED_NOTE = '[testgate] earlier output dropped — this is the tail, not the whole run';
22
+ const TIMEOUT_EXIT_CODE = -1;
23
+ function tailLines(text, n) {
24
+ // Drop one trailing newline first so a command's final real line doesn't
25
+ // show up as a spurious empty "line" that pushes real content out of the tail.
26
+ const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
27
+ const lines = trimmed.split('\n');
28
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
29
+ }
30
+ /**
31
+ * Name the signal a check died from, if it died from one.
32
+ *
33
+ * Two shapes reach us, because checks run through a shell (`shell: true`):
34
+ * the shell itself signalled gives `signal` directly, while a signalled
35
+ * *grandchild* is reported by the shell as exit code 128+N. Both used to land
36
+ * in the results as a bare number with an empty tail, which reads as "the
37
+ * tests failed" and sends the reader to the wrong subsystem entirely — the
38
+ * mistake D-47 records. A crash is not a test failure and must not look like one.
39
+ *
40
+ * The 128+N branch is an inference, not an observation: a command free to pick
41
+ * its own exit codes may return 137 meaning something of its own, and nothing
42
+ * in the exit status distinguishes that from a shell reporting SIGKILL. The
43
+ * shell throws that information away before we see it. So the note for that
44
+ * branch is worded as the reading it is, and the caller keeps the raw code.
45
+ */
46
+ function signalNote(code, signal) {
47
+ if (signal !== null) {
48
+ return `[testgate] the check process was killed by ${signal} — it crashed, it did not fail`;
49
+ }
50
+ if (code !== null && code > 128 && code < 128 + 65) {
51
+ const number = code - 128;
52
+ const name = Object.entries(osConstants.signals).find(([, n]) => n === number)?.[0];
53
+ if (name !== undefined) {
54
+ return (`[testgate] exit code ${code} is the shell's convention for a command killed by ` +
55
+ `${name} (128+${number}) — read it as a crash, not a test failure, unless this check ` +
56
+ 'is known to use that exit code itself');
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Run one check command in its own process group (POSIX `setsid`, via
63
+ * `detached: true`) so a timeout can kill the WHOLE group — `process.kill`
64
+ * with a negative pid signals every process in that group, not just the
65
+ * immediate shell child. A plain per-child kill (e.g. execFileSync's
66
+ * built-in `timeout`) leaves grandchildren the shell spawned (background
67
+ * jobs, long-lived children) running as orphans after the parent shell
68
+ * dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
69
+ */
70
+ function runOne(check, cwd, timeoutMs) {
71
+ return new Promise((resolve) => {
72
+ const child = spawn(check.cmd, {
73
+ cwd,
74
+ shell: true,
75
+ detached: true,
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ });
78
+ let output = '';
79
+ let dropped = false;
80
+ let timedOut = false;
81
+ /**
82
+ * Keep the last `TAIL_MAX_CHARS` and drop the rest as it arrives. Trimming
83
+ * at the end would bound the field and not the buffer, and the buffer is
84
+ * the half that can take the orchestrator down with it.
85
+ */
86
+ const absorb = (chunk) => {
87
+ output += chunk.toString('utf8');
88
+ if (output.length > TAIL_MAX_CHARS) {
89
+ output = output.slice(-TAIL_MAX_CHARS);
90
+ dropped = true;
91
+ }
92
+ };
93
+ /** The reported tail, admitting the drop when there was one. */
94
+ const tailOf = (text) => {
95
+ const kept = tailLines(text, TAIL_LINES);
96
+ return dropped ? `${DROPPED_NOTE}\n${kept}` : kept;
97
+ };
98
+ child.stdout?.on('data', absorb);
99
+ child.stderr?.on('data', absorb);
100
+ const timer = setTimeout(() => {
101
+ timedOut = true;
102
+ if (child.pid !== undefined) {
103
+ try {
104
+ process.kill(-child.pid, 'SIGKILL'); // negative pid -> whole process group
105
+ }
106
+ catch {
107
+ // Group already gone (command finished between the timer firing and here) — fine.
108
+ }
109
+ }
110
+ }, timeoutMs);
111
+ child.on('error', (err) => {
112
+ clearTimeout(timer);
113
+ resolve({
114
+ name: check.name,
115
+ pass: false,
116
+ exitCode: 1,
117
+ tail: tailOf(err.message),
118
+ });
119
+ });
120
+ child.on('close', (code, signal) => {
121
+ clearTimeout(timer);
122
+ if (timedOut) {
123
+ const timeoutNote = `[testgate] "${check.name}" timed out after ${timeoutMs}ms — process group killed`;
124
+ resolve({
125
+ name: check.name,
126
+ pass: false,
127
+ exitCode: TIMEOUT_EXIT_CODE,
128
+ tail: tailOf(output ? `${output}\n${timeoutNote}` : timeoutNote),
129
+ });
130
+ return;
131
+ }
132
+ const exitCode = code !== null ? code : signal ? -1 : 1;
133
+ const note = signalNote(code, signal);
134
+ const annotated = note === null ? output : output ? `${output}\n${note}` : note;
135
+ resolve({
136
+ name: check.name,
137
+ pass: exitCode === 0,
138
+ exitCode,
139
+ tail: tailOf(annotated),
140
+ });
141
+ });
142
+ });
143
+ }
144
+ /**
145
+ * Run configured check commands (test/typecheck/lint/coverage/…) sequentially
146
+ * in a worktree, one process per command, with a per-command timeout. First
147
+ * failure short-circuits the remaining checks unless `runAll` is set. Thin —
148
+ * no test-output parsing beyond the process exit code.
149
+ */
150
+ export async function run(checks, opts) {
151
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
152
+ const results = [];
153
+ for (const check of checks) {
154
+ const result = await runOne(check, opts.cwd, timeoutMs);
155
+ results.push(result);
156
+ if (!result.pass && !opts.runAll)
157
+ break;
158
+ }
159
+ return { results, pass: results.length > 0 ? results.every((r) => r.pass) : true };
160
+ }
161
+ /**
162
+ * Cumulative regression gate (architecture §11): the epic's single test
163
+ * command, standing in for "every previously merged task's tests in the
164
+ * epic" — the caller is responsible for pointing `epicTestCmd` at whatever
165
+ * aggregate command covers that (e.g. the epic's full suite), this module
166
+ * has no opinion on what's inside it. A thin wrapper over `run`, not a
167
+ * separate code path.
168
+ */
169
+ export async function runCumulative(epicTestCmd, opts) {
170
+ return run([{ name: 'cumulative', cmd: epicTestCmd }], opts);
171
+ }