@zhuxixi/pi-agent-board 0.4.2 → 0.5.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.
@@ -0,0 +1,861 @@
1
+ /**
2
+ * Code-refs provider layer: per-platform regex rule bundles for extracting
3
+ * issue/PR references from session evidence.
4
+ *
5
+ * A "provider" is pure data: one bundle of regex rules for a code platform
6
+ * (GitHub, GitLab, or an internal/self-hosted platform defined by the user in
7
+ * `<root>/providers.json`). User providers with the same name as a builtin are
8
+ * append-merged: user rules run first, then builtin rules; scalar fields
9
+ * (hosts/prefixes/urlTemplates) fall back to the builtin values when the user
10
+ * does not set them.
11
+ *
12
+ * All functions here are pure (zero I/O) except `loadProviders`, the single
13
+ * allowed I/O entry point, which reads `<root>/providers.json` through
14
+ * `readJson` and caches the merged result by file mtime.
15
+ */
16
+ import { statSync } from "node:fs";
17
+ import { readJson } from "./atomic.mjs";
18
+ import { providersPath } from "./paths.mjs";
19
+
20
+ /**
21
+ * @typedef {Object} Provider
22
+ * @property {string} name
23
+ * @property {string[]} hosts lowercase hosts this provider matches; [] = never matched by host
24
+ * @property {string} issuePrefix
25
+ * @property {string} prPrefix
26
+ * @property {{issue?: string, pr?: string}|null} urlTemplates
27
+ * @property {Rule[]} rules
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} Rule
32
+ * @property {RegExp} regex compiled from `pattern`, matched unanchored
33
+ * @property {string} pattern
34
+ * @property {"issue"|"pr"} kind
35
+ * @property {"claim"|"action"|"view"} strength
36
+ * @property {"capture"|"outputUrl"} numberFrom "outputUrl" rules carry no capture group
37
+ */
38
+
39
+ const VALID_KINDS = new Set(["issue", "pr"]);
40
+ const VALID_STRENGTHS = new Set(["claim", "action", "view"]);
41
+ const VALID_NUMBER_FROM = new Set(["capture", "outputUrl"]);
42
+
43
+ /** https://host[:port]/owner/repo(.git) */
44
+ const HTTPS_REMOTE_RE = /^https?:\/\/([^/:\s]+)(?::\d+)?\/(.+)$/;
45
+ /** git@host:owner/repo(.git) */
46
+ const SSH_REMOTE_RE = /^git@([^/:\s]+):(.+)$/;
47
+
48
+ /**
49
+ * Lowercased host of a raw origin-remote URL, or null when it cannot be parsed.
50
+ * Supports `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`; an
51
+ * optional port is dropped.
52
+ * @param {string|null} url
53
+ * @returns {string|null}
54
+ */
55
+ export function parseRemoteHost(url) {
56
+ if (typeof url !== "string" || !url) return null;
57
+ const match = HTTPS_REMOTE_RE.exec(url) ?? SSH_REMOTE_RE.exec(url);
58
+ return match ? match[1].toLowerCase() : null;
59
+ }
60
+
61
+ /**
62
+ * `owner/repo` path of a raw origin-remote URL (trailing `.git` stripped), or
63
+ * null when it cannot be parsed. Supports the same https/ssh shapes as
64
+ * `parseRemoteHost`; nested namespaces are kept (e.g. `group/sub/repo`).
65
+ * @param {string|null} url
66
+ * @returns {string|null}
67
+ */
68
+ export function parseRemotePath(url) {
69
+ if (typeof url !== "string" || !url) return null;
70
+ const match = HTTPS_REMOTE_RE.exec(url) ?? SSH_REMOTE_RE.exec(url);
71
+ if (!match) return null;
72
+ const path = match[2].replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
73
+ return path || null;
74
+ }
75
+
76
+ /**
77
+ * Compile trusted rule specs into Rule objects. Throws on an invalid pattern;
78
+ * only used for the builtin tables, which must stay valid.
79
+ * @param {Array<{pattern: string, kind: "issue"|"pr", strength: "claim"|"action"|"view", numberFrom?: "capture"|"outputUrl"}>} specs
80
+ * @returns {Rule[]}
81
+ */
82
+ function compileRules(specs) {
83
+ return specs.map((spec) => ({
84
+ regex: new RegExp(spec.pattern),
85
+ pattern: spec.pattern,
86
+ kind: spec.kind,
87
+ strength: spec.strength,
88
+ numberFrom: spec.numberFrom ?? "capture",
89
+ }));
90
+ }
91
+
92
+ const GITHUB_RULES = [
93
+ { pattern: "gh\\s+issue\\s+edit\\s+#?(\\d+)(?=[\\s\\S]*--add-assignee)", kind: "issue", strength: "claim" },
94
+ { pattern: "gh\\s+issue\\s+(?:comment|edit|close|reopen)\\s+#?(\\d+)", kind: "issue", strength: "action" },
95
+ { pattern: "gh\\s+issue\\s+create\\b", kind: "issue", strength: "action", numberFrom: "outputUrl" },
96
+ { pattern: "gh\\s+pr\\s+(?:checkout|merge|comment|review|close)\\s+#?(\\d+)", kind: "pr", strength: "action" },
97
+ { pattern: "gh\\s+pr\\s+create\\b", kind: "pr", strength: "action", numberFrom: "outputUrl" },
98
+ { pattern: "github\\.com/[\\w.-]+/[\\w.-]+/pull/(\\d+)", kind: "pr", strength: "action" },
99
+ { pattern: "gh\\s+issue\\s+view\\s+#?(\\d+)", kind: "issue", strength: "view" },
100
+ { pattern: "gh\\s+pr\\s+(?:view|diff|checks)\\s+#?(\\d+)", kind: "pr", strength: "view" },
101
+ { pattern: "github\\.com/[\\w.-]+/[\\w.-]+/issues/(\\d+)", kind: "issue", strength: "view" },
102
+ ];
103
+
104
+ const GITLAB_RULES = [
105
+ { pattern: "glab\\s+issue\\s+(?:edit|update)\\s+#?(\\d+)(?=[\\s\\S]*--assignee)", kind: "issue", strength: "claim" },
106
+ { pattern: "glab\\s+issue\\s+(?:note|comment|close|reopen)\\s+#?(\\d+)", kind: "issue", strength: "action" },
107
+ { pattern: "glab\\s+mr\\s+(?:checkout|merge)\\s+!?(\\d+)", kind: "pr", strength: "action" },
108
+ { pattern: "glab\\s+mr\\s+create\\b", kind: "pr", strength: "action", numberFrom: "outputUrl" },
109
+ { pattern: "/-/merge_requests/(\\d+)", kind: "pr", strength: "action" },
110
+ { pattern: "glab\\s+issue\\s+view\\s+#?(\\d+)", kind: "issue", strength: "view" },
111
+ { pattern: "glab\\s+mr\\s+view\\s+!?(\\d+)", kind: "pr", strength: "view" },
112
+ { pattern: "/-/issues/(\\d+)", kind: "issue", strength: "view" },
113
+ ];
114
+
115
+ const GENERIC_RULES = [
116
+ { pattern: "/issues/(\\d+)", kind: "issue", strength: "view" },
117
+ { pattern: "/pull/(\\d+)", kind: "pr", strength: "action" },
118
+ { pattern: "/-/issues/(\\d+)", kind: "issue", strength: "view" },
119
+ { pattern: "/-/merge_requests/(\\d+)", kind: "pr", strength: "action" },
120
+ ];
121
+
122
+ /**
123
+ * Builtin GitHub + GitLab provider bundles (fresh objects per call).
124
+ * @returns {Provider[]}
125
+ */
126
+ export function builtinProviders() {
127
+ return [githubProvider(), gitlabProvider()];
128
+ }
129
+
130
+ /**
131
+ * Host-agnostic fallback provider used when no provider matches the repo host:
132
+ * URL-only rules for the common issue/PR URL shapes, `#` / `▸#` prefixes, no
133
+ * urlTemplates. Fresh object per call.
134
+ * @returns {Provider}
135
+ */
136
+ export function genericFallbackProvider() {
137
+ return {
138
+ name: "generic",
139
+ hosts: [],
140
+ issuePrefix: "#",
141
+ prPrefix: "▸#",
142
+ urlTemplates: null,
143
+ rules: compileRules(GENERIC_RULES),
144
+ };
145
+ }
146
+
147
+ /** @returns {Provider} */
148
+ function githubProvider() {
149
+ return {
150
+ name: "github",
151
+ hosts: ["github.com"],
152
+ issuePrefix: "#",
153
+ prPrefix: "▸#",
154
+ urlTemplates: {
155
+ issue: "https://{host}/{owner}/{repo}/issues/{number}",
156
+ pr: "https://{host}/{owner}/{repo}/pull/{number}",
157
+ },
158
+ rules: compileRules(GITHUB_RULES),
159
+ };
160
+ }
161
+
162
+ /** @returns {Provider} */
163
+ function gitlabProvider() {
164
+ return {
165
+ name: "gitlab",
166
+ hosts: ["gitlab.com"],
167
+ issuePrefix: "#",
168
+ prPrefix: "!",
169
+ urlTemplates: {
170
+ issue: "https://{host}/{owner}/{repo}/-/issues/{number}",
171
+ pr: "https://{host}/{owner}/{repo}/-/merge_requests/{number}",
172
+ },
173
+ rules: compileRules(GITLAB_RULES),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Validate one raw provider config from `providers.json`.
179
+ *
180
+ * `name` and `rules` are required; an invalid provider yields `provider: null`.
181
+ * Individual invalid rules are skipped with an error message while valid rules
182
+ * are kept. Optional scalar fields (hosts/issuePrefix/prPrefix/urlTemplates)
183
+ * are validated but left unset when absent, so `mergeProviders` can tell "user
184
+ * did not set this" from "user set a value" and fall back to builtin fields.
185
+ * @param {any} raw
186
+ * @returns {{provider: Provider|null, errors: string[]}}
187
+ */
188
+ export function validateProvider(raw) {
189
+ const errors = [];
190
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
191
+ return { provider: null, errors: ["provider must be an object"] };
192
+ }
193
+ if (typeof raw.name !== "string" || !raw.name.trim()) {
194
+ errors.push("provider name must be a non-empty string");
195
+ return { provider: null, errors };
196
+ }
197
+ const name = raw.name.trim();
198
+ if (!Array.isArray(raw.rules)) {
199
+ errors.push(`provider "${name}": rules must be an array`);
200
+ return { provider: null, errors };
201
+ }
202
+ /** @type {Rule[]} */
203
+ const rules = [];
204
+ raw.rules.forEach((rule, index) => {
205
+ const compiled = validateRule(rule, index, name, errors);
206
+ if (compiled) rules.push(compiled);
207
+ });
208
+ /** @type {Provider} */
209
+ const provider = { name, rules };
210
+ validateScalarFields(raw, name, provider, errors);
211
+ return { provider, errors };
212
+ }
213
+
214
+ /**
215
+ * Count capturing groups in a regex source string, ignoring escaped chars,
216
+ * character classes, non-capturing groups, and lookaround groups.
217
+ * @param {string} source
218
+ * @returns {number}
219
+ */
220
+ function countCaptureGroups(source) {
221
+ let count = 0;
222
+ let inClass = false;
223
+ for (let i = 0; i < source.length; i++) {
224
+ const ch = source[i];
225
+ if (ch === "\\") {
226
+ i++;
227
+ continue;
228
+ }
229
+ if (inClass) {
230
+ if (ch === "]") inClass = false;
231
+ continue;
232
+ }
233
+ if (ch === "[") {
234
+ inClass = true;
235
+ continue;
236
+ }
237
+ if (ch === "(") {
238
+ if (source[i + 1] === "?") {
239
+ // (?<name>...) is a named capturing group; (?<=, ?<! are lookbehind.
240
+ if (source[i + 2] === "<" && source[i + 3] !== "=" && source[i + 3] !== "!") count++;
241
+ } else {
242
+ count++;
243
+ }
244
+ }
245
+ }
246
+ return count;
247
+ }
248
+
249
+ /**
250
+ * Compile and validate one raw rule; returns null (and records an error) when
251
+ * any field is missing or invalid. The regex must compile and, for
252
+ * `numberFrom: "capture"` rules, must contain at least one capture group for
253
+ * the number.
254
+ * @param {any} raw
255
+ * @param {number} index
256
+ * @param {string} providerName
257
+ * @param {string[]} errors
258
+ * @returns {Rule|null}
259
+ */
260
+ function validateRule(raw, index, providerName, errors) {
261
+ const where = `provider "${providerName}" rule ${index}`;
262
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
263
+ errors.push(`${where}: must be an object`);
264
+ return null;
265
+ }
266
+ const { pattern, kind, strength, numberFrom } = raw;
267
+ if (typeof pattern !== "string" || !pattern) {
268
+ errors.push(`${where}: missing pattern`);
269
+ return null;
270
+ }
271
+ if (!VALID_KINDS.has(kind)) {
272
+ errors.push(`${where}: kind must be "issue" or "pr"`);
273
+ return null;
274
+ }
275
+ if (!VALID_STRENGTHS.has(strength)) {
276
+ errors.push(`${where}: strength must be "claim", "action", or "view"`);
277
+ return null;
278
+ }
279
+ if (numberFrom !== undefined && !VALID_NUMBER_FROM.has(numberFrom)) {
280
+ errors.push(`${where}: numberFrom must be "capture" or "outputUrl"`);
281
+ return null;
282
+ }
283
+ let regex;
284
+ try {
285
+ regex = new RegExp(pattern);
286
+ } catch (e) {
287
+ errors.push(`${where}: invalid regex "${pattern}": ${e.message}`);
288
+ return null;
289
+ }
290
+ const resolvedNumberFrom = numberFrom ?? "capture";
291
+ if (resolvedNumberFrom === "capture" && countCaptureGroups(regex.source) === 0) {
292
+ errors.push(`${where}: capture rule must have a capture group`);
293
+ return null;
294
+ }
295
+ return { regex, pattern, kind, strength, numberFrom: resolvedNumberFrom };
296
+ }
297
+
298
+ /**
299
+ * Validate optional scalar fields in place: malformed values are dropped (left
300
+ * unset so mergeProviders falls back to the builtin) with an error recorded.
301
+ * A scalar list is only assigned when at least one entry parsed, so an
302
+ * all-invalid `hosts`/`urlTemplates` is treated as unset rather than as an
303
+ * empty override that would clobber the builtin values.
304
+ * @param {any} raw
305
+ * @param {string} name
306
+ * @param {Provider} provider
307
+ * @param {string[]} errors
308
+ */
309
+ function validateScalarFields(raw, name, provider, errors) {
310
+ if (raw.hosts !== undefined) {
311
+ if (Array.isArray(raw.hosts)) {
312
+ /** @type {string[]} */
313
+ const hosts = [];
314
+ raw.hosts.forEach((host, i) => {
315
+ if (typeof host === "string" && host) hosts.push(host);
316
+ else errors.push(`provider "${name}": hosts[${i}] must be a non-empty string`);
317
+ });
318
+ if (hosts.length > 0) provider.hosts = hosts;
319
+ } else {
320
+ errors.push(`provider "${name}": hosts must be an array of strings`);
321
+ }
322
+ }
323
+ if (raw.issuePrefix !== undefined) {
324
+ if (typeof raw.issuePrefix === "string") provider.issuePrefix = raw.issuePrefix;
325
+ else errors.push(`provider "${name}": issuePrefix must be a string`);
326
+ }
327
+ if (raw.prPrefix !== undefined) {
328
+ if (typeof raw.prPrefix === "string") provider.prPrefix = raw.prPrefix;
329
+ else errors.push(`provider "${name}": prPrefix must be a string`);
330
+ }
331
+ if (raw.urlTemplates !== undefined) {
332
+ if (raw.urlTemplates && typeof raw.urlTemplates === "object" && !Array.isArray(raw.urlTemplates)) {
333
+ /** @type {{issue?: string, pr?: string}} */
334
+ const templates = {};
335
+ if (raw.urlTemplates.issue !== undefined) {
336
+ if (typeof raw.urlTemplates.issue === "string") templates.issue = raw.urlTemplates.issue;
337
+ else errors.push(`provider "${name}": urlTemplates.issue must be a string`);
338
+ }
339
+ if (raw.urlTemplates.pr !== undefined) {
340
+ if (typeof raw.urlTemplates.pr === "string") templates.pr = raw.urlTemplates.pr;
341
+ else errors.push(`provider "${name}": urlTemplates.pr must be a string`);
342
+ }
343
+ if (templates.issue !== undefined || templates.pr !== undefined) {
344
+ provider.urlTemplates = templates;
345
+ }
346
+ } else {
347
+ errors.push(`provider "${name}": urlTemplates must be an object`);
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Append-merge user providers over builtins (decision D3): a user provider
354
+ * with the same name as a builtin gets its rules prepended to the builtin's
355
+ * rules and its scalar fields override the builtin's when set; unknown names
356
+ * are appended as-is with default scalars (`[]`, `#`, `▸#`, `null`). Builtin
357
+ * objects are never mutated. Validation errors are the caller's concern; the
358
+ * merge itself never throws.
359
+ * @param {Provider[]} builtins
360
+ * @param {Provider[]} user
361
+ * @returns {Provider[]}
362
+ */
363
+ export function mergeProviders(builtins, user) {
364
+ const byName = new Map(builtins.map((p) => [p.name, p]));
365
+ const merged = builtins.map((builtin) => {
366
+ const override = user.find((p) => p.name === builtin.name);
367
+ if (!override) return builtin;
368
+ return {
369
+ name: builtin.name,
370
+ hosts: override.hosts ?? builtin.hosts,
371
+ issuePrefix: override.issuePrefix ?? builtin.issuePrefix,
372
+ prPrefix: override.prPrefix ?? builtin.prPrefix,
373
+ urlTemplates: override.urlTemplates ?? builtin.urlTemplates,
374
+ rules: [...override.rules, ...builtin.rules],
375
+ };
376
+ });
377
+ for (const provider of user) {
378
+ if (byName.has(provider.name)) continue;
379
+ merged.push({
380
+ name: provider.name,
381
+ hosts: provider.hosts ?? [],
382
+ issuePrefix: provider.issuePrefix ?? "#",
383
+ prPrefix: provider.prPrefix ?? "▸#",
384
+ urlTemplates: provider.urlTemplates ?? null,
385
+ rules: provider.rules,
386
+ });
387
+ }
388
+ return merged;
389
+ }
390
+
391
+ /**
392
+ * Cached merged provider lists per store root. The cache key records the
393
+ * `providers.json` mtime (null when the file is absent) so a file change
394
+ * triggers a reload while unchanged files reuse the merged result. Validation
395
+ * errors are cached alongside so they can be surfaced once per mtime.
396
+ * @type {Map<string, {mtimeMs: number|null, providers: Provider[], errors: string[]}>}
397
+ */
398
+ const providerCache = new Map();
399
+
400
+ /**
401
+ * Resolve the effective provider list for a store root, together with any
402
+ * per-provider validation errors from the user's `<root>/providers.json`.
403
+ * Never throws — a missing, unparseable, or otherwise broken file falls back to
404
+ * builtins alone; invalid provider entries are skipped and their messages
405
+ * returned in `errors`. Cached by file mtime.
406
+ * @param {string} root
407
+ * @returns {{providers: Provider[], errors: string[]}}
408
+ */
409
+ export function loadProvidersWithErrors(root) {
410
+ const file = providersPath(root);
411
+ let mtimeMs = null;
412
+ try {
413
+ mtimeMs = statSync(file).mtimeMs;
414
+ } catch {
415
+ // no providers.json → builtins only
416
+ }
417
+ const cached = providerCache.get(root);
418
+ if (cached && cached.mtimeMs === mtimeMs) return { providers: cached.providers, errors: cached.errors };
419
+ const loaded = loadProvidersUncached(file);
420
+ providerCache.set(root, { mtimeMs, providers: loaded.providers, errors: loaded.errors });
421
+ return loaded;
422
+ }
423
+
424
+ /**
425
+ * Resolve the effective provider list for a store root (validation errors
426
+ * discarded): builtins merged with the user's `<root>/providers.json` when
427
+ * present. Never throws — a missing, unparseable, or otherwise broken file
428
+ * falls back to builtins alone. Thin wrapper over {@link loadProvidersWithErrors}.
429
+ * @param {string} root
430
+ * @returns {Provider[]}
431
+ */
432
+ export function loadProviders(root) {
433
+ return loadProvidersWithErrors(root).providers;
434
+ }
435
+
436
+ /**
437
+ * Read + validate + merge without consulting the cache.
438
+ * @param {string} file
439
+ * @returns {{providers: Provider[], errors: string[]}}
440
+ */
441
+ function loadProvidersUncached(file) {
442
+ const raw = readJson(file, null);
443
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.providers)) {
444
+ return { providers: builtinProviders(), errors: [] };
445
+ }
446
+ /** @type {Provider[]} */
447
+ const user = [];
448
+ /** @type {string[]} */
449
+ const errors = [];
450
+ for (const entry of raw.providers) {
451
+ const { provider, errors: entryErrors } = validateProvider(entry);
452
+ if (provider) user.push(provider);
453
+ errors.push(...entryErrors);
454
+ }
455
+ return { providers: mergeProviders(builtinProviders(), user), errors };
456
+ }
457
+
458
+ /**
459
+ * Clear the loadProviders cache. Test-only helper.
460
+ * @returns {void}
461
+ */
462
+ export function clearProvidersCacheForTests() {
463
+ providerCache.clear();
464
+ }
465
+
466
+ /**
467
+ * Pick the provider whose hosts contain `host` (exact match, lowercased). A
468
+ * null/empty host or no match falls back to the generic URL-only provider.
469
+ * @param {Provider[]} providers
470
+ * @param {string|null} host
471
+ * @returns {Provider}
472
+ */
473
+ export function matchProvider(providers, host) {
474
+ if (typeof host === "string" && host) {
475
+ const needle = host.toLowerCase();
476
+ for (const provider of providers) {
477
+ for (const candidate of provider.hosts) {
478
+ if (candidate.toLowerCase() === needle) return provider;
479
+ }
480
+ }
481
+ }
482
+ return genericFallbackProvider();
483
+ }
484
+
485
+ /**
486
+ * @typedef {Object} Ref
487
+ * @property {"issue"|"pr"} kind
488
+ * @property {number} number
489
+ * @property {"claim"|"action"|"view"|"mention"} strength
490
+ * @property {"high"|"medium"|"low"} confidence
491
+ * @property {string} source
492
+ * @property {string|null} url
493
+ * @property {number} lastIndex
494
+ */
495
+
496
+ /**
497
+ * @typedef {Object} CodeRefsResult
498
+ * @property {string} provider
499
+ * @property {Ref|null} issue
500
+ * @property {Ref|null} pr
501
+ * @property {Ref[]} allRefs
502
+ */
503
+
504
+ const STRENGTH_RANK = { claim: 3, action: 2, view: 1, mention: 0 };
505
+ const REF_CONFIDENCE = { claim: "high", action: "high", view: "medium", mention: "low" };
506
+
507
+ /** URL rules carry a `/issues/`, `/pull/`, or `/merge_requests/` path segment. */
508
+ const URL_RULE_RE = /issues\/|pull\/|merge_requests\//;
509
+ /** `closes #N` / `fixes #N` / `issue #N` back-link inside a `pr create` body. */
510
+ const PR_BACKLINK_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})/i;
511
+ /** `issue-<N>-...` worktree/branch naming convention (engine-builtin). */
512
+ const WORKTREE_RE = /(?:^|[/\\])issue-(\d{1,7})(?:-|$)/;
513
+ /** Bare `#N` mentions. */
514
+ const MENTION_RE = /#(\d{1,7})/g;
515
+
516
+ /** Worktree/branch naming is session metadata, treated as before the transcript. */
517
+ const WORKTREE_INDEX = -1;
518
+
519
+ /** @param {Rule} rule */
520
+ function isUrlRule(rule) {
521
+ return URL_RULE_RE.test(rule.pattern);
522
+ }
523
+
524
+ /**
525
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
526
+ */
527
+ function addCandidate(candidates, kind, number, strength, source, lastIndex) {
528
+ if (!Number.isInteger(number) || number <= 0) return;
529
+ candidates.push({ kind, number, strength, source, lastIndex });
530
+ }
531
+
532
+ /**
533
+ * Extract issue/PR references from session evidence against one provider. Pure
534
+ * (zero I/O): the caller has already resolved the provider for the repo host
535
+ * and passes `repoUrl` (`owner/repo`) plus `host` for urlTemplate filling.
536
+ *
537
+ * `input.commands` are scanned in array order; `input.assistantTexts` are
538
+ * treated as the ordered sequence continuing after commands (indexes keep
539
+ * counting). Rule strengths rank `claim > action > view > mention`; a tie in
540
+ * strength is broken by the highest `lastIndex`.
541
+ *
542
+ * @param {{commands: Array<{command: string}>, assistantTexts: string[], worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null}} input
543
+ * @param {Provider} provider
544
+ * @returns {CodeRefsResult}
545
+ */
546
+ export function extractCodeRefs(input, provider) {
547
+ input = input ?? {};
548
+ const commands = Array.isArray(input.commands) ? input.commands : [];
549
+ const assistantTexts = Array.isArray(input.assistantTexts) ? input.assistantTexts : [];
550
+ const worktreePath = input.worktreePath ?? null;
551
+ const branch = input.branch ?? null;
552
+ const repoUrl = input.repoUrl ?? null;
553
+ const host = input.host ?? null;
554
+ const rules = provider.rules;
555
+
556
+ /** @type {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
557
+ const candidates = [];
558
+ /** @type {Array<{kind: "issue"|"pr", index: number}>} */
559
+ const pendingCreates = [];
560
+
561
+ // Rule 1: commands in order, against every provider rule.
562
+ commands.forEach((cmd, index) => {
563
+ const text = cmd && typeof cmd.command === "string" ? cmd.command : "";
564
+ if (!text) return;
565
+ for (const rule of rules) {
566
+ const m = rule.regex.exec(text);
567
+ if (!m) continue;
568
+ if (rule.numberFrom === "outputUrl") {
569
+ pendingCreates.push({ kind: rule.kind, index });
570
+ // Rule 4: PR back-link within a pr create command.
571
+ if (rule.kind === "pr") applyPrBacklink(text, index, candidates);
572
+ } else {
573
+ addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "command", index);
574
+ }
575
+ }
576
+ });
577
+
578
+ // Rule 2: assistant texts (indexes continue after commands) with URL rules only.
579
+ const urlRules = rules.filter(isUrlRule);
580
+ assistantTexts.forEach((text, i) => {
581
+ const index = commands.length + i;
582
+ if (typeof text !== "string" || !text) return;
583
+ for (const rule of urlRules) {
584
+ const m = rule.regex.exec(text);
585
+ if (!m) continue;
586
+ addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "url", index);
587
+ }
588
+ });
589
+
590
+ // Rule 3: resolve pending create markers against subsequent evidence.
591
+ for (let mi = 0; mi < pendingCreates.length; mi++) {
592
+ const marker = pendingCreates[mi];
593
+ const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
594
+ if (resolved) {
595
+ addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
596
+ }
597
+ // Rule 4b: the issue back-link of a created PR may live in a later
598
+ // assistant message ("This PR closes #40") or in --body-file content
599
+ // that never appears in the command string — scan subsequent evidence
600
+ // for the back-link pattern as well (not just the command itself).
601
+ // The scan stops at the NEXT pr-create marker so an earlier create
602
+ // never absorbs a later PR's back-link.
603
+ if (marker.kind === "pr") {
604
+ const nextPr = pendingCreates.slice(mi + 1).find((m) => m.kind === "pr");
605
+ const backlink = resolveBacklinkAfter(marker, commands, assistantTexts, nextPr?.index ?? Infinity);
606
+ if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
607
+ }
608
+ }
609
+
610
+ // Rule 5: worktree/branch naming (engine-builtin, not configurable).
611
+ for (const value of [worktreePath, branch]) {
612
+ if (typeof value !== "string" || !value) continue;
613
+ const m = WORKTREE_RE.exec(value);
614
+ if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "worktree", WORKTREE_INDEX);
615
+ }
616
+
617
+ // Rule 7 (first half): drop view signals seen fewer than twice. This runs
618
+ // before the mention check so a single discarded view does not suppress the
619
+ // bare-`#N` fallback.
620
+ discardWeakViews(candidates);
621
+
622
+ // Rule 6: bare `#N` mention fallback, only when no stronger issue signal.
623
+ if (!hasIssueCandidateAtLeastView(candidates)) {
624
+ const mention = mentionFallback(assistantTexts, commands.length);
625
+ if (mention) addCandidate(candidates, "issue", mention.number, "mention", "mention", mention.lastIndex);
626
+ }
627
+
628
+ // Rule 7 (second half): aggregate winners + allRefs.
629
+ return aggregate(candidates, provider, repoUrl, host);
630
+ }
631
+
632
+ /**
633
+ * @param {string} text
634
+ * @param {number} index
635
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
636
+ */
637
+ function applyPrBacklink(text, index, candidates) {
638
+ const m = PR_BACKLINK_RE.exec(text);
639
+ if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "pr-body", index);
640
+ }
641
+
642
+ /**
643
+ * Find the first PR→issue back-link ("Closes #N" etc.) in evidence AFTER a
644
+ * `pr create` marker — covers assistant messages and later commands alike.
645
+ * The scan stops before `stopBefore` (typically the next pr-create marker).
646
+ * @param {{kind: "issue"|"pr", index: number}} marker
647
+ * @param {Array<{command: string}>} commands
648
+ * @param {string[]} assistantTexts
649
+ * @param {number} [stopBefore]
650
+ */
651
+ function resolveBacklinkAfter(marker, commands, assistantTexts, stopBefore = Infinity) {
652
+ const total = Math.min(commands.length + assistantTexts.length, stopBefore);
653
+ for (let index = marker.index + 1; index < total; index++) {
654
+ const text = evidenceTextAt(index, commands, assistantTexts);
655
+ if (!text) continue;
656
+ const m = PR_BACKLINK_RE.exec(text);
657
+ if (m) return { number: Number(m[1]), index };
658
+ }
659
+ return null;
660
+ }
661
+
662
+ /**
663
+ * @param {{kind: "issue"|"pr", index: number}} marker
664
+ * @param {Array<{command: string}>} commands
665
+ * @param {string[]} assistantTexts
666
+ * @param {Rule[]} urlRules
667
+ */
668
+ function resolveCreate(marker, commands, assistantTexts, urlRules) {
669
+ const total = commands.length + assistantTexts.length;
670
+ for (let index = marker.index + 1; index < total; index++) {
671
+ const text = evidenceTextAt(index, commands, assistantTexts);
672
+ if (!text) continue;
673
+ for (const rule of urlRules) {
674
+ if (rule.kind !== marker.kind) continue;
675
+ const m = rule.regex.exec(text);
676
+ if (m) return { number: Number(m[1]), index };
677
+ }
678
+ }
679
+ return null;
680
+ }
681
+
682
+ /**
683
+ * @param {number} index
684
+ * @param {Array<{command: string}>} commands
685
+ * @param {string[]} assistantTexts
686
+ */
687
+ function evidenceTextAt(index, commands, assistantTexts) {
688
+ if (index < commands.length) {
689
+ const cmd = commands[index];
690
+ return cmd && typeof cmd.command === "string" ? cmd.command : "";
691
+ }
692
+ const text = assistantTexts[index - commands.length];
693
+ return typeof text === "string" ? text : "";
694
+ }
695
+
696
+ /**
697
+ * Remove view candidates whose number was viewed fewer than twice (D1: view
698
+ * signals only count at frequency >= 2). Mutates `candidates` in place.
699
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
700
+ */
701
+ function discardWeakViews(candidates) {
702
+ const counts = new Map();
703
+ for (const c of candidates) {
704
+ if (c.strength !== "view") continue;
705
+ const key = `${c.kind}:${c.number}`;
706
+ counts.set(key, (counts.get(key) ?? 0) + 1);
707
+ }
708
+ for (let i = candidates.length - 1; i >= 0; i--) {
709
+ const c = candidates[i];
710
+ if (c.strength === "view" && (counts.get(`${c.kind}:${c.number}`) ?? 0) < 2) {
711
+ candidates.splice(i, 1);
712
+ }
713
+ }
714
+ }
715
+
716
+ /**
717
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
718
+ */
719
+ function hasIssueCandidateAtLeastView(candidates) {
720
+ return candidates.some((c) => c.kind === "issue" && STRENGTH_RANK[c.strength] >= STRENGTH_RANK.view);
721
+ }
722
+
723
+ /**
724
+ * Count bare `#N` mentions over the last 20 assistant texts. Returns the
725
+ * winner (and its last occurrence index) only when it appears at least 3 times
726
+ * and at least twice as often as the runner-up; otherwise null.
727
+ * @param {string[]} assistantTexts
728
+ * @param {number} baseIndex
729
+ */
730
+ function mentionFallback(assistantTexts, baseIndex) {
731
+ const start = Math.max(0, assistantTexts.length - 20);
732
+ const counts = new Map();
733
+ const lastIndex = new Map();
734
+ for (let i = start; i < assistantTexts.length; i++) {
735
+ const text = assistantTexts[i];
736
+ if (typeof text !== "string") continue;
737
+ for (const m of text.matchAll(MENTION_RE)) {
738
+ const n = Number(m[1]);
739
+ counts.set(n, (counts.get(n) ?? 0) + 1);
740
+ lastIndex.set(n, baseIndex + i);
741
+ }
742
+ }
743
+ if (counts.size === 0) return null;
744
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
745
+ const winner = ranked[0];
746
+ const runnerUp = ranked[1]?.[1] ?? 0;
747
+ if (winner[1] < 3 || winner[1] < 2 * runnerUp) return null;
748
+ return { number: winner[0], lastIndex: lastIndex.get(winner[0]) ?? baseIndex };
749
+ }
750
+
751
+ /**
752
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
753
+ * @param {Provider} provider
754
+ * @param {string|null} repoUrl
755
+ * @param {string|null} host
756
+ */
757
+ function aggregate(candidates, provider, repoUrl, host) {
758
+ /** @type {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
759
+ const byKey = new Map();
760
+ for (const c of candidates) {
761
+ const key = `${c.kind}:${c.number}`;
762
+ const prev = byKey.get(key);
763
+ if (!prev) {
764
+ byKey.set(key, c);
765
+ continue;
766
+ }
767
+ const rank = STRENGTH_RANK[c.strength];
768
+ const prevRank = STRENGTH_RANK[prev.strength];
769
+ // Stronger wins; equal strength → most recent; equal recency → the more
770
+ // specific later-derived signal (e.g. create-url beats url).
771
+ if (rank > prevRank || (rank === prevRank && c.lastIndex >= prev.lastIndex)) {
772
+ byKey.set(key, c);
773
+ }
774
+ }
775
+
776
+ const issue = pickWinner(byKey, "issue");
777
+ const pr = pickWinner(byKey, "pr");
778
+ const allRefs = [...byKey.values()]
779
+ .sort((a, b) => STRENGTH_RANK[b.strength] - STRENGTH_RANK[a.strength] || b.lastIndex - a.lastIndex)
780
+ .slice(0, 10)
781
+ .map((c) => toRef(c, provider, repoUrl, host));
782
+
783
+ return {
784
+ provider: provider.name,
785
+ issue: issue ? toRef(issue, provider, repoUrl, host) : null,
786
+ pr: pr ? toRef(pr, provider, repoUrl, host) : null,
787
+ allRefs,
788
+ };
789
+ }
790
+
791
+ /**
792
+ * @param {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} byKey
793
+ * @param {"issue"|"pr"} kind
794
+ */
795
+ function pickWinner(byKey, kind) {
796
+ let best = null;
797
+ for (const c of byKey.values()) {
798
+ if (c.kind !== kind) continue;
799
+ if (!best) {
800
+ best = c;
801
+ continue;
802
+ }
803
+ const rank = STRENGTH_RANK[c.strength];
804
+ const bestRank = STRENGTH_RANK[best.strength];
805
+ if (rank > bestRank || (rank === bestRank && c.lastIndex > best.lastIndex)) best = c;
806
+ }
807
+ return best;
808
+ }
809
+
810
+ /**
811
+ * @param {{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}} c
812
+ * @param {Provider} provider
813
+ * @param {string|null} repoUrl
814
+ * @param {string|null} host
815
+ * @returns {Ref}
816
+ */
817
+ function toRef(c, provider, repoUrl, host) {
818
+ return {
819
+ kind: c.kind,
820
+ number: c.number,
821
+ strength: c.strength,
822
+ confidence: REF_CONFIDENCE[c.strength],
823
+ source: c.source,
824
+ url: c.strength === "mention" ? null : fillUrl(provider, c.kind, c.number, repoUrl, host),
825
+ lastIndex: c.lastIndex,
826
+ };
827
+ }
828
+
829
+ /**
830
+ * Fill a provider urlTemplate (`{host}`, `{owner}`, `{repo}`, `{number}`).
831
+ * Returns null when there is no template for the kind or no repoUrl.
832
+ * @param {Provider} provider
833
+ * @param {"issue"|"pr"} kind
834
+ * @param {number} number
835
+ * @param {string|null} repoUrl
836
+ * @param {string|null} host
837
+ */
838
+ function fillUrl(provider, kind, number, repoUrl, host) {
839
+ const template = provider.urlTemplates?.[kind];
840
+ if (!template || typeof repoUrl !== "string" || !repoUrl) return null;
841
+ // Templates embed {host}; without a host the URL would be malformed, so bail.
842
+ if (template.includes("{host}") && !host) return null;
843
+ const { owner, repo } = splitRepoUrl(repoUrl);
844
+ return template
845
+ .replaceAll("{host}", typeof host === "string" ? host : "")
846
+ .replaceAll("{owner}", owner)
847
+ .replaceAll("{repo}", repo)
848
+ .replaceAll("{number}", String(number));
849
+ }
850
+
851
+ /**
852
+ * Split an `owner/repo` path into its owner and repo parts (repo name minus
853
+ * any trailing `.git`).
854
+ * @param {string} repoUrl
855
+ */
856
+ function splitRepoUrl(repoUrl) {
857
+ const path = repoUrl.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
858
+ const slash = path.lastIndexOf("/");
859
+ if (slash === -1) return { owner: "", repo: path };
860
+ return { owner: path.slice(0, slash), repo: path.slice(slash + 1) };
861
+ }