omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/to-spec.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The to-spec grooming contract (#679, #772): the one structured result a
3
+ * groomer agent must answer in before anything it says may become a durable
4
+ * grooming verdict. #735 landed the durable grooming table, but no shipped
5
+ * agent contract, strict result parser, or persistence path. This module
6
+ * ships all three: the prompt lives in `briefs/to-spec.md`, the strict JSON
7
+ * Schema the task agent is invoked through is `TO_SPEC_SCHEMA`, and
8
+ * `recordToSpecGrooming` is the only persistence path — parse and validate
9
+ * first, then write through the existing `Store.upsertGrooming`.
10
+ *
11
+ * The store's verdict surface is `promotable | blocked | considered` (#735).
12
+ * The five contract verdicts map onto it — PROMOTABLE → `promotable`;
13
+ * ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
14
+ * PRODUCT DECISION → `blocked` — and a result that cannot be trusted
15
+ * (malformed JSON, a schema violation, no authoritative source, or a stale
16
+ * source) persists as `blocked` and never as `promotable`/`considered`.
17
+ * Reprocessing the same candidate is deterministic: the same input at the
18
+ * same observation time produces the same row, and malformed output never
19
+ * erases a prior valid (promotable/considered) result. Stale or source-less
20
+ * output does replace it — as blocked, because a judgeable refusal is
21
+ * information: the latest pass could not ground a verdict, so the candidate
22
+ * must not keep reading as groomed.
23
+ *
24
+ * The structured result survives a process/tick restart inside the record's
25
+ * existing `evidence` field — a JSON payload with a `kind` discriminator
26
+ * (`to-spec` | `to-spec-failure`) — so no `GroomingRecord` extension and no
27
+ * second store is needed. Nothing here launches a task, edits an issue,
28
+ * creates decomposition children, or touches the queue label: the
29
+ * orchestrator remains the only promotion authority.
30
+ *
31
+ * The invocation contract is OMP's native task-agent output schema: the
32
+ * later tick passes `outputSchema: TO_SPEC_SCHEMA` with `schemaMode:
33
+ * "strict"` to the task tool. Conductor re-validates whatever the agent
34
+ * returned before persisting, because the harness is permissive by design
35
+ * and the store vets only what this module hands it.
36
+ *
37
+ * One contract, every surface (#883): this Zod schema is the single source of
38
+ * truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
39
+ * `parseToSpecResult` persists against it, and the drift test pins the
40
+ * rendered brief, the shipped agent role, and the floor's return contract to
41
+ * exactly these fields — a field the prose asks for that the schema refuses
42
+ * is a candidate recorded blocked for following instructions.
43
+ */
44
+
45
+ import { z } from "zod";
46
+
47
+ import type { GroomingRecord, GroomingVerdict, Store } from "./types.ts";
48
+
49
+ /** The five verdicts a to-spec groomer may return (#772). */
50
+ export const TO_SPEC_VERDICTS = [
51
+ "ALREADY DONE",
52
+ "PROMOTABLE",
53
+ "NEEDS DECOMPOSITION",
54
+ "BLOCKED",
55
+ "NEEDS PRODUCT DECISION",
56
+ ] as const;
57
+
58
+ export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
59
+
60
+ /**
61
+ * How old an authoritative source may be before a result drawn from it no
62
+ * longer counts. A verdict computed over a default branch that has moved on
63
+ * may have missed a landing that invalidates it, so a result whose source was
64
+ * observed longer ago than this persists as `blocked(stale-source)` and can
65
+ * never read as promotable/considered. 24 hours: a groomed candidate must
66
+ * have looked at the code within a day, and a re-grooming pass a day later
67
+ * re-reads the live source anyway.
68
+ */
69
+ export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
70
+
71
+ /** `routing: "MULTI"` requires `routingSplit` naming one repo per slice. */
72
+ const MULTI_ROUTING = "MULTI" as const;
73
+
74
+ const ToSpecSourceSchema = z
75
+ .object({
76
+ name: z.string().trim().min(1).describe("The authoritative source that was read: repo or tracker, e.g. `TerrifiedBug/conductor`."),
77
+ ref: z.string().trim().min(1).describe("The exact ref (commit/branch/tag) that was reviewed."),
78
+ freshAt: z
79
+ .number()
80
+ .int()
81
+ .describe("Epoch milliseconds when the source was observed — required so staleness is judgeable without trusting prose."),
82
+ })
83
+ .strict()
84
+ .describe("The authoritative source/ref/freshness every verdict must stand on.");
85
+
86
+ /**
87
+ * The one strict contract a grooming result must satisfy before it may
88
+ * persist as promotable or considered (and everything else maps onto blocked
89
+ * or is refused). `proposedBrief` and `reasonNotToPromote` are exactly one of
90
+ * the two, chosen by verdict: PROMOTABLE carries the brief, every other
91
+ * verdict states why it must not be promoted.
92
+ */
93
+ const ToSpecResultSchema = z
94
+ .object({
95
+ verdict: z.enum(TO_SPEC_VERDICTS).describe("Exactly one of the five to-spec verdicts."),
96
+ routing: z
97
+ .string()
98
+ .trim()
99
+ .min(1)
100
+ .describe("Exactly one `owner/repo`, or `MULTI` with `routingSplit` naming the split."),
101
+ routingSplit: z
102
+ .record(z.string(), z.string())
103
+ .optional()
104
+ .describe("Required when `routing` is `MULTI`: what each slice goes to."),
105
+ source: ToSpecSourceSchema,
106
+ evidence: z
107
+ .array(z.string().trim().min(1))
108
+ .optional()
109
+ .describe(
110
+ "The files/symbols proving the verdict — required for ALREADY DONE (the code that already does the work, never a title match); welcome on every verdict.",
111
+ ),
112
+ laterWorkInvalidates: z
113
+ .boolean()
114
+ .describe("Whether later work (an epic committed after this candidate was filed) invalidated its premise."),
115
+ laterWorkNote: z
116
+ .string()
117
+ .trim()
118
+ .min(1)
119
+ .describe("What was searched for the invalidating-later-work check and what was found — the check's evidence."),
120
+ entryPoints: z
121
+ .array(z.string().trim().min(1))
122
+ .min(3)
123
+ .max(6)
124
+ .describe("3-6 files to change or read first, the discovery a worker budget dies on when absent."),
125
+ existingTests: z
126
+ .array(z.string().trim().min(1))
127
+ .describe("Tests that already exercise the behaviour, by path; empty when none exist."),
128
+ likelySilentFake: z
129
+ .string()
130
+ .trim()
131
+ .min(1)
132
+ .describe("The single thing most likely to be silently faked while implementing and how to prove it is not."),
133
+ proofCommands: z
134
+ .array(z.string().trim().min(1))
135
+ .min(1)
136
+ .describe("Focused commands that prove the work, each with its cwd when relevant."),
137
+ fileLane: z
138
+ .array(z.string().trim().min(1))
139
+ .min(1)
140
+ .describe("The files/dirs this slice writes — the file lane that serialises concurrent work."),
141
+ dependencies: z
142
+ .array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
143
+ .describe(
144
+ 'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
145
+ ),
146
+ proposedBrief: z
147
+ .string()
148
+ .trim()
149
+ .min(1)
150
+ .optional()
151
+ .describe("Required for PROMOTABLE: the brief a worker would be dispatched with."),
152
+ reasonNotToPromote: z
153
+ .string()
154
+ .trim()
155
+ .min(1)
156
+ .optional()
157
+ .describe("Required for every verdict except PROMOTABLE: why this must not be promoted."),
158
+ })
159
+ .strict()
160
+ .superRefine((value, ctx) => {
161
+ const verdict = value.verdict;
162
+ if (verdict === "PROMOTABLE") {
163
+ if (value.proposedBrief === undefined) {
164
+ ctx.addIssue({ code: "custom", message: "PROMOTABLE requires proposedBrief" });
165
+ }
166
+ if (value.reasonNotToPromote !== undefined) {
167
+ ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
168
+ }
169
+ } else {
170
+ if (value.reasonNotToPromote === undefined) {
171
+ ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
172
+ }
173
+ if (value.proposedBrief !== undefined) {
174
+ ctx.addIssue({ code: "custom", message: `${verdict} must not carry proposedBrief` });
175
+ }
176
+ }
177
+ if (verdict === "ALREADY DONE" && (value.evidence === undefined || value.evidence.length === 0)) {
178
+ ctx.addIssue({
179
+ code: "custom",
180
+ message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
181
+ });
182
+ }
183
+ if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
184
+ ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
185
+ }
186
+ if (value.routing !== MULTI_ROUTING && value.routingSplit !== undefined) {
187
+ ctx.addIssue({ code: "custom", message: "routingSplit is only valid with routing `MULTI`" });
188
+ }
189
+ })
190
+ .describe("A complete, to-spec grooming result; nothing outside this shape is accepted.");
191
+
192
+ /** The strict JSON Schema the OMP task tool validates the agent's structured
193
+ * output with (`outputSchema` + `schemaMode: "strict"`, #772). */
194
+ export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
195
+
196
+ export type ToSpecResult = z.infer<typeof ToSpecResultSchema>;
197
+
198
+ /** Why a result could not be trusted; each persists as a blocked record. */
199
+ export type ToSpecFailure =
200
+ | { kind: "malformed"; detail: string }
201
+ | { kind: "missing-source"; detail: string }
202
+ | { kind: "stale-source"; detail: string };
203
+
204
+ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: false; failure: ToSpecFailure };
205
+
206
+ /** The row's `evidence` when a result passed validation. */
207
+ export interface ToSpecEvidence {
208
+ kind: "to-spec";
209
+ result: ToSpecResult;
210
+ }
211
+
212
+ /** The row's `evidence` when a result was refused. */
213
+ export interface ToSpecFailureEvidence {
214
+ kind: "to-spec-failure";
215
+ failure: ToSpecFailure;
216
+ /** The raw agent output, bounded, so a refusal is diagnosable. */
217
+ input: string;
218
+ }
219
+
220
+ /** How much raw agent output a failure record may carry. */
221
+ export const TO_SPEC_INPUT_SAMPLE_MAX = 2_000;
222
+
223
+ /** Refused results always land on the same store surface, named by failure. */
224
+ const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
225
+ malformed: "malformed",
226
+ "missing-source": "missing-source",
227
+ "stale-source": "stale-source",
228
+ };
229
+
230
+ /** The five contract verdicts onto the store's three (#735). */
231
+ function mapStoreVerdict(verdict: ToSpecVerdict): GroomingVerdict {
232
+ switch (verdict) {
233
+ case "PROMOTABLE":
234
+ return "promotable";
235
+ case "ALREADY DONE":
236
+ case "NEEDS DECOMPOSITION":
237
+ return "considered";
238
+ case "BLOCKED":
239
+ case "NEEDS PRODUCT DECISION":
240
+ return "blocked";
241
+ }
242
+ }
243
+
244
+ /** The groomer's own label the record carries (the #679 half of `reason`). */
245
+ function mapStoreReason(verdict: ToSpecVerdict): string {
246
+ switch (verdict) {
247
+ case "ALREADY DONE":
248
+ return "already-done";
249
+ case "PROMOTABLE":
250
+ return "promotable";
251
+ case "NEEDS DECOMPOSITION":
252
+ return "needs-decomposition";
253
+ case "BLOCKED":
254
+ return "blocked";
255
+ case "NEEDS PRODUCT DECISION":
256
+ return "needs-product-decision";
257
+ }
258
+ }
259
+
260
+ /** True for a JSON object value (null and arrays excluded). */
261
+ function isObject(value: unknown): value is Record<string, unknown> {
262
+ return typeof value === "object" && value !== null && !Array.isArray(value);
263
+ }
264
+
265
+ /**
266
+ * Pulls one fenced JSON block out of an agent answer, exactly like the setup
267
+ * probes: a model's text, parsed as JSON. Anything unparseable is malformed —
268
+ * no seed at all, and the candidate persists as blocked rather than groomed.
269
+ */
270
+ function extractJson(input: string): string {
271
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(input);
272
+ return (fenced?.[1] ?? input).trim();
273
+ }
274
+
275
+ /**
276
+ * Parse and validate a groomer's raw output against the to-spec contract.
277
+ * `now` is explicit so staleness and determinism are testable: the same
278
+ * input at the same observation time always yields the same outcome. On
279
+ * failure the outcome says *why* in one of the three refusal classes:
280
+ * unparseable/schema-breaking output is `malformed`, output that never names
281
+ * an authoritative source (or lacks name/ref/freshAt) is `missing-source`,
282
+ * and a source observed more than `TO_SPEC_MAX_SOURCE_AGE_MS` ago is
283
+ * `stale-source`. A `freshAt` in the future is a clock lie, hence malformed.
284
+ */
285
+ export function parseToSpecResult(input: string, now: number): ParseToSpecOutcome {
286
+ const body = extractJson(input);
287
+ if (body.length === 0) {
288
+ return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
289
+ }
290
+ let raw: unknown;
291
+ try {
292
+ raw = JSON.parse(body);
293
+ } catch {
294
+ return { ok: false, failure: { kind: "malformed", detail: "output is not valid JSON" } };
295
+ }
296
+ if (!isObject(raw)) {
297
+ return { ok: false, failure: { kind: "malformed", detail: "output is not a JSON object" } };
298
+ }
299
+ const parsed = ToSpecResultSchema.safeParse(raw);
300
+ if (!parsed.success) {
301
+ const detail = parsed.error.issues[0]?.message ?? "schema violation";
302
+ const source = raw.source;
303
+ if (!isObject(source) || source.name === undefined || source.ref === undefined || source.freshAt === undefined) {
304
+ return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
305
+ }
306
+ return { ok: false, failure: { kind: "malformed", detail } };
307
+ }
308
+ const freshAt = parsed.data.source.freshAt;
309
+ if (freshAt > now) {
310
+ return { ok: false, failure: { kind: "malformed", detail: `source freshAt ${freshAt} lies in the future of ${now}` } };
311
+ }
312
+ if (now - freshAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
313
+ return {
314
+ ok: false,
315
+ failure: {
316
+ kind: "stale-source",
317
+ detail: `source observed at ${freshAt} is ${now - freshAt}ms old — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
318
+ },
319
+ };
320
+ }
321
+ return { ok: true, result: parsed.data };
322
+ }
323
+
324
+ /** What one grooming pass did to the row, so the caller can report it. */
325
+ export type ToSpecGroomingOutcome =
326
+ | { kind: "persisted"; record: GroomingRecord }
327
+ | { kind: "kept-prior"; record: GroomingRecord };
328
+
329
+ /** One candidate's grooming pass. */
330
+ export interface ToSpecGroomingRequest {
331
+ project: string;
332
+ issue: number;
333
+ /** The agent's raw output, exactly as returned. */
334
+ input: string;
335
+ /** Observation time for staleness and the recorded row; `Date.now()` when
336
+ * omitted (tests pass it to keep reprocessing deterministic). */
337
+ now?: number;
338
+ }
339
+
340
+ function failureEvidence(failure: ToSpecFailure, input: string): string {
341
+ const sample =
342
+ input.length > TO_SPEC_INPUT_SAMPLE_MAX ? `${input.slice(0, TO_SPEC_INPUT_SAMPLE_MAX)}…[truncated]` : input;
343
+ const payload: ToSpecFailureEvidence = { kind: "to-spec-failure", failure, input: sample };
344
+ return JSON.stringify(payload);
345
+ }
346
+
347
+ /**
348
+ * The only way a groomer's output becomes a durable grooming verdict: parse
349
+ * and validate, then upsert through the existing store — no second store, no
350
+ * `GroomingRecord` extension. Valid results map to the store's verdict
351
+ * surface with the full structured result serialized into `evidence`
352
+ * (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
353
+ * refused result persists as `blocked` with the failure named in
354
+ * `evidence` (`to-spec-failure`) — never as promotable/considered — except
355
+ * when a prior valid result exists: malformed reprocessing then keeps that
356
+ * prior row instead of erasing it. Nothing here touches labels or issues.
357
+ */
358
+ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
359
+ const now = request.now ?? Date.now();
360
+ const parsed = parseToSpecResult(request.input, now);
361
+ if (parsed.ok) {
362
+ const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
363
+ store.upsertGrooming({
364
+ project: request.project,
365
+ issue: request.issue,
366
+ verdict: mapStoreVerdict(parsed.result.verdict),
367
+ reason: mapStoreReason(parsed.result.verdict),
368
+ evidence: JSON.stringify(evidence),
369
+ at: now,
370
+ });
371
+ return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
372
+ }
373
+ const prior = store.grooming(request.project, request.issue);
374
+ if (
375
+ parsed.failure.kind === "malformed" &&
376
+ prior !== undefined &&
377
+ (prior.verdict === "promotable" || prior.verdict === "considered")
378
+ ) {
379
+ return { kind: "kept-prior", record: prior };
380
+ }
381
+ store.upsertGrooming({
382
+ project: request.project,
383
+ issue: request.issue,
384
+ verdict: "blocked",
385
+ reason: FAILURE_REASON[parsed.failure.kind],
386
+ evidence: failureEvidence(parsed.failure, request.input),
387
+ at: now,
388
+ });
389
+ return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
390
+ }
391
+
392
+ /**
393
+ * Recover the validated result from a record's evidence (the restart
394
+ * round-trip). Returns undefined for anything that is not a `to-spec`
395
+ * payload or fails validation again, so a hand-edited row cannot read back
396
+ * as groomed.
397
+ */
398
+ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined {
399
+ let raw: unknown;
400
+ try {
401
+ raw = JSON.parse(evidence);
402
+ } catch {
403
+ return undefined;
404
+ }
405
+ if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
406
+ const parsed = ToSpecResultSchema.safeParse(raw.result);
407
+ return parsed.success ? parsed.data : undefined;
408
+ }
@@ -132,6 +132,30 @@ export class GhError extends Error {
132
132
  }
133
133
  }
134
134
 
135
+ /**
136
+ * The one classified failure {@link Tracker.prState} throws (#779): the named
137
+ * pull request definitively does not exist. It is thrown only for an
138
+ * individual REST 404 that a same-repository pulls-list read has corroborated
139
+ * (see {@link repoPullReadUnavailable}) — a deleted or never-created PR number
140
+ * in a repository the credential can still read. A repository hidden from the
141
+ * credential (private, lost token scope, SSO enforcement) also answers 404,
142
+ * so without that corroboration a 404 stays "could not tell".
143
+ *
144
+ * Distinct from a plain {@link GhError} because a corroborated 404 is an
145
+ * answer, not an outage: retrying can never conjure the PR, so the settle
146
+ * sweep releases the stranded row — over its preserved branch — instead of
147
+ * re-asking a nonexistent PR forever. Every other failure (a revoked token, a
148
+ * flaky network, a server 5xx, an opaque 404) still resolves to `undefined`
149
+ * from `prState`, and the port's "could not tell, ask again for free" callers
150
+ * never see it.
151
+ */
152
+ export class GhPrMissingError extends GhError {
153
+ constructor(cause: GhError) {
154
+ super(cause.argv, cause.code, cause.stderr, cause.stdout);
155
+ this.name = "GhPrMissingError";
156
+ }
157
+ }
158
+
135
159
  /**
136
160
  * The single execution path for this adapter. `stdin` is written to the child
137
161
  * and closed, which is how bodies containing newlines, backticks or a leading
@@ -1308,6 +1332,20 @@ export function isTransientServerError(err: unknown): boolean {
1308
1332
  return err instanceof GhError && isTransientServer5xx(err.stderr);
1309
1333
  }
1310
1334
 
1335
+ /**
1336
+ * True when a `gh` failure is the REST layer answering 404 for the requested
1337
+ * resource. The raw status is deliberately not the definitive verdict: GitHub
1338
+ * returns the same 404 for a PR number absent from a readable repository and
1339
+ * for a whole repository hidden from the credential, so the caller must
1340
+ * corroborate against a same-repository pull-read (see
1341
+ * {@link repoPullReadUnavailable}) before a 404 means "the PR is gone". What a
1342
+ * 404 does always mean is that it is not a 5xx ("ask later") and not a 401/403
1343
+ * credential refusal. gh's stderr renders the status as `HTTP 404: Not Found`.
1344
+ */
1345
+ export function isMissingPr404(err: unknown): err is GhError {
1346
+ return err instanceof GhError && /HTTP 404\b/i.test(err.stderr);
1347
+ }
1348
+
1311
1349
  /**
1312
1350
  * True when a `gh` argv rides the GraphQL API surface rather than core REST.
1313
1351
  *
@@ -1403,6 +1441,35 @@ export class GhRateLimitError extends GhError {
1403
1441
  * rate-limit refusal opens and the GraphQL-only gate a 5xx opens. */
1404
1442
  export const RATE_LIMIT_COOLDOWN_MS = 60_000;
1405
1443
 
1444
+ /**
1445
+ * The control that makes an individual PR read's 404 definitive (#781 review
1446
+ * round 1): whether the repository named by `parts` answers a Pull Requests
1447
+ * list read to the current credential.
1448
+ *
1449
+ * GitHub returns the same 404 bytes in two opposite situations — a PR number
1450
+ * that does not exist in a repository this credential can read, and a whole
1451
+ * repository hidden from the credential (private, lost token scope, SSO
1452
+ * enforcement) — so a bare 404 on the individual read says nothing. Asking
1453
+ * the same-repository pulls surface splits them: a 200, even `[]`, proves
1454
+ * pull-read access and makes the individual 404 a genuinely missing PR; a
1455
+ * control that is itself unavailable leaves the question open and the caller
1456
+ * must keep its row retryable. Returns true when pull-read access cannot be
1457
+ * proven.
1458
+ */
1459
+ async function repoPullReadUnavailable(
1460
+ runGh: typeof gh,
1461
+ parts: { owner: string; repo: string },
1462
+ ): Promise<boolean> {
1463
+ // `--jq length` bounds the payload to one integer while still exercising the
1464
+ // exact pull-read surface; `gh api` returns the first page only by default.
1465
+ try {
1466
+ await runGh(["api", `repos/${parts.owner}/${parts.repo}/pulls`, "--jq", "length"]);
1467
+ return false;
1468
+ } catch {
1469
+ return true;
1470
+ }
1471
+ }
1472
+
1406
1473
  /** Instrumentation hooks the daemon binds the tracker to (#198). */
1407
1474
  export interface TrackerHooks {
1408
1475
  /** Fired immediately before each `gh` spawn, so the daemon can count its own
@@ -1734,12 +1801,41 @@ export function makeTracker(
1734
1801
  "{state, merged_at}",
1735
1802
  ]),
1736
1803
  );
1804
+ } catch (err) {
1805
+ // One classified throw, and only one: an individual REST 404 that the
1806
+ // same-repository pulls read corroborates. 404 alone is ambiguous —
1807
+ // GitHub hides a whole repository the credential cannot read as the
1808
+ // same 404, so a lost token scope or SSO change would otherwise
1809
+ // terminalize every real run (#781 review round 1). Only a PR number
1810
+ // that is genuinely gone while the repo still answers pull-read access
1811
+ // is definitively missing. Everything else — a hidden/private repo, a
1812
+ // revoked token, a flaky network, a server 5xx — stays undefined per
1813
+ // the port's fail-closed contract, and the row retries for free.
1814
+ if (isMissingPr404(err) && !(await repoPullReadUnavailable(runGh, parts))) {
1815
+ throw new GhPrMissingError(err);
1816
+ }
1817
+ return undefined;
1818
+ }
1819
+ },
1820
+
1821
+ async prHead(url: string): Promise<string | undefined> {
1822
+ if (!PR_URL.test(url)) return undefined;
1823
+ const parts = prUrlParts(url);
1824
+ if (parts === undefined) return undefined;
1825
+ try {
1826
+ // The REST pull-request projection carries the head at `.head.sha` —
1827
+ // there is no top-level `head_sha` (verified live on #812). The jq
1828
+ // renames it, the same idiom `verifyPrRest` uses, so the shared
1829
+ // `restPrHeadFrom` parser sees the shape it is tested against.
1830
+ return restPrHeadFrom(
1831
+ await runGh([
1832
+ "api",
1833
+ `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
1834
+ "--jq",
1835
+ "{head_sha: .head.sha}",
1836
+ ]),
1837
+ );
1737
1838
  } catch {
1738
- // Never throws, per the port's contract. A deleted PR, a revoked token
1739
- // and a flaky network all mean "could not tell", and the caller's whole
1740
- // job is to leave the row alone on that — so classifying them here would
1741
- // buy nothing but a way to get the classification wrong. The next tick
1742
- // asks again for free.
1743
1839
  return undefined;
1744
1840
  }
1745
1841
  },
@@ -2076,15 +2172,9 @@ export function makeTracker(
2076
2172
  },
2077
2173
 
2078
2174
  async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> {
2079
- try {
2080
- return labeledIssuesFrom(
2081
- await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
2082
- );
2083
- } catch {
2084
- // Also the answer on a repo without the sub-issues API, or an issue that
2085
- // was never decomposed. All three mean the same thing to the caller.
2086
- return [];
2087
- }
2175
+ return labeledIssuesFrom(
2176
+ await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]),
2177
+ );
2088
2178
  },
2089
2179
  };
2090
2180