@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
|
@@ -61,6 +61,23 @@ export const LEGAL_TRANSITIONS = Object.freeze({
|
|
|
61
61
|
expired: [],
|
|
62
62
|
amended: [],
|
|
63
63
|
});
|
|
64
|
+
/**
|
|
65
|
+
* The `finding_status` values a waiver can actually be granted from — read off
|
|
66
|
+
* the table above rather than restated beside it. `transition()` enforces
|
|
67
|
+
* LEGAL_TRANSITIONS, so a hand-kept roster of the same fact can only agree
|
|
68
|
+
* with it or be wrong about it, and being wrong is silent: a status the roster
|
|
69
|
+
* has not caught up with is never reconciled to `waived` and never counted as
|
|
70
|
+
* awaiting the operator.
|
|
71
|
+
*
|
|
72
|
+
* It lives here, not in waivers.ts, because this module owns the table — and
|
|
73
|
+
* because waivers.ts is already in an import cycle with this one, where a
|
|
74
|
+
* top-level read of `LEGAL_TRANSITIONS` from over there would be a temporal
|
|
75
|
+
* dead zone away from throwing, depending only on which module node reaches
|
|
76
|
+
* first.
|
|
77
|
+
*/
|
|
78
|
+
export const WAIVABLE_STATUSES = Object.entries(LEGAL_TRANSITIONS)
|
|
79
|
+
.filter(([, next]) => next.includes('waived'))
|
|
80
|
+
.map(([status]) => status);
|
|
64
81
|
/** The status an amendment puts a cited spec finding into; `amended` is what discharges it. */
|
|
65
82
|
export const AMEND_PENDING_STATUS = 'amend-pending';
|
|
66
83
|
/** Terminal: the amendment's task ids landed. Reachable only from AMEND_PENDING_STATUS. */
|
|
@@ -74,11 +91,31 @@ export const AMENDED_STATUS = 'amended';
|
|
|
74
91
|
*/
|
|
75
92
|
const AMENDMENT_STATUSES = [AMEND_PENDING_STATUS, AMENDED_STATUS];
|
|
76
93
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
94
|
+
* The one status the table gives outgoing edges to that is closed anyway.
|
|
95
|
+
* D-180 gave `waived` a way back so a revoked grant can reopen it, but it is a
|
|
96
|
+
* decision until that denial arrives, and until then it belongs with
|
|
97
|
+
* `refuted`/`expired`/`fix-verified`/`amended` on the closed side.
|
|
98
|
+
*
|
|
99
|
+
* Declared rather than read off the table because "this status is a decision"
|
|
100
|
+
* is a judgement about what the status MEANS. The table knows an edge exists;
|
|
101
|
+
* it cannot tell a way back from a way onward.
|
|
102
|
+
*/
|
|
103
|
+
const CLOSED_DESPITE_A_WAY_BACK = ['waived'];
|
|
104
|
+
/**
|
|
105
|
+
* A finding still awaiting something: read off LEGAL_TRANSITIONS rather than
|
|
106
|
+
* restated beside it, exactly as WAIVABLE_STATUSES above is. A finding is
|
|
107
|
+
* awaiting something precisely while the table still has somewhere to take it,
|
|
108
|
+
* so this is the statuses with a non-empty outgoing edge set, less the
|
|
109
|
+
* decision above.
|
|
110
|
+
*
|
|
111
|
+
* It was a hand-written list until a probe showed what that costs. Nothing
|
|
112
|
+
* held the list to the table, and the drift ran in the dangerous direction: a
|
|
113
|
+
* status the list had not caught up with was absent from it, and absent reads
|
|
114
|
+
* as CLOSED, so the epic gate closes over a finding still in flight and nobody
|
|
115
|
+
* ever sees a blocker for it. That is not hypothetical -- it is D-127, which
|
|
116
|
+
* happened exactly this way when `amend-pending` reached the table and not the
|
|
117
|
+
* list. Derived, the default inverts: a status nobody has classified yet is
|
|
118
|
+
* open, which blocks a close rather than waving one through.
|
|
82
119
|
*
|
|
83
120
|
* `amend-pending` is in it for D-127 Part B: that finding is open the same way
|
|
84
121
|
* `fix-pending` is, carrying an assigned discharge condition (amends_task_ids
|
|
@@ -95,13 +132,74 @@ const AMENDMENT_STATUSES = [AMEND_PENDING_STATUS, AMENDED_STATUS];
|
|
|
95
132
|
* corroborate"). A second copy would drift the moment taxonomy.yml grows a
|
|
96
133
|
* status.
|
|
97
134
|
*/
|
|
98
|
-
export const OPEN_FINDING_STATUSES = new Set(
|
|
99
|
-
|
|
100
|
-
|
|
135
|
+
export const OPEN_FINDING_STATUSES = new Set(Object.entries(LEGAL_TRANSITIONS)
|
|
136
|
+
.filter(([status, next]) => next.length > 0 && !CLOSED_DESPITE_A_WAY_BACK.includes(status))
|
|
137
|
+
.map(([status]) => status));
|
|
138
|
+
/**
|
|
139
|
+
* Open, but already somebody's assignment: the statuses whose finding is
|
|
140
|
+
* waiting on work that has a named owner and a named discharge condition.
|
|
141
|
+
*
|
|
142
|
+
* Declared rather than read off the table, for the same reason
|
|
143
|
+
* CLOSED_DESPITE_A_WAY_BACK above is: "somebody already owns this" is a
|
|
144
|
+
* judgement about what a status MEANS, and the table cannot tell an
|
|
145
|
+
* assignment from a plain next step.
|
|
146
|
+
*
|
|
147
|
+
* Which way the default falls is the whole design. Declared, a status nobody
|
|
148
|
+
* has classified yet is dispatchable, and the cost of being wrong is a coder
|
|
149
|
+
* told about work someone else is doing -- which the injected block itself
|
|
150
|
+
* labels context, never scope, and which a reviewer catches as an unrequested
|
|
151
|
+
* change. Subtracted from a declared dispatchable side, the cost of being
|
|
152
|
+
* wrong is a finding open against the files a coder is about to edit that
|
|
153
|
+
* never reaches them, and no gate, review or transcript says a word about it.
|
|
154
|
+
*/
|
|
155
|
+
export const OPEN_BUT_ALREADY_ASSIGNED = [
|
|
101
156
|
'fix-pending',
|
|
102
157
|
'fix-landed',
|
|
103
158
|
AMEND_PENDING_STATUS,
|
|
104
|
-
]
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* The open statuses a dispatch may hand a coder as context (P9-15): open, and
|
|
162
|
+
* not already on somebody's plate.
|
|
163
|
+
*
|
|
164
|
+
* Derived from OPEN_FINDING_STATUSES rather than typed beside it, for the
|
|
165
|
+
* reason that constant is derived from the table -- and this one was typed,
|
|
166
|
+
* in findingContext.ts, as `['raised', 'confirmed']` with nothing anywhere
|
|
167
|
+
* holding it to anything. A probe that added an eleventh finding status to
|
|
168
|
+
* taxonomy.yml, architecture §8 and LEGAL_TRANSITIONS drove the findings
|
|
169
|
+
* suite green while a finding parked at it was invisible to every dispatch.
|
|
170
|
+
*
|
|
171
|
+
* Lives here rather than beside its one reader, because it is the same
|
|
172
|
+
* lifecycle question about the same vocabulary the two constants above ask.
|
|
173
|
+
*/
|
|
174
|
+
export const OPEN_FOR_DISPATCH = [...OPEN_FINDING_STATUSES].filter((status) => !OPEN_BUT_ALREADY_ASSIGNED.includes(status));
|
|
175
|
+
/**
|
|
176
|
+
* Closed by SHOWING rather than by deciding: the fix was verified, the claim
|
|
177
|
+
* was refuted, or the window it applied to is gone. Declared rather than read
|
|
178
|
+
* off the table, for the reason CLOSED_DESPITE_A_WAY_BACK and
|
|
179
|
+
* OPEN_BUT_ALREADY_ASSIGNED above are: "this closure was earned" is a
|
|
180
|
+
* judgement about what a status MEANS, and the table only knows an edge is
|
|
181
|
+
* absent.
|
|
182
|
+
*/
|
|
183
|
+
const EARNED_CLOSED_STATUSES = ['refuted', 'fix-verified', 'expired'];
|
|
184
|
+
/**
|
|
185
|
+
* Statuses that close a finding by DECIDING rather than by showing a fix
|
|
186
|
+
* (D-120): closed, and not earned. Disjoint from OPEN_FINDING_STATUSES by
|
|
187
|
+
* construction — and this file is now what constructs it. epic.ts typed the
|
|
188
|
+
* pair `{waived, amended}` beside a docblock claiming exactly that, with
|
|
189
|
+
* nothing anywhere holding it to the table.
|
|
190
|
+
*
|
|
191
|
+
* Which way the default falls is the whole design, and here it falls the
|
|
192
|
+
* visible way. Derived as the remainder, a closed status nobody has
|
|
193
|
+
* classified yet is discretionary: the judge is told about a closure it may
|
|
194
|
+
* decide needs no second thought, and the close event records one waiver too
|
|
195
|
+
* many. Typed instead, the cost of being wrong is a finding closed by a
|
|
196
|
+
* person's decision that the gate does not block on, the judge is never
|
|
197
|
+
* shown, and the close event's `discretionary_findings` omits — so "was this
|
|
198
|
+
* epic closed on decisions?" answers "no", permanently and wrongly. The gate
|
|
199
|
+
* does not block on these, which is precisely why the judge is told: what
|
|
200
|
+
* nothing blocks on is what nobody re-reads.
|
|
201
|
+
*/
|
|
202
|
+
export const DISCRETIONARY_FINDING_STATUSES = new Set(Object.keys(LEGAL_TRANSITIONS).filter((status) => !OPEN_FINDING_STATUSES.has(status) && !EARNED_CLOSED_STATUSES.includes(status)));
|
|
105
203
|
/**
|
|
106
204
|
* D-21 Part 4. Appended by `repairObligation` to correct a malformed
|
|
107
205
|
* `amends_task_ids` entry on a finding parked at `amend-pending` — never a
|
|
@@ -152,7 +250,9 @@ function escapeRegExp(text) {
|
|
|
152
250
|
* forms) — never a bare `:digits` anywhere in the text. A blanket
|
|
153
251
|
* `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
|
|
154
252
|
* would collide with an unrelated ":9090" finding (reproduced regression,
|
|
155
|
-
* reviewer finding #3).
|
|
253
|
+
* reviewer finding #3). The line number may itself be a range
|
|
254
|
+
* (`Widget.tsx:1549-1576`), which a re-review reports as often as a single
|
|
255
|
+
* line (issue #178).
|
|
156
256
|
*/
|
|
157
257
|
function stripPathLineRef(summary, filePath) {
|
|
158
258
|
const forwardSlashPath = filePath.replace(/\\/g, '/');
|
|
@@ -161,16 +261,37 @@ function stripPathLineRef(summary, filePath) {
|
|
|
161
261
|
for (const candidate of candidates) {
|
|
162
262
|
if (!candidate)
|
|
163
263
|
continue;
|
|
164
|
-
const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
|
|
264
|
+
const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?:[-–—]\\d+)?(?::\\d+)?`, 'g');
|
|
165
265
|
result = result.replace(pattern, candidate);
|
|
166
266
|
}
|
|
167
267
|
return result;
|
|
168
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* A `lines N-M` / `lines N–M` (en dash) / `line N to line M` range — the
|
|
271
|
+
* shape a judge describing a large component actually writes, and the one
|
|
272
|
+
* the original single `line N` strip below never covered (issue #178).
|
|
273
|
+
* Anchored on the word "line"/"lines" immediately before the numbers, so
|
|
274
|
+
* free-text numerics elsewhere in the sentence are never touched.
|
|
275
|
+
*/
|
|
276
|
+
const LINE_SPAN_RE = /\blines?\s+\d[\d,]*\s*(?:[-–—]|to(?:\s+lines?)?)\s*\d[\d,]*\b/g;
|
|
277
|
+
/**
|
|
278
|
+
* `~5,050-line` / `6,720-line` — a size given as a hyphenated adjective
|
|
279
|
+
* before "line"/"component"/"file"/etc, approximate or exact. Only the
|
|
280
|
+
* `<number>-line` token is removed; the noun it modifies is untouched, and
|
|
281
|
+
* a hyphenated count before any OTHER noun (`3-day`, `5-retry`) is left
|
|
282
|
+
* alone — it is not a line reference.
|
|
283
|
+
*/
|
|
284
|
+
const LINE_COUNT_RE = /~?\d[\d,]*-line\b/g;
|
|
285
|
+
/** `L1549-L6600` — a GitHub-style permalink line range, bare or after `#`. */
|
|
286
|
+
const GITHUB_LINE_RANGE_RE = /\bl\d+-l\d+\b/g;
|
|
169
287
|
/** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
|
|
170
288
|
function normalizeSummary(summary) {
|
|
171
289
|
return summary
|
|
172
290
|
.toLowerCase()
|
|
291
|
+
.replace(LINE_SPAN_RE, '')
|
|
173
292
|
.replace(/\bline\s+\d+\b/g, '')
|
|
293
|
+
.replace(LINE_COUNT_RE, '')
|
|
294
|
+
.replace(GITHUB_LINE_RANGE_RE, '')
|
|
174
295
|
.replace(/\s+/g, ' ')
|
|
175
296
|
.trim();
|
|
176
297
|
}
|
|
@@ -49,9 +49,12 @@ export function slugFromRemoteUrl(url) {
|
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
51
|
* Which repository the git checkout at `dir` belongs to. Classifies a
|
|
52
|
-
* `GitCommandError` from `readOriginUrl`
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* `GitCommandError` from `readOriginUrl` by POSITIVE match only:
|
|
53
|
+
* `not a git repository` is `not-a-repo`; `No such remote` is `no-origin`;
|
|
54
|
+
* every other git failure -- never spawned, or spawned and failed for a
|
|
55
|
+
* different reason -- is `git-failed`, with `detail` carrying git's own
|
|
56
|
+
* words. Any other error is a programming or environment fault and is
|
|
57
|
+
* rethrown, not mis-filed.
|
|
55
58
|
*/
|
|
56
59
|
export function resolveRepoAtDir(dir) {
|
|
57
60
|
let url;
|
|
@@ -63,7 +66,10 @@ export function resolveRepoAtDir(dir) {
|
|
|
63
66
|
if (/not a git repository/i.test(err.stderr)) {
|
|
64
67
|
return { reason: 'not-a-repo', detail: `${dir} is not a git repository` };
|
|
65
68
|
}
|
|
66
|
-
|
|
69
|
+
if (/No such remote/i.test(err.stderr)) {
|
|
70
|
+
return { reason: 'no-origin', detail: `${dir} has no "origin" remote` };
|
|
71
|
+
}
|
|
72
|
+
return { reason: 'git-failed', detail: err.message };
|
|
67
73
|
}
|
|
68
74
|
throw err;
|
|
69
75
|
}
|
|
@@ -135,9 +141,26 @@ function writeBodyFile(body) {
|
|
|
135
141
|
export function buildCreateIssueArgv(repo, title, body) {
|
|
136
142
|
return ['issue', 'create', '--repo', repo, '--title', title, '--body-file', writeBodyFile(body)];
|
|
137
143
|
}
|
|
138
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Pure builder: the argv for `gh issue list` restricted to open issues
|
|
146
|
+
* matching `query`. Without --json, gh prints a human-readable table that
|
|
147
|
+
* parseSearchResult can never parse as JSON, folding a real search into a
|
|
148
|
+
* generic failure indistinguishable from an actual nonzero exit. The fields
|
|
149
|
+
* requested here are exactly the ones parseSearchResult reads off each item.
|
|
150
|
+
*/
|
|
139
151
|
export function buildSearchIssuesArgv(repo, query) {
|
|
140
|
-
return [
|
|
152
|
+
return [
|
|
153
|
+
'issue',
|
|
154
|
+
'list',
|
|
155
|
+
'--repo',
|
|
156
|
+
repo,
|
|
157
|
+
'--state',
|
|
158
|
+
'open',
|
|
159
|
+
'--json',
|
|
160
|
+
'number,body,url',
|
|
161
|
+
'--search',
|
|
162
|
+
query,
|
|
163
|
+
];
|
|
141
164
|
}
|
|
142
165
|
/** Pure builder: the argv for `gh issue comment`. Spawns nothing. */
|
|
143
166
|
export function buildCommentArgv(repo, issueNumber, body) {
|
|
@@ -44,7 +44,11 @@ export function redactCredentials(text) {
|
|
|
44
44
|
return colon === -1 ? `${scheme}***@` : `${scheme}${userinfo.slice(0, colon)}:***@`;
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `onConflict`, when given, reads what git said from stdout on exit 1 — for
|
|
49
|
+
* the commands (merge-tree) that report a conflict there, not on stderr.
|
|
50
|
+
*/
|
|
51
|
+
function exec(cwd, args, onConflict) {
|
|
48
52
|
try {
|
|
49
53
|
return execFileSync('git', args, {
|
|
50
54
|
cwd,
|
|
@@ -57,7 +61,9 @@ function exec(cwd, args) {
|
|
|
57
61
|
// A child that never spawned has no exit code; `code` carries the errno
|
|
58
62
|
// string instead, and that is the only thing worth reporting.
|
|
59
63
|
const status = typeof e.status === 'number' ? e.status : null;
|
|
60
|
-
const said =
|
|
64
|
+
const said = onConflict !== undefined && status === 1
|
|
65
|
+
? onConflict(e.stdout ?? '')
|
|
66
|
+
: (e.stderr ?? (typeof e.code === 'string' ? `git could not be run (${e.code})` : ''));
|
|
61
67
|
throw new GitCommandError(cwd, args, status, redactCredentials(said));
|
|
62
68
|
}
|
|
63
69
|
}
|
|
@@ -72,6 +78,18 @@ export function runGit(cwd, args) {
|
|
|
72
78
|
export function runGitRaw(cwd, args) {
|
|
73
79
|
return exec(cwd, args);
|
|
74
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* `git merge-tree --write-tree` of two commits: the merged tree's id, with no
|
|
83
|
+
* working tree or index touched. A conflict (exit 1) throws GitCommandError
|
|
84
|
+
* carrying the conflicted paths and git's CONFLICT messages, which merge-tree
|
|
85
|
+
* prints on stdout rather than stderr.
|
|
86
|
+
*/
|
|
87
|
+
export function writeMergeTree(cwd, base, head) {
|
|
88
|
+
const args = ['merge-tree', '--write-tree', '--name-only', base, head];
|
|
89
|
+
// stdout on a conflict is the would-be tree id, then what conflicted.
|
|
90
|
+
const out = exec(cwd, args, (stdout) => stdout.split('\n').slice(1).join('\n'));
|
|
91
|
+
return out.split('\n')[0];
|
|
92
|
+
}
|
|
75
93
|
/**
|
|
76
94
|
* The `origin` remote's URL, exactly as git reports it — credentials and
|
|
77
95
|
* all, since redaction happens only on the error path inside `exec`. Throws
|
|
@@ -4,7 +4,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
4
4
|
import { loadBudgetPolicy } from './budgets.js';
|
|
5
5
|
import { SmithError } from './errors.js';
|
|
6
6
|
import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
|
|
7
|
-
import { loadGuardrailPolicy } from './policy.js';
|
|
7
|
+
import { INSPECTED_FILE_TOOLS, loadGuardrailPolicy } from './policy.js';
|
|
8
8
|
import { loadTaxonomy } from './taxonomy.js';
|
|
9
9
|
/**
|
|
10
10
|
* The worker harness port: *how* a turn runs, named at last.
|
|
@@ -81,11 +81,15 @@ import { loadTaxonomy } from './taxonomy.js';
|
|
|
81
81
|
* weaker promise than the in-process path's sandbox lease — it is a
|
|
82
82
|
* differently-shaped one. An in-process judge runs under guard.sh with a
|
|
83
83
|
* Bash tool in hand, and the lease is what stops it from writing; a cli judge
|
|
84
|
-
*
|
|
85
|
-
* revocation cannot improve on. Rule 6's
|
|
86
|
-
* runs either way, because a read-only
|
|
87
|
-
* what
|
|
88
|
-
*
|
|
84
|
+
* whose `judge_args` this factory has read as read-only never has the ability
|
|
85
|
+
* to write at all, which a lease revocation cannot improve on. Rule 6's
|
|
86
|
+
* fingerprint-before-and-after still runs either way, because a read-only
|
|
87
|
+
* flag is what the harness promises, not what this factory has watched
|
|
88
|
+
* happen. `judge_args` that do not read as read-only — absent, or present
|
|
89
|
+
* and spent on something else — mean the harness makes no such promise, and
|
|
90
|
+
* the refusal stands as before. Which flags read that way, for which
|
|
91
|
+
* program, is declared at `judgeReadOnly` below; it is the one thing about
|
|
92
|
+
* this escape valve that must not be taken on trust.
|
|
89
93
|
*
|
|
90
94
|
* `schema_args` is a smaller instance of the same shape: arguments appended
|
|
91
95
|
* only when the request resolved a schema, so a turn that validates nothing
|
|
@@ -365,6 +369,82 @@ function substitute(template, values, harness) {
|
|
|
365
369
|
return value;
|
|
366
370
|
});
|
|
367
371
|
}
|
|
372
|
+
/** `--flag value` or `--flag=value`. The last occurrence wins, as an argv does. */
|
|
373
|
+
function flagValue(args, names) {
|
|
374
|
+
let found = null;
|
|
375
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
376
|
+
const arg = args[i];
|
|
377
|
+
for (const name of names) {
|
|
378
|
+
if (arg === name) {
|
|
379
|
+
const next = args[i + 1];
|
|
380
|
+
if (next !== undefined)
|
|
381
|
+
found = next;
|
|
382
|
+
}
|
|
383
|
+
else if (arg.startsWith(`${name}=`)) {
|
|
384
|
+
found = arg.slice(name.length + 1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return found;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* A Map rather than an object literal on purpose: the key is a program name
|
|
392
|
+
* read off a policy file, and `Object.prototype` answers to several of them.
|
|
393
|
+
*/
|
|
394
|
+
const READ_ONLY_JUDGE_FLAGS = new Map([
|
|
395
|
+
// codex brings its own sandbox: `read-only` is enforced by the OS, so the
|
|
396
|
+
// worktree path the process holds is a path it can only read.
|
|
397
|
+
[
|
|
398
|
+
'codex',
|
|
399
|
+
(args) => {
|
|
400
|
+
const sandbox = flagValue(args, ['-s', '--sandbox']);
|
|
401
|
+
if (sandbox === 'read-only')
|
|
402
|
+
return { promised: true, flag: '-s read-only' };
|
|
403
|
+
return {
|
|
404
|
+
promised: false,
|
|
405
|
+
reason: sandbox === null
|
|
406
|
+
? 'its judge_args name no `-s read-only`'
|
|
407
|
+
: `its judge_args ask for the \`${sandbox}\` sandbox, which is not read-only`,
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
// claude has no filesystem sandbox to ask for; it has the tools it is
|
|
412
|
+
// given. Denying every tool that writes a file is the same promise by a
|
|
413
|
+
// different mechanism — and which tools those are is policy.ts's roster,
|
|
414
|
+
// read from there rather than typed again here.
|
|
415
|
+
[
|
|
416
|
+
'claude',
|
|
417
|
+
(args) => {
|
|
418
|
+
const denied = flagValue(args, ['--disallowedTools']);
|
|
419
|
+
if (denied === null) {
|
|
420
|
+
return { promised: false, reason: 'its judge_args name no `--disallowedTools`' };
|
|
421
|
+
}
|
|
422
|
+
const names = new Set(denied.split(',').map((name) => name.trim()));
|
|
423
|
+
const left = INSPECTED_FILE_TOOLS.filter((tool) => !names.has(tool));
|
|
424
|
+
if (left.length === 0)
|
|
425
|
+
return { promised: true, flag: `--disallowedTools ${denied}` };
|
|
426
|
+
return {
|
|
427
|
+
promised: false,
|
|
428
|
+
reason: `its judge_args leave ${left.join(', ')} in the judge's hands`,
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
]);
|
|
433
|
+
function judgeReadOnly(harness) {
|
|
434
|
+
if (harness.judgeArgs.length === 0) {
|
|
435
|
+
return { promised: false, reason: 'it declares no judge_args at all' };
|
|
436
|
+
}
|
|
437
|
+
const program = harness.command === null ? '' : path.basename(harness.command);
|
|
438
|
+
const recognise = READ_ONLY_JUDGE_FLAGS.get(program);
|
|
439
|
+
if (recognise === undefined) {
|
|
440
|
+
const known = [...READ_ONLY_JUDGE_FLAGS.keys()].join(' and ');
|
|
441
|
+
return {
|
|
442
|
+
promised: false,
|
|
443
|
+
reason: `this factory cannot read a read-only flag for \`${program}\` (it reads ${known})`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return recognise(harness.judgeArgs);
|
|
447
|
+
}
|
|
368
448
|
/**
|
|
369
449
|
* Describe the process that would run this turn. Start nothing.
|
|
370
450
|
*
|
|
@@ -433,12 +513,14 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
433
513
|
template: templateRelative,
|
|
434
514
|
};
|
|
435
515
|
}
|
|
436
|
-
|
|
516
|
+
const readOnly = access === 'judge' && worktree !== null ? judgeReadOnly(harness) : null;
|
|
517
|
+
if (readOnly !== null && !readOnly.promised) {
|
|
437
518
|
// §18 rule 5, one step out from providers/types.ts. An external process
|
|
438
519
|
// holding a worktree path holds write access to it; no lease this side of
|
|
439
520
|
// the boundary can take that back — unless the harness's own policy entry
|
|
440
|
-
// declares judge_args that
|
|
441
|
-
|
|
521
|
+
// declares judge_args that are read-only and not merely present. See the
|
|
522
|
+
// header.
|
|
523
|
+
throw new HarnessError('harness.judge-worktree', `Harness "${harness.name}" runs a separate program, and ${role} is a judge role. A judge outside this process gets the prompt and nothing else — a worktree path it holds is write access no sandbox lease can revoke (architecture §18 rule 5). ${harness.name} could still serve ${role} a worktree if its policy entry declared judge_args that make the program read-only, but ${readOnly.reason}.`, { harness: harness.name, role, reason: readOnly.reason });
|
|
442
524
|
}
|
|
443
525
|
const tier = request.tier ?? tierFromTemplate(template.file);
|
|
444
526
|
const model = tier !== null ? (harness.models[tier] ?? null) : null;
|
|
@@ -503,7 +585,7 @@ export function summarizeHarnesses(options = {}) {
|
|
|
503
585
|
if (!templateFor(role, agentsDir).exists)
|
|
504
586
|
return false;
|
|
505
587
|
if (roleAccess(role, guardrails) === 'judge') {
|
|
506
|
-
return harness.kind === 'in-process' || harness.
|
|
588
|
+
return harness.kind === 'in-process' || judgeReadOnly(harness).promised;
|
|
507
589
|
}
|
|
508
590
|
return true;
|
|
509
591
|
}),
|