@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.
Files changed (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. 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
- * A finding still awaiting something. Derived from LEGAL_TRANSITIONS above by
78
- * reading, not by code: these are exactly the statuses with a non-empty
79
- * outgoing edge set that are not themselves a decision. `waived` has outgoing
80
- * edges too (a denial can reopen it) but is closed until one arrives, so it
81
- * belongs with `refuted`/`expired`/`fix-verified`/`amended` on the other side.
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
- 'raised',
100
- 'confirmed',
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` into `not-a-repo` or `no-origin`
53
- * before handing a resolved URL to `slugFromRemoteUrl`; any other error is
54
- * a programming or environment fault and is rethrown, not mis-filed.
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
- return { reason: 'no-origin', detail: `${dir} has no "origin" remote` };
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
- /** Pure builder: the argv for `gh issue list` restricted to open issues matching `query`. */
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 ['issue', 'list', '--repo', repo, '--state', 'open', '--search', query];
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
- function exec(cwd, args) {
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 = e.stderr ?? (typeof e.code === 'string' ? `git could not be run (${e.code})` : '');
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
- * with `judge_args` set never has the ability to write at all, which a lease
85
- * revocation cannot improve on. Rule 6's fingerprint-before-and-after still
86
- * runs either way, because a read-only flag is what the harness promises, not
87
- * what this factory has watched happen. An empty `judge_args` means the
88
- * harness makes no such promise, and the refusal stands as before.
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
- if (access === 'judge' && worktree !== null && harness.judgeArgs.length === 0) {
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 make the program read-only. See the header.
441
- 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 were to declare judge_args that make the program read-only.`, { harness: harness.name, role });
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.judgeArgs.length > 0;
588
+ return harness.kind === 'in-process' || judgeReadOnly(harness).promised;
507
589
  }
508
590
  return true;
509
591
  }),