@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,913 @@
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
+ /**
510
+ * Back-link inside an explicit `pr create` command body: closing keywords
511
+ * (optionally followed by "issue") or the legacy bare `issue #N` form.
512
+ * Word boundaries keep embedded keywords (prefix/disclose/unresolved) out;
513
+ * `(?!\w)` rejects longer numbers instead of truncating them to 7 digits.
514
+ */
515
+ const PR_CREATE_BACKLINK_RE =
516
+ /\b(?:(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+(?:issue\s+)?|issue\s+)#(\d{1,7})(?!\w)/i;
517
+ /**
518
+ * Back-link in later assistant evidence: canonical closing-keyword syntax
519
+ * only (`closes #N` etc.). A bare `issue #N` mention — e.g. a code-review
520
+ * report's finding number — never matches here (issue #65).
521
+ */
522
+ const PR_FOLLOWUP_BACKLINK_RE =
523
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+#(\d{1,7})(?!\w)/i;
524
+
525
+ /**
526
+ * Match a PR→issue back-link in the text of an explicit `pr create` command
527
+ * (closing keywords or the legacy bare `issue #N` body form).
528
+ * @param {string} text
529
+ * @returns {number|null}
530
+ */
531
+ function matchPrCreateBacklink(text) {
532
+ const m = PR_CREATE_BACKLINK_RE.exec(text);
533
+ return m ? Number(m[1]) : null;
534
+ }
535
+
536
+ /**
537
+ * Match a PR→issue back-link in later assistant evidence — canonical
538
+ * closing-keyword syntax only, never a bare `issue #N` (issue #65).
539
+ * @param {string} text
540
+ * @returns {number|null}
541
+ */
542
+ function matchPrFollowupBacklink(text) {
543
+ const m = PR_FOLLOWUP_BACKLINK_RE.exec(text);
544
+ return m ? Number(m[1]) : null;
545
+ }
546
+ /** `issue-<N>-...` worktree/branch naming convention (engine-builtin). */
547
+ const WORKTREE_RE = /(?:^|[/\\])issue-(\d{1,7})(?:-|$)/;
548
+ /**
549
+ * Bare `#N` mentions. The lookbehind rejects a preceding `#` (markdown
550
+ * headings, `##N` typos) or word character (`a#1`) — those are not issue
551
+ * references (issue #61).
552
+ */
553
+ const MENTION_RE = /(?<![#\w])#(\d{1,7})/g;
554
+ /** Inline code span (single-line) — excluded from mention counting. */
555
+ const INLINE_CODE_SPAN_RE = /`[^`\n]*`/g;
556
+ /** Explicit PR-context tokens immediately before a `#N` match. */
557
+ const PR_CONTEXT_RE = /(?:^|\W)(?:pr|pull|mr|merge)s?(?:\W|$)/i;
558
+
559
+ /** Worktree/branch naming is session metadata, treated as before the transcript. */
560
+ const WORKTREE_INDEX = -1;
561
+
562
+ /** @param {Rule} rule */
563
+ function isUrlRule(rule) {
564
+ return URL_RULE_RE.test(rule.pattern);
565
+ }
566
+
567
+ /**
568
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
569
+ */
570
+ function addCandidate(candidates, kind, number, strength, source, lastIndex) {
571
+ if (!Number.isInteger(number) || number <= 0) return;
572
+ candidates.push({ kind, number, strength, source, lastIndex });
573
+ }
574
+
575
+ /**
576
+ * Extract issue/PR references from session evidence against one provider. Pure
577
+ * (zero I/O): the caller has already resolved the provider for the repo host
578
+ * and passes `repoUrl` (`owner/repo`) plus `host` for urlTemplate filling.
579
+ *
580
+ * `input.commands` are scanned in array order; `input.assistantTexts` are
581
+ * treated as the ordered sequence continuing after commands (indexes keep
582
+ * counting). Rule strengths rank `claim > action > view > mention`; a tie in
583
+ * strength is broken by the highest `lastIndex`.
584
+ *
585
+ * @param {{commands: Array<{command: string}>, assistantTexts: string[], worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null}} input
586
+ * @param {Provider} provider
587
+ * @returns {CodeRefsResult}
588
+ */
589
+ export function extractCodeRefs(input, provider) {
590
+ input = input ?? {};
591
+ const commands = Array.isArray(input.commands) ? input.commands : [];
592
+ const assistantTexts = Array.isArray(input.assistantTexts) ? input.assistantTexts : [];
593
+ const worktreePath = input.worktreePath ?? null;
594
+ const branch = input.branch ?? null;
595
+ const repoUrl = input.repoUrl ?? null;
596
+ const host = input.host ?? null;
597
+ const rules = provider.rules;
598
+
599
+ /** @type {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
600
+ const candidates = [];
601
+ /** @type {Array<{kind: "issue"|"pr", index: number}>} */
602
+ const pendingCreates = [];
603
+
604
+ // Rule 1: commands in order, against every provider rule.
605
+ commands.forEach((cmd, index) => {
606
+ const text = cmd && typeof cmd.command === "string" ? cmd.command : "";
607
+ if (!text) return;
608
+ for (const rule of rules) {
609
+ const m = rule.regex.exec(text);
610
+ if (!m) continue;
611
+ if (rule.numberFrom === "outputUrl") {
612
+ pendingCreates.push({ kind: rule.kind, index });
613
+ // Rule 4: PR back-link within a pr create command.
614
+ if (rule.kind === "pr") applyPrBacklink(text, index, candidates);
615
+ } else {
616
+ addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "command", index);
617
+ }
618
+ }
619
+ });
620
+
621
+ // Rule 2: assistant texts (indexes continue after commands) with URL rules only.
622
+ const urlRules = rules.filter(isUrlRule);
623
+ assistantTexts.forEach((text, i) => {
624
+ const index = commands.length + i;
625
+ if (typeof text !== "string" || !text) return;
626
+ for (const rule of urlRules) {
627
+ const m = rule.regex.exec(text);
628
+ if (!m) continue;
629
+ addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "url", index);
630
+ }
631
+ });
632
+
633
+ // Rule 3: resolve pending create markers against subsequent evidence.
634
+ for (const marker of pendingCreates) {
635
+ const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
636
+ if (resolved) {
637
+ addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
638
+ }
639
+ }
640
+ // Rule 4b: the issue back-link of a created PR may live in a later
641
+ // assistant message ("This PR closes #40") or in --body-file content that
642
+ // never appears in the command string. The flattened evidence input keeps
643
+ // no interleaving timestamps, so a follow-up back-link can be attributed
644
+ // only when exactly one distinct PR-create command exists; with zero or
645
+ // multiple PR creates the assistant scan is skipped rather than guessing.
646
+ // Later commands are never scanned: `gh issue close #N` or
647
+ // `gh pr comment ... fixes #N` are their own signals, not this PR's
648
+ // back-link (issue #65).
649
+ const prCreateIndexes = new Set(pendingCreates.filter((m) => m.kind === "pr").map((m) => m.index));
650
+ if (prCreateIndexes.size === 1) {
651
+ const backlink = resolveBacklinkAfter(assistantTexts, commands.length);
652
+ if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
653
+ }
654
+
655
+ // Rule 5: worktree/branch naming (engine-builtin, not configurable).
656
+ for (const value of [worktreePath, branch]) {
657
+ if (typeof value !== "string" || !value) continue;
658
+ const m = WORKTREE_RE.exec(value);
659
+ if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "worktree", WORKTREE_INDEX);
660
+ }
661
+
662
+ // Rule 7 (first half): drop view signals seen fewer than twice. This runs
663
+ // before the mention check so a single discarded view does not suppress the
664
+ // bare-`#N` fallback.
665
+ discardWeakViews(candidates);
666
+
667
+ // Rule 6: bare `#N` mention fallback, only when no stronger issue signal.
668
+ if (!hasIssueCandidateAtLeastView(candidates)) {
669
+ const mention = mentionFallback(assistantTexts, commands.length);
670
+ if (mention) addCandidate(candidates, "issue", mention.number, "mention", "mention", mention.lastIndex);
671
+ }
672
+
673
+ // Rule 7 (second half): aggregate winners + allRefs.
674
+ return aggregate(candidates, provider, repoUrl, host);
675
+ }
676
+
677
+ /**
678
+ * @param {string} text
679
+ * @param {number} index
680
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
681
+ */
682
+ function applyPrBacklink(text, index, candidates) {
683
+ const number = matchPrCreateBacklink(text);
684
+ if (number !== null) addCandidate(candidates, "issue", number, "claim", "pr-body", index);
685
+ }
686
+
687
+ /**
688
+ * Find the first PR→issue back-link in later assistant texts — canonical
689
+ * closing-keyword syntax only ("Closes #N" etc., see
690
+ * PR_FOLLOWUP_BACKLINK_RE). Commands are never scanned. `baseIndex` (the
691
+ * command count) keeps the existing ordering contract; it is not a real
692
+ * timestamp.
693
+ * @param {string[]} assistantTexts
694
+ * @param {number} baseIndex
695
+ * @returns {{number: number, index: number}|null}
696
+ */
697
+ function resolveBacklinkAfter(assistantTexts, baseIndex) {
698
+ for (let i = 0; i < assistantTexts.length; i++) {
699
+ const text = assistantTexts[i];
700
+ if (typeof text !== "string" || !text) continue;
701
+ const number = matchPrFollowupBacklink(text);
702
+ if (number !== null) return { number, index: baseIndex + i };
703
+ }
704
+ return null;
705
+ }
706
+
707
+ /**
708
+ * @param {{kind: "issue"|"pr", index: number}} marker
709
+ * @param {Array<{command: string}>} commands
710
+ * @param {string[]} assistantTexts
711
+ * @param {Rule[]} urlRules
712
+ */
713
+ function resolveCreate(marker, commands, assistantTexts, urlRules) {
714
+ const total = commands.length + assistantTexts.length;
715
+ for (let index = marker.index + 1; index < total; index++) {
716
+ const text = evidenceTextAt(index, commands, assistantTexts);
717
+ if (!text) continue;
718
+ for (const rule of urlRules) {
719
+ if (rule.kind !== marker.kind) continue;
720
+ const m = rule.regex.exec(text);
721
+ if (m) return { number: Number(m[1]), index };
722
+ }
723
+ }
724
+ return null;
725
+ }
726
+
727
+ /**
728
+ * @param {number} index
729
+ * @param {Array<{command: string}>} commands
730
+ * @param {string[]} assistantTexts
731
+ */
732
+ function evidenceTextAt(index, commands, assistantTexts) {
733
+ if (index < commands.length) {
734
+ const cmd = commands[index];
735
+ return cmd && typeof cmd.command === "string" ? cmd.command : "";
736
+ }
737
+ const text = assistantTexts[index - commands.length];
738
+ return typeof text === "string" ? text : "";
739
+ }
740
+
741
+ /**
742
+ * Remove view candidates whose number was viewed fewer than twice (D1: view
743
+ * signals only count at frequency >= 2). Mutates `candidates` in place.
744
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
745
+ */
746
+ function discardWeakViews(candidates) {
747
+ const counts = new Map();
748
+ for (const c of candidates) {
749
+ if (c.strength !== "view") continue;
750
+ const key = `${c.kind}:${c.number}`;
751
+ counts.set(key, (counts.get(key) ?? 0) + 1);
752
+ }
753
+ for (let i = candidates.length - 1; i >= 0; i--) {
754
+ const c = candidates[i];
755
+ if (c.strength === "view" && (counts.get(`${c.kind}:${c.number}`) ?? 0) < 2) {
756
+ candidates.splice(i, 1);
757
+ }
758
+ }
759
+ }
760
+
761
+ /**
762
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
763
+ */
764
+ function hasIssueCandidateAtLeastView(candidates) {
765
+ return candidates.some((c) => c.kind === "issue" && STRENGTH_RANK[c.strength] >= STRENGTH_RANK.view);
766
+ }
767
+
768
+ /**
769
+ * Count bare `#N` mentions over the last 20 assistant texts. Returns the
770
+ * winner (and its last occurrence index) only when it appears at least 3 times
771
+ * and at least twice as often as the runner-up; otherwise null.
772
+ * @param {string[]} assistantTexts
773
+ * @param {number} baseIndex
774
+ */
775
+ function mentionFallback(assistantTexts, baseIndex) {
776
+ const start = Math.max(0, assistantTexts.length - 20);
777
+ const counts = new Map();
778
+ const lastIndex = new Map();
779
+ for (let i = start; i < assistantTexts.length; i++) {
780
+ const text = assistantTexts[i];
781
+ if (typeof text !== "string") continue;
782
+ // Doc examples inside inline code spans are not real references; strip
783
+ // them before counting (issue #61).
784
+ const stripped = text.replace(INLINE_CODE_SPAN_RE, " ");
785
+ for (const m of stripped.matchAll(MENTION_RE)) {
786
+ // `monitor pr #N` is explicitly PR context: do not count it toward
787
+ // the issue fallback (issue #61).
788
+ const before = stripped.slice(Math.max(0, m.index - 16), m.index);
789
+ if (PR_CONTEXT_RE.test(before)) continue;
790
+ const n = Number(m[1]);
791
+ counts.set(n, (counts.get(n) ?? 0) + 1);
792
+ lastIndex.set(n, baseIndex + i);
793
+ }
794
+ }
795
+ if (counts.size === 0) return null;
796
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
797
+ const winner = ranked[0];
798
+ const runnerUp = ranked[1]?.[1] ?? 0;
799
+ if (winner[1] < 3 || winner[1] < 2 * runnerUp) return null;
800
+ return { number: winner[0], lastIndex: lastIndex.get(winner[0]) ?? baseIndex };
801
+ }
802
+
803
+ /**
804
+ * @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
805
+ * @param {Provider} provider
806
+ * @param {string|null} repoUrl
807
+ * @param {string|null} host
808
+ */
809
+ function aggregate(candidates, provider, repoUrl, host) {
810
+ /** @type {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
811
+ const byKey = new Map();
812
+ for (const c of candidates) {
813
+ const key = `${c.kind}:${c.number}`;
814
+ const prev = byKey.get(key);
815
+ if (!prev) {
816
+ byKey.set(key, c);
817
+ continue;
818
+ }
819
+ const rank = STRENGTH_RANK[c.strength];
820
+ const prevRank = STRENGTH_RANK[prev.strength];
821
+ // Stronger wins; equal strength → most recent; equal recency → the more
822
+ // specific later-derived signal (e.g. create-url beats url).
823
+ if (rank > prevRank || (rank === prevRank && c.lastIndex >= prev.lastIndex)) {
824
+ byKey.set(key, c);
825
+ }
826
+ }
827
+
828
+ const issue = pickWinner(byKey, "issue");
829
+ const pr = pickWinner(byKey, "pr");
830
+ const allRefs = [...byKey.values()]
831
+ .sort((a, b) => STRENGTH_RANK[b.strength] - STRENGTH_RANK[a.strength] || b.lastIndex - a.lastIndex)
832
+ .slice(0, 10)
833
+ .map((c) => toRef(c, provider, repoUrl, host));
834
+
835
+ return {
836
+ provider: provider.name,
837
+ issue: issue ? toRef(issue, provider, repoUrl, host) : null,
838
+ pr: pr ? toRef(pr, provider, repoUrl, host) : null,
839
+ allRefs,
840
+ };
841
+ }
842
+
843
+ /**
844
+ * @param {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} byKey
845
+ * @param {"issue"|"pr"} kind
846
+ */
847
+ function pickWinner(byKey, kind) {
848
+ let best = null;
849
+ for (const c of byKey.values()) {
850
+ if (c.kind !== kind) continue;
851
+ if (!best) {
852
+ best = c;
853
+ continue;
854
+ }
855
+ const rank = STRENGTH_RANK[c.strength];
856
+ const bestRank = STRENGTH_RANK[best.strength];
857
+ if (rank > bestRank || (rank === bestRank && c.lastIndex > best.lastIndex)) best = c;
858
+ }
859
+ return best;
860
+ }
861
+
862
+ /**
863
+ * @param {{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}} c
864
+ * @param {Provider} provider
865
+ * @param {string|null} repoUrl
866
+ * @param {string|null} host
867
+ * @returns {Ref}
868
+ */
869
+ function toRef(c, provider, repoUrl, host) {
870
+ return {
871
+ kind: c.kind,
872
+ number: c.number,
873
+ strength: c.strength,
874
+ confidence: REF_CONFIDENCE[c.strength],
875
+ source: c.source,
876
+ url: c.strength === "mention" ? null : fillUrl(provider, c.kind, c.number, repoUrl, host),
877
+ lastIndex: c.lastIndex,
878
+ };
879
+ }
880
+
881
+ /**
882
+ * Fill a provider urlTemplate (`{host}`, `{owner}`, `{repo}`, `{number}`).
883
+ * Returns null when there is no template for the kind or no repoUrl.
884
+ * @param {Provider} provider
885
+ * @param {"issue"|"pr"} kind
886
+ * @param {number} number
887
+ * @param {string|null} repoUrl
888
+ * @param {string|null} host
889
+ */
890
+ function fillUrl(provider, kind, number, repoUrl, host) {
891
+ const template = provider.urlTemplates?.[kind];
892
+ if (!template || typeof repoUrl !== "string" || !repoUrl) return null;
893
+ // Templates embed {host}; without a host the URL would be malformed, so bail.
894
+ if (template.includes("{host}") && !host) return null;
895
+ const { owner, repo } = splitRepoUrl(repoUrl);
896
+ return template
897
+ .replaceAll("{host}", typeof host === "string" ? host : "")
898
+ .replaceAll("{owner}", owner)
899
+ .replaceAll("{repo}", repo)
900
+ .replaceAll("{number}", String(number));
901
+ }
902
+
903
+ /**
904
+ * Split an `owner/repo` path into its owner and repo parts (repo name minus
905
+ * any trailing `.git`).
906
+ * @param {string} repoUrl
907
+ */
908
+ function splitRepoUrl(repoUrl) {
909
+ const path = repoUrl.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
910
+ const slash = path.lastIndexOf("/");
911
+ if (slash === -1) return { owner: "", repo: path };
912
+ return { owner: path.slice(0, slash), repo: path.slice(slash + 1) };
913
+ }