omp-conductor 0.16.0 → 0.16.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.
@@ -7,24 +7,146 @@
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
- import { closeSync, openSync, readSync, statSync } from "node:fs";
10
+ import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
11
+ import { join } from "node:path";
11
12
  import { findProject, loadConfig } from "../config.ts";
12
13
  import { dbPath, LIVE_STATES, openStore } from "../store.ts";
13
- import { formatTranscriptLine } from "../transcript.ts";
14
+ import { formatTranscriptLine, prop } from "../transcript.ts";
15
+ import type { Store } from "../types.ts";
14
16
 
15
- /** How often `tail` re-stats the transcript it is following. */
17
+ /** How often `tail` re-stats the transcripts it is following. */
16
18
  const TAIL_POLL_MS = 1_000;
17
19
 
18
20
  /**
19
- * How long the transcript must stay unchanged, after its run has left the live
21
+ * How long the transcripts must stay unchanged, after its run has left the live
20
22
  * states, before `tail` calls it over. The state flips from the daemon's thread
21
23
  * while the harness may still be flushing its last message, so exiting on the
22
24
  * state alone truncates the ending an operator ran this command to watch.
23
25
  */
24
26
  const TAIL_QUIET_MS = 5_000;
25
27
 
28
+ /** The harness records advisor turns under this reserved stem. */
29
+ const ADVISOR_TRANSCRIPT_PREFIX = "__advisor";
30
+
31
+ /**
32
+ * One followed file: its open descriptor, how many bytes have been rendered,
33
+ * and whatever byte tail did not end on a newline yet. Holding the partial line
34
+ * across polls is what stops a UTF-8 sequence straddling a read boundary (or
35
+ * the newline itself) from being mangled by decoding each chunk on its own.
36
+ */
37
+ interface FollowedStream {
38
+ fd: number;
39
+ offset: number;
40
+ pending: Buffer;
41
+ }
42
+
26
43
  /**
27
- * Follow one run's transcript the way `tail -f` follows a log.
44
+ * Return the complete lines appended to `file` since the last poll, plus
45
+ * whether any bytes changed at all (the quiet-timer needs the latter even when
46
+ * a delta rendered no displayable line). A file that shrank (truncated or
47
+ * replaced) restarts from zero; a final line without a newline stays in
48
+ * `pending` until its newline lands.
49
+ */
50
+ function followLines(
51
+ state: FollowedStream,
52
+ file: string,
53
+ ): { lines: Buffer[]; changed: boolean } {
54
+ const lines: Buffer[] = [];
55
+ let changed = false;
56
+ let size = 0;
57
+ try {
58
+ size = statSync(file).size;
59
+ } catch {
60
+ // A transcript that vanishes mid-follow is not worth crashing over.
61
+ return { lines, changed };
62
+ }
63
+ // Shorter than what we have already read means truncated or replaced;
64
+ // resuming from the old offset would read the middle of another file.
65
+ if (size < state.offset) {
66
+ state.offset = 0;
67
+ state.pending = Buffer.alloc(0);
68
+ }
69
+ if (size > state.offset) {
70
+ const chunk = Buffer.allocUnsafe(size - state.offset);
71
+ const read = readSync(state.fd, chunk, 0, chunk.length, state.offset);
72
+ state.offset += read;
73
+ state.pending = Buffer.concat([state.pending, chunk.subarray(0, read)]);
74
+ for (;;) {
75
+ const nl = state.pending.indexOf(0x0a);
76
+ if (nl < 0) break;
77
+ lines.push(state.pending.subarray(0, nl));
78
+ state.pending = state.pending.subarray(nl + 1);
79
+ }
80
+ changed = read > 0;
81
+ }
82
+ return { lines, changed };
83
+ }
84
+
85
+ /**
86
+ * One advisor-transcript line rendered for the watcher, or `undefined` for the
87
+ * lines not worth a row. An advisor speaks in two forms, and an operator
88
+ * watching a run wants both:
89
+ *
90
+ * - plain assistant text, rendered exactly like the primary transcript's
91
+ * (one spelling, so the two cannot drift), and
92
+ * - `advise` tool calls — the reviewer's actual advisory, whose note and
93
+ * severity live in the call arguments and would otherwise be invisible.
94
+ *
95
+ * The reviewer's other tool calls (`glob`/`read` probes into the workspace)
96
+ * render nothing: they are the investigation, not the advice. Every rendered
97
+ * line is marked so the watcher can tell which reviewer produced it. Exported
98
+ * so a unit test pins the surface without a store.
99
+ */
100
+ export function renderAdvisorLine(line: string): string | undefined {
101
+ let entry: unknown;
102
+ try {
103
+ entry = JSON.parse(line);
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ if (prop(entry, "type") !== "message") return undefined;
108
+ const message = prop(entry, "message");
109
+ if (prop(message, "role") !== "assistant") return undefined;
110
+
111
+ const content = prop(message, "content");
112
+ // A string-typed message is one plain text block — the same shape the
113
+ // primary transcript's formatter accepts, so an advisor advisory written as
114
+ // a bare string is not silently dropped.
115
+ if (typeof content === "string") {
116
+ return content.trim() === "" ? undefined : `[advisor] assistant: ${content.trim()}`;
117
+ }
118
+ const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
119
+ const out: string[] = [];
120
+ for (const block of blocks) {
121
+ const type = prop(block, "type");
122
+ if (type === "text") {
123
+ const text = prop(block, "text");
124
+ if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
125
+ } else if (type === "toolCall" && prop(block, "name") === "advise") {
126
+ let args = prop(block, "arguments");
127
+ if (typeof args === "string") {
128
+ try {
129
+ args = JSON.parse(args);
130
+ } catch {
131
+ continue;
132
+ }
133
+ }
134
+ const note = prop(args, "note");
135
+ if (typeof note === "string" && note.trim() !== "") {
136
+ const severity = prop(args, "severity");
137
+ const label = typeof severity === "string" && severity !== "" ? severity : "nit";
138
+ out.push(`${label}: ${note.trim()}`);
139
+ }
140
+ }
141
+ }
142
+ return out.length === 0 ? undefined : `[advisor] ${out.join("\n")}`;
143
+ }
144
+
145
+ /**
146
+ * Follow one run's transcripts the way `tail -f` follows a log: the worker's
147
+ * own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
148
+ * reviewer's turns, #542), so an advisory the reviewer raised is visible in
149
+ * the same stream as the work it reacted to.
28
150
  *
29
151
  * Reads from byte zero rather than from the end: attaching to a worker that is
30
152
  * already ten turns in and then showing nothing until turn eleven is not
@@ -36,10 +158,24 @@ const TAIL_QUIET_MS = 5_000;
36
158
  * here is buffered, and a handler could only add a poll interval of latency to
37
159
  * every Ctrl-C.
38
160
  */
39
- async function tailRun(project: string, issue: number): Promise<void> {
40
- // Read-only in practice: the store is opened WAL with a busy timeout, so this
41
- // never contends with the daemon writing the same rows.
42
- const store = openStore(dbPath());
161
+ export async function tailRun(
162
+ project: string,
163
+ issue: number,
164
+ deps: {
165
+ /** Read-only in practice: opened WAL with a busy timeout. Tests inject one. */
166
+ store?: Store;
167
+ /** How often the transcripts are re-statted (tests shrink it). */
168
+ pollMs?: number;
169
+ /** How long a terminal run must stay quiet before the watcher exits. */
170
+ quietMs?: number;
171
+ /** Where rendered lines go; the CLI writes to stdout, tests capture. */
172
+ write?: (line: string) => void;
173
+ } = {},
174
+ ): Promise<void> {
175
+ const store = deps.store ?? openStore(dbPath());
176
+ const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
177
+ const pollMs = deps.pollMs ?? TAIL_POLL_MS;
178
+ const quietMs = deps.quietMs ?? TAIL_QUIET_MS;
43
179
  try {
44
180
  const run = store.latestRun(project, issue);
45
181
  if (run === undefined) throw new Error(`no run recorded for #${issue}`);
@@ -47,56 +183,80 @@ async function tailRun(project: string, issue: number): Promise<void> {
47
183
  // Claimed but not yet started, or an attempt whose session never opened one.
48
184
  if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
49
185
 
50
- const fd = openSync(path, "r");
186
+ const primary: FollowedStream = { fd: openSync(path, "r"), offset: 0, pending: Buffer.alloc(0) };
187
+ // Advisor transcripts live in the directory named after the primary
188
+ // transcript stem; the harness writes them there beside the session file.
189
+ const advisorDir = path.endsWith(".jsonl") ? path.slice(0, -".jsonl".length) : undefined;
190
+ const advisors = new Map<string, FollowedStream>();
51
191
  try {
52
- let offset = 0;
53
- let pending = Buffer.alloc(0);
54
192
  let lastChange = Date.now();
55
193
 
56
194
  for (;;) {
57
- let size = offset;
58
- try {
59
- size = statSync(path).size;
60
- } catch {
61
- // A transcript that vanishes mid-follow is not worth crashing over.
62
- // The run's own state, below, is what decides when this command ends.
195
+ let changed = false;
196
+ const primaryDelta = followLines(primary, path);
197
+ changed = primaryDelta.changed || changed;
198
+ for (const raw of primaryDelta.lines) {
199
+ const rendered = formatTranscriptLine(raw.toString("utf8"));
200
+ if (rendered !== undefined) write(rendered);
63
201
  }
64
- // Shorter than what we have already read means truncated or replaced;
65
- // resuming from the old offset would read the middle of another file.
66
- if (size < offset) {
67
- offset = 0;
68
- pending = Buffer.alloc(0);
69
- }
70
- if (size > offset) {
71
- const chunk = Buffer.allocUnsafe(size - offset);
72
- const read = readSync(fd, chunk, 0, chunk.length, offset);
73
- offset += read;
74
- // Split on newlines as bytes, not as text: a UTF-8 sequence straddling
75
- // a read boundary would be mangled by decoding each chunk on its own.
76
- pending = Buffer.concat([pending, chunk.subarray(0, read)]);
77
- for (;;) {
78
- const nl = pending.indexOf(0x0a);
79
- if (nl < 0) break;
80
- const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
81
- pending = pending.subarray(nl + 1);
82
- if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
202
+
203
+ // Follow every advisor transcript that appears beside the primary one.
204
+ // Files land mid-run (the reviewer's first turn), so each poll re-lists
205
+ // the directory and opens whatever is new.
206
+ if (advisorDir !== undefined) {
207
+ let names: string[] = [];
208
+ try {
209
+ names = readdirSync(advisorDir).filter(
210
+ (name) =>
211
+ name.startsWith(ADVISOR_TRANSCRIPT_PREFIX) &&
212
+ name.endsWith(".jsonl"),
213
+ );
214
+ } catch {
215
+ // No advisor directory — nothing to surface.
216
+ }
217
+ for (const name of names) {
218
+ const file = join(advisorDir, name);
219
+ let state = advisors.get(file);
220
+ if (state === undefined) {
221
+ try {
222
+ state = { fd: openSync(file, "r"), offset: 0, pending: Buffer.alloc(0) };
223
+ advisors.set(file, state);
224
+ } catch {
225
+ continue;
226
+ }
227
+ }
228
+ const advisorDelta = followLines(state, file);
229
+ changed = advisorDelta.changed || changed;
230
+ for (const raw of advisorDelta.lines) {
231
+ const rendered = renderAdvisorLine(raw.toString("utf8"));
232
+ if (rendered !== undefined) write(rendered);
233
+ }
234
+ }
235
+ // Drop watched files that vanished (mid-run rotation is rare, but a
236
+ // stale descriptor would otherwise pin the old inode forever).
237
+ for (const [file, state] of advisors) {
238
+ if (!names.includes(file.slice(advisorDir.length + 1))) {
239
+ closeSync(state.fd);
240
+ advisors.delete(file);
241
+ }
83
242
  }
84
- if (read > 0) lastChange = Date.now();
85
243
  }
244
+ if (changed) lastChange = Date.now();
86
245
 
87
246
  // Re-read this exact run every poll — not `latestRun`, which would jump
88
247
  // to a retry started meanwhile and report its state against the wrong
89
248
  // transcript. The daemon writes the row from another process, so looking
90
249
  // is the only way to notice the run finished.
91
250
  const state = store.getRun(run.id)?.state ?? run.state;
92
- if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
93
- process.stdout.write(`run ended: ${state}\n`);
251
+ if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= quietMs) {
252
+ write(`run ended: ${state}`);
94
253
  return;
95
254
  }
96
- await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
255
+ await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
97
256
  }
98
257
  } finally {
99
- closeSync(fd);
258
+ closeSync(primary.fd);
259
+ for (const state of advisors.values()) closeSync(state.fd);
100
260
  }
101
261
  } finally {
102
262
  store.close();
@@ -104,6 +264,6 @@ async function tailRun(project: string, issue: number): Promise<void> {
104
264
  }
105
265
 
106
266
  export async function tailCommand(ctx: CommandContext): Promise<void> {
107
- const issue = ctx.issueArg("tail", ctx.argv[1]);
108
- await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
267
+ const issue = ctx.issueArg("tail", ctx.argv[1]);
268
+ await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
109
269
  }
@@ -325,6 +325,10 @@ const projectSchema = z
325
325
  // schema owns. Conductor validates YAML shape only — the loader keeps it
326
326
  // when it is a mapping and drops anything else, like `modelFallbacks`.
327
327
  ompSettings: z.unknown().optional(),
328
+ // Opt-in omp advisor on workers (#542). Only the literal boolean `true`
329
+ // opts in (it stages `advisor.enabled: true` into the omp settings
330
+ // overlay); anything else is dropped by the loader, like `workerModel`.
331
+ workerAdvisor: z.unknown().optional(),
328
332
  escalation: escalationSchema.optional(),
329
333
  authority: authoritySchema.optional(),
330
334
  releasePolicy: releasePolicySchema.optional(),
package/src/config.ts CHANGED
@@ -1122,6 +1122,36 @@ function finalizeProject(
1122
1122
  ? (rawOmpSettings as Record<string, unknown>)
1123
1123
  : undefined;
1124
1124
 
1125
+ // Opt-in omp advisor on this project's workers (#542). `workerAdvisor: true`
1126
+ // stages `advisor.enabled: true` into the omp settings overlay — the same
1127
+ // #537 channel everything else uses — so the advisor's own behaviour (roster,
1128
+ // model, tools) stays omp's to resolve from the staged or global config, and
1129
+ // conductor never names a model or grants a tool. Only the literal boolean
1130
+ // `true` opts in; anything else (absent, false, a string) is dropped like an
1131
+ // unusable `modelFallbacks` entry, keeping today's dispatch byte for byte.
1132
+ const workerAdvisor = p["workerAdvisor"] === true;
1133
+ const effectiveOmpSettings =
1134
+ workerAdvisor || ompSettings !== undefined
1135
+ ? {
1136
+ ...(ompSettings === undefined ? {} : ompSettings),
1137
+ ...(workerAdvisor
1138
+ ? {
1139
+ // Merge, not replace: an operator's own `ompSettings.advisor`
1140
+ // block (roster or model overrides) survives; only `enabled` is
1141
+ // forced on, because that is the point of the opt-in.
1142
+ advisor: {
1143
+ ...(typeof ompSettings?.advisor === "object" &&
1144
+ ompSettings.advisor !== null &&
1145
+ !Array.isArray(ompSettings.advisor)
1146
+ ? (ompSettings.advisor as Record<string, unknown>)
1147
+ : {}),
1148
+ enabled: true,
1149
+ },
1150
+ }
1151
+ : {}),
1152
+ }
1153
+ : undefined;
1154
+
1125
1155
  // Marked critical-base/safety markers (commit SHAs or refs). A continuation
1126
1156
  // branch must contain every one before the dispatcher may reattach it;
1127
1157
  // unusable entries are dropped like `modelFallbacks` and an absent or empty
@@ -1151,7 +1181,7 @@ function finalizeProject(
1151
1181
  ...(workerModel === undefined ? {} : { workerModel }),
1152
1182
  ...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
1153
1183
  ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
1154
- ...(ompSettings === undefined ? {} : { ompSettings }),
1184
+ ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
1155
1185
  escalation,
1156
1186
  authority,
1157
1187
  releasePolicy,
package/src/daemon.ts CHANGED
@@ -296,6 +296,13 @@ interface Deps {
296
296
  * probe the way `criticalBase` does.
297
297
  */
298
298
  probeWorktreeLane?: RunLaneProbe;
299
+ /**
300
+ * Reads one issue's tracker state in a repository the admission tracker is
301
+ * not bound to — the cross-repo Depends-on interlock (#420). Wired by
302
+ * `runDaemon` to a repo-scoped tracker; a test injects a fake. Absent,
303
+ * admission fails a routed cross-repo prerequisite closed.
304
+ */
305
+ probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
299
306
  }
300
307
 
301
308
  /**
@@ -5584,6 +5591,15 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
5584
5591
  probeCriticalBase: (repo, markers, branch) =>
5585
5592
  probeCriticalBase(project, repo, branch, markers),
5586
5593
  probeWorktreeLane: (input) => probeRunLane(input),
5594
+ // A cross-repo Depends-on prerequisite reads through the same GitHub
5595
+ // credential/accounting seams as the project tracker — a fresh tracker
5596
+ // scoped to the referenced repo, reusing the daemon's gh hooks so API
5597
+ // spend and refusals are counted exactly as the main tracker's are.
5598
+ probeIssueIn: (ownerRepo, issue) =>
5599
+ makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
5600
+ onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
5601
+ onRefusal: (at) => store.recordGhRefusal?.(at),
5602
+ }).issueSnapshot(issue),
5587
5603
  ...(verbPeerReader === undefined ? {} : { verbPeerReader }),
5588
5604
  verbActions,
5589
5605
  };
package/src/depends-on.ts CHANGED
@@ -1,34 +1,50 @@
1
1
  /**
2
- * The `Depends-on:` declaration parser (epic #321, slice #419).
2
+ * The `Depends-on:` declaration parser (epic #321, slices #419/#420).
3
3
  *
4
- * A candidate issue can declare same-repo prerequisites it must not be
5
- * dispatched before: `Depends-on: #123` (repeatable, one or more per line).
6
- * Admission reads every referenced issue's live tracker state and holds the
7
- * candidate while any prerequisite is open, so a worker is never sent at work
8
- * whose base is not yet merged.
4
+ * A candidate issue can declare prerequisites it must not be dispatched
5
+ * before: `Depends-on: #123` (a same-repo issue) or `Depends-on:
6
+ * owner/repo#123` (an issue in a routed repository), repeatable one or more
7
+ * per line. Admission reads every referenced issue's live tracker state and
8
+ * holds the candidate while any prerequisite is open, so a worker is never
9
+ * sent at work whose base is not yet merged.
9
10
  *
10
11
  * The parser is deliberately small and strict, the two halves of the contract:
11
12
  * the *marker* is matched case-insensitively (so `DEPENDS-ON`, `Depends-On`,
12
- * `depends-on` all declare), while the *references* are strict bare issue
13
- * numbers (`#123`). A marker line that carries no strict reference — an empty
14
- * `Depends-on:`, or a bare non-numeric `Depends-on: #abc` is never a usable
15
- * prerequisite, so it is reported as `malformed` for the grooming flag rather
16
- * than guessed at, and the declaration it belongs to is ignored.
13
+ * `depends-on` all declare), while the *references* are strict — a bare issue
14
+ * number (`#123`) or a qualifying `owner/repo#123` with exactly two path
15
+ * segments glued to the number. A marker line that carries no strict
16
+ * reference an empty `Depends-on:`, a bare non-numeric `Depends-on: #abc`,
17
+ * or a slashed-but-unqualified form that is not `owner/repo#n` is never a
18
+ * usable prerequisite, so it is reported as `malformed` for the grooming flag
19
+ * rather than guessed at, and the declaration it belongs to is ignored.
17
20
  *
18
- * Cross-repo references (a qualifying `owner/repo#n` or `repo#n` form) and
19
- * graph cycles are later slices (#420/#421) and are deliberately out of scope
20
- * here: only plain `#<n>` same-repo forms are resolved.
21
+ * Graph cycles are a later slice (#421) and stay out of scope here.
21
22
  */
22
23
 
23
24
  export interface DependsOnDecl {
24
- /** Referenced prerequisite issue numbers, deduplicated, first-seen order. */
25
+ /** Referenced same-repo prerequisite issue numbers, deduplicated,
26
+ * first-seen order. */
25
27
  readonly refs: readonly number[];
26
- /** Marker-bearing lines that carried no strict `#<n>` reference (e.g.
28
+ /** Referenced cross-repo prerequisites (`owner/repo#n`), deduplicated,
29
+ * first-seen order. Admission resolves these against the fleet's routed
30
+ * repositories, never against the candidate's own repo (#420). */
31
+ readonly crossRefs: readonly DependsOnCrossRef[];
32
+ /** Marker-bearing lines that carried no strict reference (e.g.
27
33
  * `Depends-on: #abc`). Bounded to what the body actually said, for the
28
34
  * grooming flag's detail. */
29
35
  readonly malformed: readonly string[];
30
36
  }
31
37
 
38
+ /**
39
+ * A strict cross-repo reference. `repo` is the canonical `owner/repo` spelling
40
+ * from the body, untouched, so admission can match it by identity against the
41
+ * fleet's routed repositories rather than by guess.
42
+ */
43
+ export interface DependsOnCrossRef {
44
+ readonly repo: string;
45
+ readonly issue: number;
46
+ }
47
+
32
48
  /**
33
49
  * How a line is spelled: `depends-on`/`depends on`, optional whitespace, then
34
50
  * a `:` or `=` separator. The marker is matched case-insensitively; the value
@@ -38,13 +54,29 @@ export interface DependsOnDecl {
38
54
  */
39
55
  const MARKER = /^depends[-\s]?on\s*[:=]\s*(.*)$/i;
40
56
 
41
- /** A strict reference is a bare `#` followed by digits — no repo qualifier,
42
- * no words. Everything else is not a prerequisite this slice resolves. */
43
- const REF = /#(\d+)/g;
57
+ /** A strict same-repo reference is a bare `#` followed by digits — no repo
58
+ * qualifier, no words. The negative lookbehind means a `#<n>` glued to a
59
+ * word (e.g. `web#7` or `issue#5`) is not a bare reference: without the
60
+ * `owner/` segment it is neither a valid cross-repo ref nor a valid
61
+ * same-repo one, so it is flagged malformed rather than silently resolved in
62
+ * the candidate's own repo. */
63
+ const REF = /(?<![A-Za-z0-9._/-])#(\d+)/g;
64
+
65
+ /**
66
+ * A strict cross-repo reference is `owner/repo#<n>` — exactly two path
67
+ * segments (each a GitHub owner/repository charset run) glued to a bare issue
68
+ * number, no whitespace. Scanned before {@link REF} and blanked out of the
69
+ * value, so the trailing `#<n>` is never re-read as a *same-repo* reference:
70
+ * the whole point of the qualified form is that it names a different
71
+ * repository, and resolving it in the candidate's own repo is the #420 fake.
72
+ */
73
+ const CROSS_REF = /([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+)/g;
44
74
 
45
75
  export function parseDependsOn(body: string): DependsOnDecl {
46
76
  const refs: number[] = [];
47
77
  const seen = new Set<number>();
78
+ const crossRefs: DependsOnCrossRef[] = [];
79
+ const seenCross = new Set<string>();
48
80
  const malformed: string[] = [];
49
81
  for (const raw of body.split("\n")) {
50
82
  const line = raw.trim();
@@ -52,22 +84,39 @@ export function parseDependsOn(body: string): DependsOnDecl {
52
84
  const match = MARKER.exec(line);
53
85
  if (match === null) continue;
54
86
  const value = match[1] ?? "";
87
+
88
+ // Cross-repo references first, blanked out of the value so their `#<n>`
89
+ // cannot also be collected as a same-repo reference below.
90
+ let lineHadRef = false;
91
+ const masked = value.replace(CROSS_REF, (whole, owner, name, number) => {
92
+ const repo = `${owner}/${name}`;
93
+ const n = Number(number);
94
+ if (Number.isSafeInteger(n) && !seenCross.has(`${repo}#${n}`)) {
95
+ seenCross.add(`${repo}#${n}`);
96
+ crossRefs.push({ repo, issue: n });
97
+ }
98
+ lineHadRef = true;
99
+ return " ".repeat(whole.length);
100
+ });
101
+
55
102
  REF.lastIndex = 0;
56
- const found = [...value.matchAll(REF)];
57
- if (found.length === 0) {
58
- // A marker line with no strict reference is a malformed declaration:
59
- // never crash, never silently claim — the declaration is ignored and
60
- // surfaced for grooming.
61
- malformed.push(line);
62
- continue;
63
- }
103
+ const found = [...masked.matchAll(REF)];
64
104
  for (const group of found) {
65
105
  const n = Number(group[1]!);
66
106
  if (Number.isSafeInteger(n) && !seen.has(n)) {
67
107
  seen.add(n);
68
108
  refs.push(n);
69
109
  }
110
+ lineHadRef = true;
111
+ }
112
+
113
+ if (!lineHadRef) {
114
+ // A marker line with no strict reference — same-repo or cross-repo — is
115
+ // a malformed declaration: never crash, never silently claim — the
116
+ // declaration is ignored and surfaced for grooming.
117
+ malformed.push(line);
118
+ continue;
70
119
  }
71
120
  }
72
- return { refs, malformed };
121
+ return { refs, crossRefs, malformed };
73
122
  }