@juzser/blacksmith 0.2.0 → 0.4.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 +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- package/package.json +8 -8
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose the three pure pieces into the run's write path: fold the log
|
|
3
|
+
* (errorIssues.ts), ask the roadmap whether a project's tracker is writable
|
|
4
|
+
* (the resolved switch, handed in), resolve the repo and classify `gh`
|
|
5
|
+
* (git.ts/gh.ts), deduplicate against already-open issues, open or comment,
|
|
6
|
+
* and record what happened as an `issue-reported` event of its own.
|
|
7
|
+
*
|
|
8
|
+
* `reportErrors()` is the one entry point a run calls right after it
|
|
9
|
+
* records an error. It is idempotent by fingerprint: called twice over the
|
|
10
|
+
* same log plus the events the first call appended, it opens and comments
|
|
11
|
+
* nothing the second time, because step 4 below reads that history back.
|
|
12
|
+
*
|
|
13
|
+
* Dedup asks GitHub, not a local index (functional clause 3). No file
|
|
14
|
+
* under `state/` tracks which fingerprints are already open. The loser's
|
|
15
|
+
* cost of that choice: one `gh issue list --search` round trip per
|
|
16
|
+
* candidate error rather than an O(1) local lookup, and every `commented`
|
|
17
|
+
* outcome pays a second `gh issue comment` round trip on top. Both are
|
|
18
|
+
* accepted because a local index would drift the moment an issue is closed
|
|
19
|
+
* or reopened by a human outside this process, and this module would have
|
|
20
|
+
* no way to notice.
|
|
21
|
+
*
|
|
22
|
+
* This module never re-implements task 2 or task 3: `errorIssues.ts`,
|
|
23
|
+
* `gh.ts` and `git.ts` are imported, never edited, and never touched here
|
|
24
|
+
* are `PROJECTS_DIR`, `process.cwd()` or `REPO_ROOT` — the project register
|
|
25
|
+
* is a required parameter, supplied by the caller (task 5), never read off
|
|
26
|
+
* the filesystem by this module.
|
|
27
|
+
*/
|
|
28
|
+
import { FINGERPRINT_LINE_PREFIX, foldErrorEvents, renderBody, renderComment, renderTitle, toIssueBodyFields, toIssueCommentFields, withSessionFallback, } from './errorIssues.js';
|
|
29
|
+
import { appendEvent } from './events.js';
|
|
30
|
+
import { buildCommentArgv, buildCreateIssueArgv, buildSearchIssuesArgv, classifyGh, resolveProjectRepo, } from './gh.js';
|
|
31
|
+
/**
|
|
32
|
+
* The closed, named outcome vocabulary. Exactly one of these nine words is
|
|
33
|
+
* recorded for every candidate error, always — functional clause 2.
|
|
34
|
+
* `skipped-unresolved-project` is settled before any of the others can be:
|
|
35
|
+
* a row this factory cannot identify the project of is never filed against
|
|
36
|
+
* this factory's own repository by default (the privacy-leak guard).
|
|
37
|
+
*/
|
|
38
|
+
export const ISSUE_REPORT_OUTCOMES = [
|
|
39
|
+
'opened',
|
|
40
|
+
'commented',
|
|
41
|
+
'deduped-open',
|
|
42
|
+
'skipped-disabled',
|
|
43
|
+
'skipped-no-remote',
|
|
44
|
+
'skipped-gh-missing',
|
|
45
|
+
'skipped-unauthenticated',
|
|
46
|
+
'skipped-unresolved-project',
|
|
47
|
+
'failed',
|
|
48
|
+
];
|
|
49
|
+
const ISSUE_REPORTED_EVENT_TYPE = 'issue-reported';
|
|
50
|
+
/**
|
|
51
|
+
* The `issue-reported` payload allowlist (functional clause 6, nonfunctional
|
|
52
|
+
* clause 3): named fields only, never a spread of a source event's payload.
|
|
53
|
+
* Sorted, and this is the literal the test asserts against.
|
|
54
|
+
*/
|
|
55
|
+
export const ISSUE_REPORT_PAYLOAD_KEYS = [
|
|
56
|
+
'detail',
|
|
57
|
+
'error_class',
|
|
58
|
+
'fingerprint',
|
|
59
|
+
'issue_url',
|
|
60
|
+
'latest_event_id',
|
|
61
|
+
'outcome',
|
|
62
|
+
'reason',
|
|
63
|
+
'repo_slug',
|
|
64
|
+
'source',
|
|
65
|
+
'task_ref',
|
|
66
|
+
];
|
|
67
|
+
function parseSearchResult(stdout) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(stdout);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
return null;
|
|
77
|
+
const issues = [];
|
|
78
|
+
for (const item of parsed) {
|
|
79
|
+
if (typeof item !== 'object' || item === null)
|
|
80
|
+
return null;
|
|
81
|
+
const number = item.number;
|
|
82
|
+
const body = item.body;
|
|
83
|
+
if (typeof number !== 'number' || typeof body !== 'string')
|
|
84
|
+
return null;
|
|
85
|
+
const url = item.url;
|
|
86
|
+
issues.push({ number, body, url: typeof url === 'string' ? url : undefined });
|
|
87
|
+
}
|
|
88
|
+
return issues;
|
|
89
|
+
}
|
|
90
|
+
/** The exact machine-readable fingerprint line, built the same way errorIssues.ts's renderers do. */
|
|
91
|
+
function fingerprintLine(fingerprint) {
|
|
92
|
+
return `${FINGERPRINT_LINE_PREFIX}${fingerprint}`;
|
|
93
|
+
}
|
|
94
|
+
/** An exact line match, not a substring: functional clause 4. */
|
|
95
|
+
function findExactMatch(issues, fingerprint) {
|
|
96
|
+
const line = fingerprintLine(fingerprint);
|
|
97
|
+
return issues.find((issue) => issue.body.split('\n').includes(line)) ?? null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The task ref an unstamped row carries, if any -- the envelope's own
|
|
101
|
+
* `task_id` first, falling back to a hand-appended row's `payload.task_ref`.
|
|
102
|
+
* Mirrors errorIssues.ts's `candidateTaskRef`, which this module never
|
|
103
|
+
* imports so as to keep the two folds' internals independent.
|
|
104
|
+
*/
|
|
105
|
+
function candidateTaskRef(record) {
|
|
106
|
+
if (typeof record.task_id === 'string' && record.task_id.length > 0)
|
|
107
|
+
return record.task_id;
|
|
108
|
+
const payload = (record.payload ?? {});
|
|
109
|
+
return typeof payload.task_ref === 'string' && payload.task_ref.length > 0
|
|
110
|
+
? payload.task_ref
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A row's project, resolved the same way `errorIssues.ts`'s fold resolves
|
|
115
|
+
* one: an explicit stamp always wins; absent that, `resolveRow` (the row's
|
|
116
|
+
* own task ref widened with `withSessionFallback`'s session fallbacks) gets
|
|
117
|
+
* the answer. `null` when none of them can tell -- this factory's own
|
|
118
|
+
* project is never guessed. This is what keeps an unstamped row from a
|
|
119
|
+
* foreign (or unidentifiable) project's session from being folded into this
|
|
120
|
+
* factory's own prior-report history (and, through `decideOutcome`'s dedup
|
|
121
|
+
* key, its own repository) merely because nothing stamped it.
|
|
122
|
+
*/
|
|
123
|
+
function resolveRecordProject(record, resolveRow) {
|
|
124
|
+
if (typeof record.project === 'string')
|
|
125
|
+
return record.project;
|
|
126
|
+
return resolveRow(candidateTaskRef(record), record.session_id);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Every already-appended `issue-reported` record still open, read off the
|
|
130
|
+
* SNAPSHOT of events this call was handed — never off events this same call
|
|
131
|
+
* itself appends. That is what lets the five duplicate candidates a single
|
|
132
|
+
* broken gate produces in one round resolve through GitHub search (one
|
|
133
|
+
* create, four comments) rather than short-circuiting on each other.
|
|
134
|
+
*
|
|
135
|
+
* A record whose project cannot be resolved at all is dropped rather than
|
|
136
|
+
* pushed: it can never match `priorReportFor`'s `h.project === report.project`
|
|
137
|
+
* comparison against a null-project report either (Step 0 below settles
|
|
138
|
+
* those before Step 4 runs), so keeping it around would only cost a lookup.
|
|
139
|
+
*/
|
|
140
|
+
function priorOpenReports(events, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
141
|
+
const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const event of events) {
|
|
144
|
+
const { record } = event;
|
|
145
|
+
if (record.event_type !== ISSUE_REPORTED_EVENT_TYPE)
|
|
146
|
+
continue;
|
|
147
|
+
const payload = record.payload;
|
|
148
|
+
if (payload.outcome !== 'opened' &&
|
|
149
|
+
payload.outcome !== 'commented' &&
|
|
150
|
+
payload.outcome !== 'deduped-open') {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (typeof payload.fingerprint !== 'string' || typeof payload.latest_event_id !== 'string') {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const project = resolveRecordProject(record, resolveRow);
|
|
157
|
+
if (project === null)
|
|
158
|
+
continue;
|
|
159
|
+
out.push({
|
|
160
|
+
project,
|
|
161
|
+
fingerprint: payload.fingerprint,
|
|
162
|
+
outcome: payload.outcome,
|
|
163
|
+
latest_event_id: payload.latest_event_id,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function toPayload(record) {
|
|
169
|
+
const payload = {
|
|
170
|
+
outcome: record.outcome,
|
|
171
|
+
fingerprint: record.fingerprint,
|
|
172
|
+
source: record.source,
|
|
173
|
+
error_class: record.error_class,
|
|
174
|
+
task_ref: record.task_ref,
|
|
175
|
+
latest_event_id: record.latest_event_id,
|
|
176
|
+
};
|
|
177
|
+
if (record.repo_slug !== undefined)
|
|
178
|
+
payload.repo_slug = record.repo_slug;
|
|
179
|
+
if (record.issue_url !== undefined)
|
|
180
|
+
payload.issue_url = record.issue_url;
|
|
181
|
+
if (record.reason !== undefined)
|
|
182
|
+
payload.reason = record.reason;
|
|
183
|
+
if (record.detail !== undefined)
|
|
184
|
+
payload.detail = record.detail;
|
|
185
|
+
return payload;
|
|
186
|
+
}
|
|
187
|
+
async function appendIssueReported(report, record, opts) {
|
|
188
|
+
return appendEvent({
|
|
189
|
+
session_id: report.session_id,
|
|
190
|
+
actor: 'system',
|
|
191
|
+
event_type: ISSUE_REPORTED_EVENT_TYPE,
|
|
192
|
+
task_id: report.task_ref,
|
|
193
|
+
// Schema minimum is 1; a report folded from an event that carried no
|
|
194
|
+
// plan version (e.g. a hand-appended error-logged) has none either.
|
|
195
|
+
plan_version: report.plan_version ?? 1,
|
|
196
|
+
causal_parent: report.latest_event_id,
|
|
197
|
+
payload: toPayload(record),
|
|
198
|
+
// `EventInput.project` is `string | undefined` -- omitted, never
|
|
199
|
+
// `null`, when a row's project could not be resolved; the writer
|
|
200
|
+
// never stamps a guess (events.ts's documented convention).
|
|
201
|
+
...(report.project !== null ? { project: report.project } : {}),
|
|
202
|
+
}, opts);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Step 4's dedup rule, the one copy both the real path and the preview read:
|
|
206
|
+
* same project, same fingerprint, same latest event, already open.
|
|
207
|
+
*/
|
|
208
|
+
function priorReportFor(history, report) {
|
|
209
|
+
return history.find((h) => h.project === report.project &&
|
|
210
|
+
h.fingerprint === report.fingerprint &&
|
|
211
|
+
h.latest_event_id === report.latest_event_id);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* `register` is required and never defaulted: forwarded `undefined` would
|
|
215
|
+
* otherwise fail inside `resolveProjectRepo` naming neither the parameter
|
|
216
|
+
* nor this module. `[]` is a legitimate register and is not rejected.
|
|
217
|
+
*/
|
|
218
|
+
function requireRegister(register, entryPoint) {
|
|
219
|
+
if (!Array.isArray(register)) {
|
|
220
|
+
throw new Error(`${entryPoint}: the register parameter is required (readonly ProjectRef[]), got ${String(register)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Functional clause 2's decision procedure, in order, stopping at the first match. */
|
|
224
|
+
async function decideOutcome(report, isProjectEnabled, register, runner, history) {
|
|
225
|
+
const base = {
|
|
226
|
+
fingerprint: report.fingerprint,
|
|
227
|
+
project: report.project,
|
|
228
|
+
source: report.source,
|
|
229
|
+
error_class: report.error_class,
|
|
230
|
+
task_ref: report.task_ref,
|
|
231
|
+
latest_event_id: report.latest_event_id,
|
|
232
|
+
};
|
|
233
|
+
// Step 0: can this row's project be identified at all. A row still
|
|
234
|
+
// unresolved after the fold's own session fallbacks is never filed
|
|
235
|
+
// against this factory's own repository by default (the privacy-leak
|
|
236
|
+
// guard this whole module exists to close) -- settled before repo
|
|
237
|
+
// resolution or any `gh` interaction, ahead of every other step.
|
|
238
|
+
if (report.project === null) {
|
|
239
|
+
return { ...base, outcome: 'skipped-unresolved-project', reason: 'project-unresolved' };
|
|
240
|
+
}
|
|
241
|
+
const project = report.project;
|
|
242
|
+
// Step 1: the switch.
|
|
243
|
+
if (!isProjectEnabled(project)) {
|
|
244
|
+
return { ...base, outcome: 'skipped-disabled', reason: 'switch-off' };
|
|
245
|
+
}
|
|
246
|
+
// Step 2: which repository.
|
|
247
|
+
const repo = resolveProjectRepo(project, register);
|
|
248
|
+
if (!('slug' in repo)) {
|
|
249
|
+
return { ...base, outcome: 'skipped-no-remote', reason: repo.reason, detail: repo.detail };
|
|
250
|
+
}
|
|
251
|
+
const repoSlug = repo.slug;
|
|
252
|
+
// Step 3: can `gh` actually be used.
|
|
253
|
+
const gh = classifyGh(runner);
|
|
254
|
+
if (gh.outcome === 'missing') {
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
outcome: 'skipped-gh-missing',
|
|
258
|
+
reason: 'gh-not-on-path',
|
|
259
|
+
repo_slug: repoSlug,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (gh.outcome === 'unauthenticated') {
|
|
263
|
+
return {
|
|
264
|
+
...base,
|
|
265
|
+
outcome: 'skipped-unauthenticated',
|
|
266
|
+
reason: 'gh-unauthenticated',
|
|
267
|
+
repo_slug: repoSlug,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (gh.outcome === 'unknown') {
|
|
271
|
+
return { ...base, outcome: 'failed', reason: 'gh-unknown', repo_slug: repoSlug };
|
|
272
|
+
}
|
|
273
|
+
// Step 4: already reported and still open, per the snapshot this call read.
|
|
274
|
+
if (priorReportFor(history, report)) {
|
|
275
|
+
return {
|
|
276
|
+
...base,
|
|
277
|
+
outcome: 'deduped-open',
|
|
278
|
+
reason: 'already-reported',
|
|
279
|
+
repo_slug: repoSlug,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Step 5: search GitHub itself.
|
|
283
|
+
const searchResult = runner('gh', buildSearchIssuesArgv(repoSlug, report.fingerprint));
|
|
284
|
+
if (searchResult.status !== 0) {
|
|
285
|
+
return { ...base, outcome: 'failed', reason: 'search-failed', repo_slug: repoSlug };
|
|
286
|
+
}
|
|
287
|
+
const issues = parseSearchResult(searchResult.stdout);
|
|
288
|
+
if (issues === null) {
|
|
289
|
+
// gh exited 0 but the output could not be read as an issue list (e.g.
|
|
290
|
+
// --json was dropped somewhere upstream and gh printed its human table,
|
|
291
|
+
// or the response was truncated). Distinct from a genuine nonzero exit
|
|
292
|
+
// above, which is a process failure rather than an unreadable payload.
|
|
293
|
+
return { ...base, outcome: 'failed', reason: 'search-unparseable', repo_slug: repoSlug };
|
|
294
|
+
}
|
|
295
|
+
// Step 6: an exact fingerprint-line match gets a comment.
|
|
296
|
+
const match = findExactMatch(issues, report.fingerprint);
|
|
297
|
+
if (match) {
|
|
298
|
+
const body = renderComment(toIssueCommentFields(report));
|
|
299
|
+
const commentResult = runner('gh', buildCommentArgv(repoSlug, match.number, body));
|
|
300
|
+
if (commentResult.status !== 0) {
|
|
301
|
+
return { ...base, outcome: 'failed', reason: 'comment-failed', repo_slug: repoSlug };
|
|
302
|
+
}
|
|
303
|
+
const issueUrl = match.url ?? `https://github.com/${repoSlug}/issues/${String(match.number)}`;
|
|
304
|
+
return { ...base, outcome: 'commented', repo_slug: repoSlug, issue_url: issueUrl };
|
|
305
|
+
}
|
|
306
|
+
// Step 7: no match — open a new issue.
|
|
307
|
+
const fields = toIssueBodyFields(report);
|
|
308
|
+
const createResult = runner('gh', buildCreateIssueArgv(repoSlug, renderTitle(fields), renderBody(fields)));
|
|
309
|
+
if (createResult.status !== 0) {
|
|
310
|
+
return { ...base, outcome: 'failed', reason: 'create-failed', repo_slug: repoSlug };
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
...base,
|
|
314
|
+
outcome: 'opened',
|
|
315
|
+
repo_slug: repoSlug,
|
|
316
|
+
issue_url: createResult.stdout.trim(),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Preview's statement about the one step it has no instrument to measure. */
|
|
320
|
+
const STEP_3_NOT_PERFORMED = 'not-performed';
|
|
321
|
+
const DECISION_NOTE = 'create or comment: which of the two would run is decided by the search ' +
|
|
322
|
+
'this preview deliberately does not perform';
|
|
323
|
+
/**
|
|
324
|
+
* The preview-safe half of `decideOutcome`: steps 1, 2 and 4 only, through
|
|
325
|
+
* the same fold, `priorOpenReports` and `priorReportFor` the real path uses.
|
|
326
|
+
* Never invokes the runner with command `gh`, never appends an event. For a
|
|
327
|
+
* gh-reaching candidate it BUILDS the search, create and comment argv, and
|
|
328
|
+
* prints both create and comment because which one runs is settled by a
|
|
329
|
+
* search this helper does not perform; the comment argv's issue number is
|
|
330
|
+
* `0`, a placeholder known only after that search. `runner` is accepted for
|
|
331
|
+
* signature parity with `reportErrors()`; nothing here reaches it.
|
|
332
|
+
*/
|
|
333
|
+
export async function previewOutcomes(events, isProjectEnabled, register, runner, clock, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
334
|
+
requireRegister(register, 'previewOutcomes');
|
|
335
|
+
void runner;
|
|
336
|
+
const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
|
|
337
|
+
const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const report of reports) {
|
|
340
|
+
const { fingerprint, project, source, error_class, task_ref, latest_event_id } = report;
|
|
341
|
+
const base = { fingerprint, project, source, error_class, task_ref, latest_event_id };
|
|
342
|
+
const step3 = { step_3_gh_availability: STEP_3_NOT_PERFORMED };
|
|
343
|
+
const settle = (rest) => out.push({ ...base, ...step3, ...rest });
|
|
344
|
+
// Step 0: can this row's project be identified at all.
|
|
345
|
+
if (project === null) {
|
|
346
|
+
settle({
|
|
347
|
+
settled_at_step: 0,
|
|
348
|
+
outcome: 'skipped-unresolved-project',
|
|
349
|
+
reason: 'project-unresolved',
|
|
350
|
+
});
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// Step 1: the switch.
|
|
354
|
+
if (!isProjectEnabled(project)) {
|
|
355
|
+
settle({ settled_at_step: 1, outcome: 'skipped-disabled', reason: 'switch-off' });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// Step 2: which repository -- git-only, and the source of the slug.
|
|
359
|
+
const repo = resolveProjectRepo(project, register);
|
|
360
|
+
if (!('slug' in repo)) {
|
|
361
|
+
settle({
|
|
362
|
+
settled_at_step: 2,
|
|
363
|
+
outcome: 'skipped-no-remote',
|
|
364
|
+
reason: repo.reason,
|
|
365
|
+
detail: repo.detail,
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const repoSlug = repo.slug;
|
|
370
|
+
// Step 3 is skipped, not guessed. Step 4: the own-log dedup.
|
|
371
|
+
if (priorReportFor(history, report)) {
|
|
372
|
+
const reason = 'already-reported';
|
|
373
|
+
settle({ settled_at_step: 4, outcome: 'deduped-open', reason, repo_slug: repoSlug });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const fields = toIssueBodyFields(report);
|
|
377
|
+
const issueBody = renderBody(fields);
|
|
378
|
+
const commentText = renderComment(toIssueCommentFields(report));
|
|
379
|
+
settle({
|
|
380
|
+
repo_slug: repoSlug,
|
|
381
|
+
search_argv: buildSearchIssuesArgv(repoSlug, fingerprint),
|
|
382
|
+
create_argv: buildCreateIssueArgv(repoSlug, renderTitle(fields), issueBody),
|
|
383
|
+
comment_argv: buildCommentArgv(repoSlug, 0, commentText),
|
|
384
|
+
issue_body: issueBody,
|
|
385
|
+
comment_text: commentText,
|
|
386
|
+
decision_note: DECISION_NOTE,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Fold `events` into candidate errors, decide and act on each in order, and
|
|
393
|
+
* record every attempt as an `issue-reported` event. Never throws on a
|
|
394
|
+
* reporting failure — every path returns an outcome (functional clause 8).
|
|
395
|
+
*
|
|
396
|
+
* `isProjectEnabled` is the resolved `error_issues` switch, `register` is
|
|
397
|
+
* `projects.ts`'s `{name, dir}` list (task 5 supplies it; this module never
|
|
398
|
+
* calls `factoryProjects()` itself), `runner` is task 3's injected `gh`
|
|
399
|
+
* command runner, and `clock` supplies `now` for the fold below.
|
|
400
|
+
*/
|
|
401
|
+
export async function reportErrors(events, isProjectEnabled, register, runner, clock, opts = {}, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
402
|
+
requireRegister(register, 'reportErrors');
|
|
403
|
+
// Every project's switch is applied by this module's own step 1, not by
|
|
404
|
+
// the fold: foldErrorEvents dropping a disabled project's candidates
|
|
405
|
+
// silently would lose the `skipped-disabled` record clause 2 requires.
|
|
406
|
+
const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
|
|
407
|
+
const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const report of reports) {
|
|
410
|
+
const record = await decideOutcome(report, isProjectEnabled, register, runner, history);
|
|
411
|
+
await appendIssueReported(report, record, opts);
|
|
412
|
+
out.push(record);
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `.claude/hooks/judge-stop.sh` — the SubagentStop decision for a judge-class
|
|
4
|
+
* agent (reviewer, verifier, grader, spec-reviewer, security-reviewer,
|
|
5
|
+
* auditor; `JUDGE_ROLES` in dispatchLint.ts is the authoritative set).
|
|
6
|
+
*
|
|
7
|
+
* A judge can end its turn without ever writing the artifact its dispatch
|
|
8
|
+
* declared, and today that gap is found only downstream, when `smith judge
|
|
9
|
+
* report` answers `judges.artifact-missing` — after the agent is gone. This
|
|
10
|
+
* hook closes the loop at the one moment it can still act: block the stop
|
|
11
|
+
* while the declared path does not exist, allow it once it does. A harness
|
|
12
|
+
* `maxTurns` cap is out of scope; this hook cannot override it.
|
|
13
|
+
*
|
|
14
|
+
* The declared-artifact line and its parser come from `dispatchLint.ts`,
|
|
15
|
+
* never redefined — `dispatch lint` already refuses, before dispatch, any
|
|
16
|
+
* judge prompt whose line is missing, relative, or mismatched, so this hook
|
|
17
|
+
* only asks "does the file exist yet", never "is the line well-formed".
|
|
18
|
+
*
|
|
19
|
+
* Fail open on anything this hook cannot read: a non-judge `agent_type`, a
|
|
20
|
+
* prompt with no declared-artifact line, a relative path, unparseable stdin,
|
|
21
|
+
* an unreadable transcript — blocking on those would trap the agent until
|
|
22
|
+
* `maxTurns` for an obligation the hook could never state. Every fail-open
|
|
23
|
+
* path prints a stderr note and is covered upstream by `dispatch lint` or
|
|
24
|
+
* downstream by `judge report`'s `judges.artifact-missing`.
|
|
25
|
+
*
|
|
26
|
+
* Fail closed ONLY on a readable absolute declared path that does not exist.
|
|
27
|
+
*
|
|
28
|
+
* SubagentStop stdin fields: https://code.claude.com/docs/en/hooks
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import { JUDGE_ROLES, parseDeclaredArtifactLine } from './dispatchLint.js';
|
|
34
|
+
const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
|
|
35
|
+
/**
|
|
36
|
+
* The pure decision: given the hook's stdin and the subagent's prompt text
|
|
37
|
+
* (or `null` when it could not be read at all), decide whether the stop is
|
|
38
|
+
* allowed. Touches the filesystem only to check whether the declared path
|
|
39
|
+
* exists -- the one question this hook exists to ask.
|
|
40
|
+
*/
|
|
41
|
+
export function decideJudgeStop(input, promptText) {
|
|
42
|
+
const agentType = input.agent_type;
|
|
43
|
+
if (agentType === undefined || !JUDGE_ROLE_SET.has(agentType)) {
|
|
44
|
+
// Not one of the six judge roles -- a coder's turn carries no declared
|
|
45
|
+
// artifact at all, so there is nothing for this hook to check.
|
|
46
|
+
return { decision: 'allow' };
|
|
47
|
+
}
|
|
48
|
+
if (promptText === null) {
|
|
49
|
+
return {
|
|
50
|
+
decision: 'allow',
|
|
51
|
+
note: "judge-stop: could not read this agent's prompt (transcript missing or " +
|
|
52
|
+
"unreadable); allowing the stop. judge report's judges.artifact-missing " +
|
|
53
|
+
'remains the backstop.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const declared = parseDeclaredArtifactLine(promptText);
|
|
57
|
+
if (declared === null) {
|
|
58
|
+
return {
|
|
59
|
+
decision: 'allow',
|
|
60
|
+
note: 'judge-stop: no "Declared artifact: <path>" line in this prompt; allowing ' +
|
|
61
|
+
"the stop. dispatch lint refuses this before dispatch, and judge report's " +
|
|
62
|
+
'judges.artifact-missing catches it downstream.',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!path.isAbsolute(declared)) {
|
|
66
|
+
return {
|
|
67
|
+
decision: 'allow',
|
|
68
|
+
note: `judge-stop: declared artifact "${declared}" is not an absolute path; ` +
|
|
69
|
+
'allowing the stop. dispatch lint refuses this before dispatch, and judge ' +
|
|
70
|
+
"report's judges.artifact-missing catches it downstream.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (!existsSync(declared)) {
|
|
74
|
+
return {
|
|
75
|
+
decision: 'block',
|
|
76
|
+
reason: `Declared artifact ${declared} does not exist yet. Write it before ending this turn.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Existence is this hook's whole question -- an empty or unparseable file
|
|
80
|
+
// is `judge report`'s to refuse (`judges.artifact-unparseable`, `judges.
|
|
81
|
+
// artifact-not-a-list`), not this hook's to trap the agent over.
|
|
82
|
+
return { decision: 'allow' };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The last user-role message's text in a Claude Code JSONL transcript --
|
|
86
|
+
* where a dispatch prompt and its "Declared artifact:" line live. Walks
|
|
87
|
+
* backward from the end, since a subagent's transcript can carry many turns
|
|
88
|
+
* and the dispatch prompt need not be the only user turn. Returns `null` on
|
|
89
|
+
* any read or parse failure.
|
|
90
|
+
*/
|
|
91
|
+
export function extractLastUserPromptText(transcriptPath) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(transcriptPath, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const lines = raw.split('\n');
|
|
100
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
101
|
+
const line = lines[i];
|
|
102
|
+
if (line === undefined || line.trim() === '')
|
|
103
|
+
continue;
|
|
104
|
+
let entry;
|
|
105
|
+
try {
|
|
106
|
+
entry = JSON.parse(line);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const text = userTextOf(entry);
|
|
112
|
+
if (text !== null)
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function userTextOf(entry) {
|
|
118
|
+
if (typeof entry !== 'object' || entry === null)
|
|
119
|
+
return null;
|
|
120
|
+
const rec = entry;
|
|
121
|
+
if (rec.type !== 'user')
|
|
122
|
+
return null;
|
|
123
|
+
const message = rec.message;
|
|
124
|
+
if (typeof message !== 'object' || message === null)
|
|
125
|
+
return null;
|
|
126
|
+
const content = message.content;
|
|
127
|
+
if (typeof content === 'string')
|
|
128
|
+
return content;
|
|
129
|
+
if (Array.isArray(content)) {
|
|
130
|
+
const parts = content
|
|
131
|
+
.filter((block) => typeof block === 'object' && block !== null)
|
|
132
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
133
|
+
.map((block) => block.text);
|
|
134
|
+
if (parts.length > 0)
|
|
135
|
+
return parts.join('\n');
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The whole hook, minus process I/O: parse stdin, read the prompt, decide,
|
|
141
|
+
* and format the two output streams Claude Code and an operator each read.
|
|
142
|
+
* Exercised directly by tests without spawning a process; `main` below is
|
|
143
|
+
* the only piece that touches fd 0 and process.exit.
|
|
144
|
+
*/
|
|
145
|
+
export function runJudgeStopHook(rawStdin) {
|
|
146
|
+
let input;
|
|
147
|
+
try {
|
|
148
|
+
input = JSON.parse(rawStdin);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return {
|
|
152
|
+
stdout: '',
|
|
153
|
+
stderr: 'judge-stop: could not parse stdin as JSON; allowing the stop. ' +
|
|
154
|
+
"judge report's judges.artifact-missing remains the backstop.\n",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const promptText = input.transcript_path
|
|
158
|
+
? extractLastUserPromptText(input.transcript_path)
|
|
159
|
+
: null;
|
|
160
|
+
const result = decideJudgeStop(input, promptText);
|
|
161
|
+
if (result.decision === 'block') {
|
|
162
|
+
return {
|
|
163
|
+
stdout: `${JSON.stringify({ decision: 'block', reason: result.reason })}\n`,
|
|
164
|
+
stderr: '',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { stdout: '', stderr: result.note ? `${result.note}\n` : '' };
|
|
168
|
+
}
|
|
169
|
+
function main() {
|
|
170
|
+
const { stdout, stderr } = runJudgeStopHook(readFileSync(0, 'utf8'));
|
|
171
|
+
if (stdout)
|
|
172
|
+
process.stdout.write(stdout);
|
|
173
|
+
if (stderr)
|
|
174
|
+
process.stderr.write(stderr);
|
|
175
|
+
}
|
|
176
|
+
// Runs only when executed directly (judge-stop.sh execs `node
|
|
177
|
+
// dist/judgeStopHook.js`) -- importing this module for its exports, as the
|
|
178
|
+
// tests do, must never read stdin as a side effect.
|
|
179
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
180
|
+
main();
|
|
181
|
+
}
|
|
@@ -273,7 +273,7 @@ function graderFindingCount(parsed) {
|
|
|
273
273
|
*
|
|
274
274
|
* The grader is the one role whose declared shape is not a list: it writes a
|
|
275
275
|
* verdict document, and refusing that document left its turn closable only by
|
|
276
|
-
* a hand-written evidence file (FD-1
|
|
276
|
+
* a hand-written evidence file (FD-1). For `role === 'grader'`
|
|
277
277
|
* the grader result document is accepted beside the list, and the count is
|
|
278
278
|
* its non-pass criteria. No other role gets that reading — a reviewer that
|
|
279
279
|
* wrote a verdict wrote the wrong shape.
|