@azure-id/orc 1.9.2 → 2.1.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 (228) hide show
  1. package/CHANGELOG.md +722 -0
  2. package/README-id.md +56 -42
  3. package/README.md +58 -41
  4. package/bin/build-agents.js +125 -29
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +1873 -180
  7. package/bin/fix.js +459 -0
  8. package/bin/gotcha-import.js +1081 -0
  9. package/bin/gotcha.js +1566 -0
  10. package/bin/graph-query.js +1 -1
  11. package/bin/graph.js +717 -717
  12. package/bin/habit.js +1538 -0
  13. package/bin/mockrun-catalog.js +284 -276
  14. package/bin/onboarding-content.js +2 -1
  15. package/bin/pricing.json +28 -21
  16. package/bin/run-undo.js +398 -0
  17. package/bin/trace-write.js +869 -0
  18. package/bin/verify-contracts.js +627 -100
  19. package/bin/verify-package.js +68 -10
  20. package/bin/webui/api.js +151 -6
  21. package/bin/webui/app.html +239 -232
  22. package/bin/webui/css/00-tokens.css +110 -92
  23. package/bin/webui/css/04-motion.css +87 -0
  24. package/bin/webui/css/06-responsive.css +203 -178
  25. package/bin/webui/css/panels/behaviour.css +205 -0
  26. package/bin/webui/css/panels/hookui.css +79 -0
  27. package/bin/webui/fixtures/behaviour.js +562 -0
  28. package/bin/webui/fixtures/extra.js +9 -9
  29. package/bin/webui/fixtures/flow.js +81 -81
  30. package/bin/webui/fixtures/hookui.js +506 -431
  31. package/bin/webui/fixtures/index.js +66 -3
  32. package/bin/webui/fixtures/knowledge.js +9 -3
  33. package/bin/webui/fixtures/maintenance.js +123 -42
  34. package/bin/webui/fixtures/rules.js +1007 -1002
  35. package/bin/webui/fixtures/settings.js +6 -7
  36. package/bin/webui/fixtures/stats.js +25 -6
  37. package/bin/webui/fixtures/wait.js +98 -97
  38. package/bin/webui/i18n/en/behaviour.json +156 -0
  39. package/bin/webui/i18n/en/hookui.json +290 -194
  40. package/bin/webui/i18n/en/maintenance.json +71 -52
  41. package/bin/webui/i18n/en/nav.json +25 -24
  42. package/bin/webui/i18n/en/settings.json +60 -60
  43. package/bin/webui/i18n/en/tour.json +37 -35
  44. package/bin/webui/i18n/id/behaviour.json +156 -0
  45. package/bin/webui/i18n/id/hookui.json +290 -194
  46. package/bin/webui/i18n/id/maintenance.json +71 -52
  47. package/bin/webui/i18n/id/nav.json +25 -24
  48. package/bin/webui/i18n/id/settings.json +60 -60
  49. package/bin/webui/i18n/id/tour.json +37 -35
  50. package/bin/webui/js/01-i18n.js +155 -154
  51. package/bin/webui/js/05-banners.js +181 -172
  52. package/bin/webui/js/06-edit.js +193 -183
  53. package/bin/webui/js/90-tour.js +498 -494
  54. package/bin/webui/js/91-shortcuts.js +126 -126
  55. package/bin/webui/js/99-boot.js +121 -118
  56. package/bin/webui/js/panels/behaviour.js +1087 -0
  57. package/bin/webui/js/panels/hookui.js +2196 -1691
  58. package/bin/webui/js/panels/maintenance.js +336 -235
  59. package/bin/webui/js/panels/settings.js +726 -719
  60. package/mock-run/INDEX.md +110 -107
  61. package/mock-run/extra-slots.md +1 -1
  62. package/mock-run/gotcha-import.md +118 -0
  63. package/mock-run/habits.md +129 -0
  64. package/mock-run/orc-aftermath.md +392 -392
  65. package/mock-run/orc-budget.md +29 -33
  66. package/mock-run/orc-cli.md +200 -200
  67. package/mock-run/orc-explain.md +86 -86
  68. package/mock-run/orc-extra.md +389 -392
  69. package/mock-run/orc-fix.md +87 -0
  70. package/mock-run/orc-pattern.md +112 -112
  71. package/mock-run/orc-quick.md +6 -1
  72. package/mock-run/orc.md +157 -157
  73. package/package.json +1 -1
  74. package/templates/agents/MODEL-MAPPING.md +37 -34
  75. package/templates/agents/orc-advisor-opus-5-xhigh.md +1 -7
  76. package/templates/agents/orc-analyze-mini-opus-5-med.md +1 -6
  77. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -5
  78. package/templates/agents/{orc-claude-writer-opus-4-8-high.md → orc-claude-writer-opus-5-low.md} +48 -53
  79. package/templates/agents/orc-context-combiner-opus-5-high.md +1 -11
  80. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  81. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  82. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  83. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  84. package/templates/agents/orc-executor-opus-5-high.md +14 -6
  85. package/templates/agents/orc-executor-opus-5-low.md +14 -6
  86. package/templates/agents/orc-executor-opus-5-med.md +14 -6
  87. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  88. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  89. package/templates/agents/orc-executor-sonnet-5-high.md +15 -7
  90. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  91. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  92. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  93. package/templates/agents/orc-judge-opus-5-xhigh.md +5 -9
  94. package/templates/agents/orc-learn-writer-opus-5-low.md +1 -7
  95. package/templates/agents/orc-pattern-codifier-opus-5-med.md +1 -8
  96. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  97. package/templates/agents/orc-planner-mini-opus-5-med.md +1 -4
  98. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -3
  99. package/templates/agents/orc-planner-opus-5-med.md +1 -4
  100. package/templates/agents/orc-recon-opus-5-low.md +1 -8
  101. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  102. package/templates/agents/orc-retro-opus-5-med.md +6 -8
  103. package/templates/agents/orc-retro-sonnet-5-high.md +7 -8
  104. package/templates/agents/orc-reviewer-opus-5-low.md +96 -0
  105. package/templates/agents/orc-scout-opus-5-low.md +1 -6
  106. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  107. package/templates/agents/orc-system-analyst-opus-5-high.md +1 -6
  108. package/templates/agents/orc-test-author-opus-5-med.md +4 -5
  109. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  110. package/templates/agents/orc-verifier-opus-5-med.md +16 -8
  111. package/templates/agents/{orc-wiki-scanner-opus-4-8-high.md → orc-wiki-scanner-opus-5-low.md} +74 -79
  112. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  113. package/templates/commands/orc-analyze.md +13 -21
  114. package/templates/commands/orc-fast.md +10 -15
  115. package/templates/commands/orc-fix.md +18 -0
  116. package/templates/commands/orc-poly.md +12 -21
  117. package/templates/commands/orc-pr-driver.md +11 -30
  118. package/templates/commands/orc-pr-setup.md +10 -31
  119. package/templates/commands/orc-route.md +11 -41
  120. package/templates/commands/orc-test.md +5 -60
  121. package/templates/commands/orc-wiki.md +64 -64
  122. package/templates/hooks/README.md +109 -7
  123. package/templates/hooks/orc-session-hook.js +339 -0
  124. package/templates/hooks/orc-statusline-render.js +317 -23
  125. package/templates/hooks/orc-statusline.js +165 -50
  126. package/templates/hooks/orc-subagent-line.js +23 -5
  127. package/templates/hooks/orc-weather-fetch.js +123 -0
  128. package/templates/skills/_shared/README.md +9 -0
  129. package/templates/skills/_shared/code-graph.md +47 -55
  130. package/templates/skills/_shared/config-precedence.md +3 -1
  131. package/templates/skills/_shared/extra-dispatch.md +78 -93
  132. package/templates/skills/_shared/gotchas.md +248 -177
  133. package/templates/skills/_shared/habits.md +101 -0
  134. package/templates/skills/_shared/lane-contract.md +84 -0
  135. package/templates/skills/_shared/opus5-only.md +14 -14
  136. package/templates/skills/_shared/phases/README.md +142 -83
  137. package/templates/skills/_shared/phases/analyst-gates.md +10 -21
  138. package/templates/skills/_shared/phases/execution.md +8 -14
  139. package/templates/skills/_shared/phases/house-rules.md +27 -32
  140. package/templates/skills/_shared/phases/intake.md +127 -133
  141. package/templates/skills/_shared/phases/mock-example.md +46 -56
  142. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  143. package/templates/skills/_shared/phases/planning.md +7 -17
  144. package/templates/skills/_shared/phases/preflight.md +19 -42
  145. package/templates/skills/_shared/phases/review.md +24 -28
  146. package/templates/skills/_shared/phases/rules.md +18 -42
  147. package/templates/skills/_shared/phases/scoring.md +55 -65
  148. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  149. package/templates/skills/_shared/phases/security.md +45 -55
  150. package/templates/skills/_shared/phases/ship.md +6 -15
  151. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  152. package/templates/skills/_shared/phases/summary.md +73 -48
  153. package/templates/skills/_shared/phases/testgen.md +41 -51
  154. package/templates/skills/_shared/phases/trace-verbs.md +447 -0
  155. package/templates/skills/_shared/phases/trace.md +66 -297
  156. package/templates/skills/_shared/phases/verify.md +62 -70
  157. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  158. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  159. package/templates/skills/_shared/read-ladder.md +2 -55
  160. package/templates/skills/_shared/return-validation.md +17 -70
  161. package/templates/skills/_shared/review-slice.md +106 -0
  162. package/templates/skills/_shared/smoke-gate.md +46 -28
  163. package/templates/skills/_shared/wait.md +1 -0
  164. package/templates/skills/context-combiner/SKILL.md +15 -44
  165. package/templates/skills/orc/README.md +2 -2
  166. package/templates/skills/orc/SKILL.md +35 -63
  167. package/templates/skills/orc/config.md +25 -27
  168. package/templates/skills/orc/examples/full-run-mock.md +2 -1
  169. package/templates/skills/orc/references/effort-and-mode.md +17 -17
  170. package/templates/skills/orc/references/pattern-gate.md +89 -89
  171. package/templates/skills/orc/references/phases/intake.md +41 -47
  172. package/templates/skills/orc/references/phases/integration.md +13 -19
  173. package/templates/skills/orc/references/preflight-report.md +11 -12
  174. package/templates/skills/orc/references/ultra-mode.md +9 -7
  175. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  176. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  177. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  178. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  179. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +12 -53
  180. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  181. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  182. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +11 -22
  183. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  184. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  185. package/templates/skills/orc-advisor/SKILL.md +56 -60
  186. package/templates/skills/orc-analyze/SKILL.md +33 -67
  187. package/templates/skills/orc-analyze/schemas/report-audit.md +2 -1
  188. package/templates/skills/orc-analyze/schemas/report-prose.md +2 -1
  189. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  190. package/templates/skills/orc-claude/SKILL.md +14 -14
  191. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  192. package/templates/skills/orc-diy/README.md +31 -0
  193. package/templates/skills/orc-diy/SKILL.md +27 -78
  194. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  195. package/templates/skills/orc-fast/SKILL.md +49 -75
  196. package/templates/skills/orc-fix/SKILL.md +110 -0
  197. package/templates/skills/orc-judge/SKILL.md +77 -82
  198. package/templates/skills/orc-mini/SKILL.md +64 -112
  199. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  200. package/templates/skills/orc-pattern/SKILL.md +27 -48
  201. package/templates/skills/orc-poly/SKILL.md +32 -61
  202. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  203. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  204. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  205. package/templates/skills/orc-quick/README.md +44 -3
  206. package/templates/skills/orc-quick/SKILL.md +77 -108
  207. package/templates/skills/orc-quick/references/dispatch-gate.md +18 -6
  208. package/templates/skills/orc-quick/references/gh-mode.md +50 -1
  209. package/templates/skills/orc-quick/references/look.md +3 -1
  210. package/templates/skills/orc-retro/SKILL.md +20 -19
  211. package/templates/skills/orc-retro/examples/retro-mock.md +17 -17
  212. package/templates/skills/orc-route/SKILL.md +25 -45
  213. package/templates/skills/orc-test/SKILL.md +16 -37
  214. package/templates/skills/orc-verify/SKILL.md +14 -32
  215. package/templates/skills/orc-wait/SKILL.md +156 -163
  216. package/templates/skills/orc-wiki/README.md +325 -325
  217. package/templates/skills/orc-wiki/SKILL.md +195 -195
  218. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  219. package/templates/skills/orc-wiki/references/extra.md +2 -2
  220. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  221. package/templates/skills/orc-wiki/references/phases/phase-0.md +2 -7
  222. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  223. package/templates/skills/orc-wiki/references/phases/phase-2.md +4 -9
  224. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  225. package/templates/skills/orc-wiki/references/phases/phase-3c.md +4 -8
  226. package/templates/agents/orc-claude-writer-opus-5-med.md +0 -55
  227. package/templates/agents/orc-reviewer-opus-5-med.md +0 -60
  228. package/templates/agents/orc-wiki-scanner-opus-5-med.md +0 -81
package/bin/gotcha.js ADDED
@@ -0,0 +1,1566 @@
1
+ "use strict";
2
+ // ── `orc gotcha` v2 — the gotchas engine (v2.0.0 W4) ────────────────────────
3
+ //
4
+ // v1 had the MODEL assign ids, dedupe and match. v2 moves all three to this
5
+ // module, and adds a raw layer the promotion is COMPUTED from:
6
+ //
7
+ // .claude/orc/gotchas.md the ledger — the v1 format, plus 9 optional
8
+ // fields in a fixed order (`source:` and the rest)
9
+ // .claude/orc/gotchas-archive.md evicted entries (bin/cli.js `prune`, unchanged)
10
+ // .claude/orc/observations.jsonl one line per observed event, append-only,
11
+ // written by THIS module only
12
+ //
13
+ // None is in the install manifest (`isPrunable` matches only skills/, commands/,
14
+ // agents/, hooks/), so `orc update`, `--prune` and `doctor --fix` never touch them.
15
+ //
16
+ // The heading is UNCHANGED (`GOTCHA_HEAD` in bin/cli.js, kind ∈ repair|drift|
17
+ // review|verify): a 1.x CLI must still parse a 2.0 file. The new information
18
+ // rides in the optional `source:` field (DE-9). Review learning is ALWAYS ON
19
+ // (DE-10): `card` has no off answer, and `gotcha_card_budget` is a SIZE.
20
+ //
21
+ // Routed by bin/cli.js (`orc gotcha add|match|card|filter|observe|list
22
+ // --candidates|accept|quality|why|export`). The v1 reads (`status`, `list`,
23
+ // `show`, `prune`) stay in bin/cli.js, unchanged.
24
+
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const crypto = require("crypto");
28
+ const { execFileSync } = require("child_process");
29
+
30
+ // ── The vocabularies (DATA) ─────────────────────────────────────────────────
31
+ const KINDS = ["repair", "drift", "review", "verify"];
32
+ // `source` → the `kind` it is filed under (`05` §2.2). A v1 entry has no
33
+ // `source:` line; its source is its kind.
34
+ const SOURCE_KIND = {
35
+ repair: "repair",
36
+ drift: "drift",
37
+ review: "review",
38
+ verify: "verify",
39
+ defect: "repair",
40
+ ci: "repair",
41
+ pr: "review",
42
+ sonar: "review",
43
+ sarif: "review",
44
+ dismissal: "review",
45
+ };
46
+ const V1_SOURCES = ["repair", "drift", "review", "verify"];
47
+ // An in-lane red → green with a reproduction promotes at once (`05` §4 (a)).
48
+ const IN_LANE_SOURCES = ["repair", "drift", "review", "verify", "defect", "ci"];
49
+ const CATEGORIES = [
50
+ "functional.logic",
51
+ "functional.check",
52
+ "functional.interface",
53
+ "functional.resource",
54
+ "functional.timing",
55
+ "functional.build",
56
+ "security",
57
+ "evolvability.structure",
58
+ "evolvability.documentation",
59
+ "evolvability.visual",
60
+ "test",
61
+ ];
62
+ const SEVERITIES = ["P0", "P1", "P2", "P3"];
63
+ const POLARITIES = ["flag", "suppress"];
64
+ const OUTCOMES = ["addressed", "open", "disputed", "wontfix", "flaky"];
65
+ const AUTHORS = ["human", "bot", "orc"];
66
+ // v2.1.0 §4.2 — a FIX record (`orc fix record`). `introduced_by` is who wrote
67
+ // the bad lines; `cause` is a REQUIRED ledger field already, so it is not reused.
68
+ const INTRODUCED_BY = ["orc", "ai", "human", "unknown"];
69
+ const FIX_VIA = ["orc-fix", "orc-quick", "import"];
70
+ // The fields a fix record keeps when a later import upserts the same row.
71
+ const FIX_FIELDS = ["introduced_by", "fix_run", "via", "miss", "missed_by", "class_by", "run"];
72
+ const SCOPE_CAP = 500; // review-scope.jsonl keeps the last 500 reviews (DE-13)
73
+ // The 8 required fields, in their FIXED order (v1, unchanged) …
74
+ const REQUIRED = ["trigger", "symptom", "cause", "fix", "scope", "origin", "hits", "last_seen"];
75
+ // … then the optional ones, fixed order when present. `cwe` rides beside
76
+ // `category` for a security entry (`05` §2.3 "security (+ cwe: CWE-###)").
77
+ const OPTIONAL = ["source", "rule", "category", "cwe", "severity", "polarity", "evidence", "helpful", "harmful"];
78
+
79
+ // ── The constants a retro can move ──────────────────────────────────────────
80
+ const JACCARD_MIN = 0.6; // word 3-gram Jaccard for a `sig` match
81
+ const WINDOW_DAYS = 90; // promotion (b), quiet, the quality window
82
+ const PROMOTE_CASES = 3; // (b) distinct addressed cases …
83
+ const PROMOTE_PRS = 2; // … in this many PRs
84
+ const DO_LINE_CASES = 5; // active → "do" line in executor slices
85
+ const SUPPRESS_DISPUTES = 2; // → proposed suppression
86
+ const MATCH_CAP = 3; // executor slice (v1)
87
+ const CARD_FLAG_CAP = 6; // flag + watch lines
88
+ const CARD_WATCH_CAP = 2;
89
+ const CARD_SUPPRESS_CAP = 2;
90
+ const CARD_BUDGET_DEFAULT = 600;
91
+ const CARD_BUDGET_MIN = 200;
92
+ const P3_CAP = 5;
93
+ const NOISE_MIN_FINDINGS = 10;
94
+ const NOISE_ACCEPTANCE = 0.35;
95
+ const QUALITY_FLOOR = 5; // reviews before `quality` answers
96
+ const QUALITY_TARGET = 0.6; // the acceptance a review kind aims at (the panel's tick)
97
+ const QUALITY_SERIES = 12; // reviews in the acceptance / P3 lines
98
+ const EVIDENCE_CAP = 5;
99
+ const SIG_MAX = 120;
100
+ const DAY = 86400000;
101
+
102
+ // ── Small helpers ───────────────────────────────────────────────────────────
103
+ const sha1 = (s) => crypto.createHash("sha1").update(String(s)).digest("hex");
104
+ const p2 = (n) => String(n).padStart(2, "0");
105
+ function dmy(d) {
106
+ return `${p2(d.getDate())}-${p2(d.getMonth() + 1)}-${d.getFullYear()}`;
107
+ }
108
+ // DD-MM-YYYY or YYYY-MM-DD[...] → ms, or NaN.
109
+ function dateMs(s) {
110
+ const t = String(s || "");
111
+ let m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(t);
112
+ if (m) return Date.UTC(+m[3], +m[2] - 1, +m[1]);
113
+ m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t);
114
+ if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]);
115
+ return NaN;
116
+ }
117
+ // A UTC-midnight ms (what dateMs returns) → DD-MM-YYYY, with no zone shift.
118
+ function dmyUtc(ms) {
119
+ const d = new Date(ms);
120
+ return `${p2(d.getUTCDate())}-${p2(d.getUTCMonth() + 1)}-${d.getUTCFullYear()}`;
121
+ }
122
+ function isoDay(d) {
123
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
124
+ }
125
+ const posix = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "");
126
+ const oneLine = (v) => String(v == null ? "" : v).replace(/[\r\n]+/g, " ").trim();
127
+ const estTokens = (s) => Math.ceil(String(s).length / 4);
128
+
129
+ // A glob → a RegExp. `**` crosses directories, `*` and `?` do not, `{a,b}` is
130
+ // an alternation. Paths are repo-relative with forward slashes.
131
+ function globRe(glob) {
132
+ const g = posix(glob);
133
+ let re = "";
134
+ for (let i = 0; i < g.length; i++) {
135
+ const c = g[i];
136
+ if (c === "*") {
137
+ if (g[i + 1] === "*") {
138
+ i++;
139
+ if (g[i + 1] === "/") {
140
+ i++;
141
+ re += "(?:.*/)?";
142
+ } else re += ".*";
143
+ } else re += "[^/]*";
144
+ } else if (c === "?") re += "[^/]";
145
+ else if (c === "{") {
146
+ const end = g.indexOf("}", i);
147
+ if (end === -1) re += "\\{";
148
+ else {
149
+ re += "(?:" + g.slice(i + 1, end).split(",").map((x) => x.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|") + ")";
150
+ i = end;
151
+ }
152
+ } else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
153
+ }
154
+ return new RegExp("^" + re + "$");
155
+ }
156
+ function inScope(scope, file) {
157
+ if (!scope) return false;
158
+ try {
159
+ return globRe(scope).test(posix(file));
160
+ } catch (_) {
161
+ return false;
162
+ }
163
+ }
164
+ // A glob that covers the whole repo is the unfiltered injection the v1
165
+ // contract forbids.
166
+ function wholeRepo(scope) {
167
+ const s = posix(scope).trim();
168
+ return !s || /^(\*\*\/?)*\*?(\.\*)?$/.test(s) || s === "**/*";
169
+ }
170
+ // Glob specificity: the literal path segments before the first wildcard.
171
+ function specificity(scope) {
172
+ const segs = posix(scope).split("/");
173
+ let n = 0;
174
+ for (const s of segs) {
175
+ if (/[*?{]/.test(s)) break;
176
+ n++;
177
+ }
178
+ return 1 + n;
179
+ }
180
+
181
+ // ── `sig` — the normalized text of a finding with no rule id ────────────────
182
+ // Lower case, identifiers / numbers / code removed, max 120 chars (`05` §3).
183
+ // Secrets are redacted FIRST (§8) — a token never lands in the file.
184
+ const SECRET_RES = [
185
+ /\b(?:bearer|basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
186
+ /\b(?:api[_-]?key|secret|password|passwd|token|auth)\s*[:=]\s*\S+/gi,
187
+ /\b(?:sk|pk|ghp|gho|ghs|glpat|xox[abp])[-_][A-Za-z0-9_-]{8,}/g,
188
+ /\bAKIA[0-9A-Z]{12,}\b/g,
189
+ /\b[A-Za-z0-9+/_-]{32,}={0,2}/g,
190
+ ];
191
+ function redact(text) {
192
+ let t = String(text || "");
193
+ for (const re of SECRET_RES) t = t.replace(re, " ");
194
+ return t;
195
+ }
196
+ function normalizeSig(text) {
197
+ let t = redact(text);
198
+ t = t
199
+ .replace(/```[\s\S]*?```/g, " ")
200
+ .replace(/`[^`]*`/g, " ")
201
+ .replace(/https?:\/\/\S+/g, " ")
202
+ .replace(/@[A-Za-z0-9_-]+/g, " ");
203
+ const words = t
204
+ .split(/\s+/)
205
+ .filter((w) => w && !/[_./\\()[\]{}<>=:;#$0-9]/.test(w.replace(/[,.!?;:]+$/, "")) && !/[a-z][A-Z]/.test(w))
206
+ .map((w) => w.toLowerCase().replace(/[^a-z' -]/g, ""))
207
+ .filter((w) => w && w !== "-");
208
+ let s = words.join(" ").replace(/\s+/g, " ").trim();
209
+ if (s.length > SIG_MAX) s = s.slice(0, SIG_MAX).replace(/\s+\S*$/, "").trim();
210
+ return s;
211
+ }
212
+ function grams(text) {
213
+ const w = String(text || "").toLowerCase().split(/\s+/).filter(Boolean);
214
+ if (w.length < 3) return new Set(w.length ? [w.join(" ")] : []);
215
+ const out = new Set();
216
+ for (let i = 0; i + 3 <= w.length; i++) out.add(w.slice(i, i + 3).join(" "));
217
+ return out;
218
+ }
219
+ function jaccard(a, b) {
220
+ const A = grams(a);
221
+ const B = grams(b);
222
+ if (!A.size || !B.size) return 0;
223
+ let inter = 0;
224
+ for (const g of A) if (B.has(g)) inter++;
225
+ return inter / (A.size + B.size - inter);
226
+ }
227
+
228
+ // ── Scope derivation (`05` §4) ──────────────────────────────────────────────
229
+ // The narrowest glob that covers every case: the file → `dir/**` → `**/*.<ext>`.
230
+ // A glob that would cover the whole repo is REFUSED, and the reason is named.
231
+ function deriveScope(paths) {
232
+ const ps = [...new Set(paths.map(posix).filter(Boolean))];
233
+ if (!ps.length) return { scope: null, refused: "no case carries a path" };
234
+ if (ps.length === 1) return { scope: ps[0], refused: null };
235
+ const dirs = ps.map((p) => p.split("/").slice(0, -1));
236
+ const common = [];
237
+ for (let i = 0; ; i++) {
238
+ const seg = dirs[0][i];
239
+ if (seg === undefined || !dirs.every((d) => d[i] === seg)) break;
240
+ common.push(seg);
241
+ }
242
+ if (common.length) return { scope: common.join("/") + "/**", refused: null };
243
+ const exts = [...new Set(ps.map((p) => (/\.([A-Za-z0-9]+)$/.exec(p) || [])[1] || ""))];
244
+ if (exts.length === 1 && exts[0]) return { scope: `**/*.${exts[0]}`, refused: null };
245
+ return { scope: null, refused: "the only glob that covers every case covers the whole repo" };
246
+ }
247
+
248
+ // ── The ledger (read through bin/cli.js `parseGotchas`, written here) ───────
249
+ function entryView(e) {
250
+ const f = e.fields;
251
+ const source = f.source && SOURCE_KIND[f.source] ? f.source : e.kind;
252
+ const num = (v) => {
253
+ const n = Number.parseInt(v, 10);
254
+ return Number.isFinite(n) ? n : 0;
255
+ };
256
+ return {
257
+ id: e.id,
258
+ area: e.area,
259
+ kind: e.kind,
260
+ source,
261
+ rule: f.rule || null,
262
+ category: f.category || null,
263
+ cwe: f.cwe || null,
264
+ severity: SEVERITIES.includes(f.severity) ? f.severity : null,
265
+ polarity: f.polarity === "suppress" ? "suppress" : "flag",
266
+ scope: f.scope || null,
267
+ trigger: f.trigger || null,
268
+ symptom: f.symptom || null,
269
+ fix: f.fix || null,
270
+ origin: f.origin || null,
271
+ hits: e.hits,
272
+ last_seen: f.last_seen || null,
273
+ evidence: f.evidence ? f.evidence.split(/\s+·\s+/).map((x) => x.trim()).filter(Boolean) : [],
274
+ helpful: num(f.helpful),
275
+ harmful: num(f.harmful),
276
+ raw: e,
277
+ };
278
+ }
279
+ function pad(name) {
280
+ return (name + ":").padEnd(11);
281
+ }
282
+ // Render one entry from its fields: heading, the 8 required, the optional
283
+ // ones present, then any other line the user wrote (kept, never dropped).
284
+ function renderEntry(id, area, kind, fields, extra) {
285
+ const out = [`## ${id} · ${area} · ${kind}`];
286
+ for (const k of REQUIRED) out.push(`- ${pad(k)}${oneLine(fields[k])}`);
287
+ for (const k of OPTIONAL) if (fields[k] !== undefined && fields[k] !== null && String(fields[k]) !== "") out.push(`- ${pad(k)}${oneLine(fields[k])}`);
288
+ for (const l of extra || []) out.push(l);
289
+ return out.join("\n");
290
+ }
291
+ function extraLines(e) {
292
+ return e.lines.slice(1).filter((l) => {
293
+ if (!l.trim()) return false;
294
+ const f = /^-\s+([a-z_]+):/.exec(l);
295
+ return !(f && (REQUIRED.includes(f[1]) || OPTIONAL.includes(f[1])));
296
+ });
297
+ }
298
+
299
+ function makeStore(claudeDir, deps) {
300
+ const file = deps.gotchasPath(claudeDir);
301
+ const archive = deps.gotchasArchivePath(claudeDir);
302
+ const obsFile = path.join(claudeDir, "orc", "observations.jsonl");
303
+ const readRaw = () => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
304
+ function load() {
305
+ const raw = readRaw();
306
+ const parsed = deps.parseGotchas(file);
307
+ return { raw, eol: raw && raw.includes("\r\n") ? "\r\n" : "\n", preamble: parsed.preamble, entries: parsed.entries };
308
+ }
309
+ function save(led) {
310
+ const body =
311
+ (led.preamble ? led.preamble + "\n\n" : led.entries.length ? "# Gotchas\n\n" : "") +
312
+ led.entries.map((e) => e.text).join("\n\n") +
313
+ "\n";
314
+ fs.mkdirSync(path.dirname(file), { recursive: true });
315
+ const tmp = file + ".tmp";
316
+ fs.writeFileSync(tmp, led.eol === "\r\n" ? body.replace(/\r?\n/g, "\r\n") : body);
317
+ fs.renameSync(tmp, file);
318
+ }
319
+ // IDs are monotonic and NEVER reused — the archive counts too.
320
+ function nextId(led) {
321
+ const ids = led.entries.map((e) => e.id).concat(deps.parseGotchas(archive).entries.map((e) => e.id));
322
+ const max = ids.reduce((m, id) => Math.max(m, Number(id.slice(2)) || 0), 0);
323
+ if (max >= 999) return null;
324
+ return "G-" + String(max + 1).padStart(3, "0");
325
+ }
326
+ function readObs() {
327
+ if (!fs.existsSync(obsFile)) return [];
328
+ const byId = new Map();
329
+ for (const line of fs.readFileSync(obsFile, "utf8").replace(/\r\n/g, "\n").split("\n")) {
330
+ if (!line.trim()) continue;
331
+ let o;
332
+ try {
333
+ o = JSON.parse(line);
334
+ } catch (_) {
335
+ continue;
336
+ }
337
+ if (o && o.obs) {
338
+ byId.delete(o.obs); // the LAST line per obs id wins, in file order
339
+ byId.set(o.obs, o);
340
+ }
341
+ }
342
+ return [...byId.values()];
343
+ }
344
+ function appendObs(o) {
345
+ fs.mkdirSync(path.dirname(obsFile), { recursive: true });
346
+ fs.appendFileSync(obsFile, JSON.stringify(o) + "\n");
347
+ }
348
+ return { file, archive, obsFile, load, save, nextId, readObs, appendObs };
349
+ }
350
+
351
+ // ── Linking an observation to an entry (the dedupe key) ─────────────────────
352
+ // Key = (rule or sig-signature, category, scope). A rule matches a rule; a
353
+ // sig matches the entry's symptom at 3-gram Jaccard ≥ JACCARD_MIN. The path
354
+ // must sit inside the entry's scope; a category must agree when both carry one.
355
+ function linksTo(o, v) {
356
+ if (o.category && v.category && o.category !== v.category) return false;
357
+ if (o.path && v.scope && !inScope(v.scope, o.path)) return false;
358
+ if (o.rule || v.rule) return !!(o.rule && v.rule && o.rule === v.rule);
359
+ return !!(o.sig && v.symptom && jaccard(o.sig, normalizeSig(v.symptom) || v.symptom) >= JACCARD_MIN);
360
+ }
361
+ function prOf(o) {
362
+ if (o.pr !== undefined && o.pr !== null && o.pr !== "") return String(o.pr);
363
+ const m = /\b(?:PR|MR)\s*#?\s*(\d+)/i.exec(o.ref || "");
364
+ return m ? m[1] : null;
365
+ }
366
+ const isStrong = (o) =>
367
+ o.outcome === "addressed" && ((o.source === "pr" && o.author === "human") || o.source === "sonar" || o.source === "defect");
368
+ // Human findings are MINED. Bot findings (a review bot, ORC's own reviewer)
369
+ // are MEASURED, not mined — except an in-lane red → green, which is a repair.
370
+ const isMined = (o) => o.author === "human" || ["sonar", "sarif", "ci", "defect"].includes(o.source) || (o.author === "orc" && o.reproduced === true);
371
+ const isLintRule = (r) => /^(eslint|ruff):/.test(r || "");
372
+
373
+ // ── Promotion (`05` §4), a PURE function of a candidate's cases ─────────────
374
+ // Returns { promote: bool, why, rule, needs }. Order: (a) in-lane, the miss,
375
+ // (c) security, (b) repetition. Every case counted is an `addressed` one.
376
+ function promotion(cases, nowMs) {
377
+ const now = nowMs === undefined ? Date.now() : nowMs;
378
+ const addressed = cases.filter((o) => o.outcome === "addressed");
379
+ const inLane = addressed.find((o) => o.reproduced === true && IN_LANE_SOURCES.includes(o.source));
380
+ if (inLane) return { promote: true, rule: "a", why: "in-lane red → green with a reproduction" };
381
+ const miss = addressed.find((o) => o.miss === true);
382
+ if (miss) return { promote: true, rule: "miss", why: "a miss: an ORC review read these lines and did not flag them" };
383
+ const sec = addressed.find(
384
+ (o) => o.category === "security" && (/^CWE-\d+$/.test(o.cwe || "") || ["HIGH", "BLOCKER"].includes(String(o.impact || "").toUpperCase()))
385
+ );
386
+ if (sec) return { promote: true, rule: "c", why: "security with a HIGH/BLOCKER impact or a CWE tag — at 1 case" };
387
+ const recent = addressed.filter((o) => {
388
+ const t = dateMs(o.at);
389
+ return Number.isFinite(t) && now - t <= WINDOW_DAYS * DAY;
390
+ });
391
+ const distinct = new Set(recent.map((o) => o.obs)).size;
392
+ const prs = new Set(recent.map(prOf).filter(Boolean)).size;
393
+ if (distinct >= PROMOTE_CASES && prs >= PROMOTE_PRS)
394
+ return { promote: true, rule: "b", why: `${distinct} addressed cases in ${prs} PRs within ${WINDOW_DAYS} days` };
395
+ const need = [];
396
+ if (distinct < PROMOTE_CASES) need.push(`${PROMOTE_CASES - distinct} more addressed case${PROMOTE_CASES - distinct === 1 ? "" : "s"} (${distinct} of ${PROMOTE_CASES})`);
397
+ if (prs < PROMOTE_PRS) need.push(`${PROMOTE_PRS - prs} more PR${PROMOTE_PRS - prs === 1 ? "" : "s"} (${prs} of ${PROMOTE_PRS})`);
398
+ return { promote: false, rule: null, why: null, needs: need.join(" in ") + ` within ${WINDOW_DAYS} days`, cases: distinct, prs };
399
+ }
400
+
401
+ // ── Candidates: the mined observations no entry has absorbed yet ────────────
402
+ function candidates(entries, obs, nowMs) {
403
+ const views = entries.map(entryView);
404
+ const groups = [];
405
+ for (const o of obs) {
406
+ if (!isMined(o)) continue;
407
+ if (!["addressed", "disputed", "open", "wontfix"].includes(o.outcome)) continue;
408
+ if (views.some((v) => linksTo(o, v))) continue;
409
+ let g = groups.find((x) =>
410
+ o.rule ? x.rule === o.rule && x.category === (o.category || null) : !x.rule && x.category === (o.category || null) && jaccard(o.sig, x.sig) >= JACCARD_MIN
411
+ );
412
+ if (!g) {
413
+ g = { rule: o.rule || null, sig: o.sig || null, category: o.category || null, cases: [] };
414
+ groups.push(g);
415
+ }
416
+ g.cases.push(o);
417
+ }
418
+ const out = [];
419
+ for (const g of groups) {
420
+ const addressed = g.cases.filter((o) => o.outcome === "addressed").length;
421
+ const disputed = g.cases.filter((o) => o.outcome === "disputed").length;
422
+ // One `addressed` or `disputed` outcome makes a candidate; an open case alone does not.
423
+ if (!addressed && !disputed) continue;
424
+ const { scope, refused } = deriveScope(g.cases.map((o) => o.path));
425
+ const key = `${g.rule || "sig:" + g.sig}|${g.category || ""}|${scope || ""}`;
426
+ const gating = ["P0", "P1"].some((s) => g.cases.some((o) => o.severity === s));
427
+ const suppressible = !(g.category === "security" || ((g.category || "").startsWith("functional.") && gating));
428
+ const isSuppression = disputed >= SUPPRESS_DISPUTES && disputed > addressed && suppressible;
429
+ const kind = isSuppression ? "suppression" : isLintRule(g.rule) ? "project-action" : "candidate";
430
+ const p = kind === "candidate" ? promotion(g.cases, nowMs) : { promote: false };
431
+ const first = g.cases[0];
432
+ const strong = g.cases.some(isStrong);
433
+ out.push({
434
+ id: "C-" + sha1((isSuppression ? "suppress|" : "") + key).slice(0, 6),
435
+ kind,
436
+ key,
437
+ rule: g.rule,
438
+ sig: g.sig,
439
+ category: g.category,
440
+ scope,
441
+ scope_refused: refused,
442
+ lang: first.lang || null,
443
+ source: (g.cases.find(isStrong) || first).source,
444
+ cases: g.cases.length,
445
+ addressed,
446
+ disputed,
447
+ prs: new Set(g.cases.map(prOf).filter(Boolean)).size,
448
+ strong,
449
+ watch: kind === "candidate" && strong && !!scope,
450
+ severity: g.cases.map((o) => o.severity).filter(Boolean).sort()[0] || null,
451
+ cwe: (g.cases.find((o) => o.cwe) || {}).cwe || null,
452
+ last_at: g.cases.map((o) => o.at).filter(Boolean).sort().pop() || null,
453
+ promote: kind === "candidate" && !!scope && p.promote,
454
+ promote_rule: p.rule || null,
455
+ why: p.why || null,
456
+ needs:
457
+ kind === "project-action"
458
+ ? `a lint rule — turn the linter rule on (${g.rule}); it is never a review item`
459
+ : kind === "suppression"
460
+ ? "a person accepts it: orc gotcha accept <id> (a suppression is never automatic)"
461
+ : refused
462
+ ? `no scope: ${refused}`
463
+ : p.promote
464
+ ? null
465
+ : p.needs,
466
+ obs: g.cases.map((o) => o.obs),
467
+ _cases: g.cases,
468
+ });
469
+ }
470
+ return out;
471
+ }
472
+ const publicCand = (c) => {
473
+ const o = Object.assign({}, c);
474
+ delete o._cases;
475
+ return o;
476
+ };
477
+
478
+ // A candidate → an entry body. Structured fields only (§8): the rule, the
479
+ // category, the scope and the normalized `sig` — never a sentence from a
480
+ // comment body.
481
+ function entryFromCandidate(c, why, nowMs) {
482
+ const now = new Date(nowMs === undefined ? Date.now() : nowMs);
483
+ const src = c.kind === "suppression" ? "dismissal" : SOURCE_KIND[c.source] ? c.source : "review";
484
+ const anchors = [];
485
+ for (const o of c._cases) {
486
+ if (!o.path) continue;
487
+ const a = posix(o.path) + (Array.isArray(o.lines) && o.lines.length ? ":" + o.lines[0] : "");
488
+ if (!anchors.includes(a)) anchors.push(a);
489
+ }
490
+ const what = c.rule ? `${c.rule}${c.sig ? " — " + c.sig : ""}` : c.sig || c.category || "a recurring finding";
491
+ const seen = c._cases.map((o) => dateMs(o.at)).filter(Number.isFinite);
492
+ return {
493
+ area: oneLine(c.lang || (/\.([A-Za-z0-9]+)$/.exec(c.scope || "") || [])[1] || "any").replace(/·/g, "-") || "any",
494
+ kind: SOURCE_KIND[src],
495
+ fields: {
496
+ trigger: `a change inside ${c.scope}${c.category ? " (" + c.category + ")" : ""}`,
497
+ symptom: what,
498
+ cause: c.kind === "suppression" ? "the team disputed this advice" : `${c.addressed} addressed case${c.addressed === 1 ? "" : "s"} recorded in observations.jsonl`,
499
+ fix: c.kind === "suppression" ? "do not flag it here" : "(see `orc gotcha why`)",
500
+ scope: c.scope,
501
+ origin: `${why} · ${dmy(now)} · ${c.cases} case${c.cases === 1 ? "" : "s"} · ${c.prs} PR${c.prs === 1 ? "" : "s"}`,
502
+ hits: String(c.kind === "suppression" ? c.disputed : c.addressed || 1),
503
+ last_seen: seen.length ? dmyUtc(Math.max(...seen)) : dmy(now),
504
+ source: src,
505
+ rule: c.rule || undefined,
506
+ category: c.category || undefined,
507
+ cwe: c.cwe || undefined,
508
+ severity: c.severity || undefined,
509
+ polarity: c.kind === "suppression" ? "suppress" : "flag",
510
+ evidence: anchors.slice(0, EVIDENCE_CAP).join(" · ") || undefined,
511
+ },
512
+ };
513
+ }
514
+
515
+ // ── Validating an `add` body. Returns { body } or { field, err } ────────────
516
+ function validateAdd(b) {
517
+ if (!b || typeof b !== "object" || Array.isArray(b)) return { field: "body", err: "must be one JSON object" };
518
+ const fields = b.fields && typeof b.fields === "object" ? Object.assign({}, b.fields) : {};
519
+ for (const k of REQUIRED.concat(OPTIONAL)) if (b[k] !== undefined && fields[k] === undefined) fields[k] = b[k];
520
+ const source = fields.source ? String(fields.source) : null;
521
+ if (source && !SOURCE_KIND[source]) return { field: "source", err: `must be one of: ${Object.keys(SOURCE_KIND).join(", ")}` };
522
+ const kind = b.kind ? String(b.kind) : source ? SOURCE_KIND[source] : null;
523
+ if (!KINDS.includes(kind)) return { field: "kind", err: `must be one of: ${KINDS.join(", ")}` };
524
+ if (source && SOURCE_KIND[source] !== kind) return { field: "kind", err: `source ${source} is filed under kind ${SOURCE_KIND[source]}` };
525
+ const area = oneLine(b.area || b.lang || "");
526
+ if (!area || area.includes("·")) return { field: "area", err: "must be a non-empty word with no `·`" };
527
+ const today = dmy(new Date());
528
+ if (fields.hits === undefined) fields.hits = 1;
529
+ if (fields.last_seen === undefined) fields.last_seen = today;
530
+ for (const k of REQUIRED) {
531
+ const v = fields[k];
532
+ if (v === undefined || v === null || !oneLine(v)) return { field: k, err: "is required" };
533
+ if (/[\r\n]/.test(String(v))) return { field: k, err: "must be one line" };
534
+ }
535
+ if (!/^\d+$/.test(String(fields.hits))) return { field: "hits", err: "must be a whole number" };
536
+ if (!/^\d{2}-\d{2}-\d{4}$/.test(String(fields.last_seen))) return { field: "last_seen", err: "must be DD-MM-YYYY" };
537
+ if (wholeRepo(fields.scope)) return { field: "scope", err: "covers the whole repo — a gotcha is never injected unfiltered" };
538
+ if (fields.category && !CATEGORIES.includes(fields.category)) return { field: "category", err: `must be one of: ${CATEGORIES.join(", ")}` };
539
+ if (fields.cwe && !/^CWE-\d+$/.test(fields.cwe)) return { field: "cwe", err: "must be CWE-<number>" };
540
+ if (fields.severity && !SEVERITIES.includes(fields.severity)) return { field: "severity", err: "must be P0, P1, P2 or P3" };
541
+ if (fields.polarity && !POLARITIES.includes(fields.polarity)) return { field: "polarity", err: "must be flag or suppress" };
542
+ for (const k of ["helpful", "harmful"]) if (fields[k] !== undefined && !/^\d+$/.test(String(fields[k]))) return { field: k, err: "must be a whole number" };
543
+ for (const k of OPTIONAL) if (fields[k] !== undefined && /[\r\n]/.test(String(fields[k]))) return { field: k, err: "must be one line" };
544
+ return { body: { area, kind, fields } };
545
+ }
546
+
547
+ // Dedupe on the key: same rule (or symptom at Jaccard ≥ 0.6 on the normalized
548
+ // text), same category when both carry one, same scope.
549
+ function findDuplicate(entries, body) {
550
+ const f = body.fields;
551
+ return entries.find((e) => {
552
+ const v = entryView(e);
553
+ if (posix(v.scope) !== posix(f.scope)) return false;
554
+ if (f.category && v.category && f.category !== v.category) return false;
555
+ if (f.rule || v.rule) return f.rule === v.rule;
556
+ const a = normalizeSig(f.symptom) || String(f.symptom).toLowerCase();
557
+ const b = normalizeSig(v.symptom) || String(v.symptom || "").toLowerCase();
558
+ return a === b || jaccard(a, b) >= JACCARD_MIN;
559
+ });
560
+ }
561
+
562
+ // A match bumps `hits`/`last_seen` and adds the evidence line — never a second entry.
563
+ function bumpEntry(e, { by = 1, seen, evidence, helpful = 0, harmful = 0 } = {}) {
564
+ const v = entryView(e);
565
+ const f = Object.assign({}, e.fields);
566
+ f.hits = String(e.hits + by);
567
+ const cur = dateMs(f.last_seen);
568
+ const nxt = dateMs(seen);
569
+ if (seen && (!Number.isFinite(cur) || nxt > cur)) f.last_seen = seen;
570
+ if (evidence && evidence.length) {
571
+ const list = v.evidence.slice();
572
+ for (const a of evidence) if (a && !list.includes(a)) list.push(a);
573
+ f.evidence = list.slice(-EVIDENCE_CAP).join(" · ");
574
+ }
575
+ if (helpful) f.helpful = String(v.helpful + helpful);
576
+ if (harmful) f.harmful = String(v.harmful + harmful);
577
+ e.fields = f;
578
+ e.hits = Number(f.hits);
579
+ e.text = renderEntry(e.id, e.area, e.kind, f, extraLines(e));
580
+ e.lines = e.text.split("\n");
581
+ return e;
582
+ }
583
+ function appendEntry(store, led, body) {
584
+ const id = store.nextId(led);
585
+ if (!id) return null;
586
+ const text = renderEntry(id, body.area, body.kind, body.fields, []);
587
+ const e = { id, area: body.area, kind: body.kind, lines: text.split("\n"), fields: {}, text };
588
+ for (const k of REQUIRED.concat(OPTIONAL)) if (body.fields[k] !== undefined && body.fields[k] !== null && String(body.fields[k]) !== "") e.fields[k] = oneLine(body.fields[k]);
589
+ e.hits = Number(e.fields.hits) || 0;
590
+ led.entries.push(e);
591
+ return e;
592
+ }
593
+
594
+ // ── Derived states: orphaned, quiet, "do" line ──────────────────────────────
595
+ function trackedFiles(repoRoot) {
596
+ try {
597
+ const out = execFileSync("git", ["ls-files", "-z"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
598
+ return out.split("\0").filter(Boolean);
599
+ } catch (_) {
600
+ return null; // not a git repo: the orphan check cannot run, so it marks nothing
601
+ }
602
+ }
603
+ function orphanReason(v, tracked, repoRoot) {
604
+ if (!tracked) return null;
605
+ if (v.scope && !tracked.some((f) => inScope(v.scope, f))) return "its scope matches no tracked file";
606
+ if (v.evidence.length) {
607
+ const alive = v.evidence.some((a) => fs.existsSync(path.join(repoRoot, a.replace(/:\d+(?:-\d+)?$/, ""))));
608
+ if (!alive) return "every evidence anchor points at a file that no longer exists";
609
+ }
610
+ return null;
611
+ }
612
+ function isQuiet(v, nowMs) {
613
+ const t = dateMs(v.last_seen);
614
+ return Number.isFinite(t) && nowMs - t > WINDOW_DAYS * DAY;
615
+ }
616
+ // Activation date = the first DD-MM-YYYY in `origin`.
617
+ function activation(v) {
618
+ const m = /(\d{2}-\d{2}-\d{4})/.exec(v.origin || "");
619
+ return m ? dateMs(m[1]) : NaN;
620
+ }
621
+ // Executor eligibility (`05` §5.1): a v1 source, OR the "do" line — fired
622
+ // again after activation AND 5+ total cases.
623
+ function doLine(v, obs) {
624
+ if (V1_SOURCES.includes(v.source)) return true;
625
+ if (v.hits < DO_LINE_CASES) return false;
626
+ const act = activation(v);
627
+ if (!Number.isFinite(act)) return false;
628
+ return obs.some((o) => o.outcome === "addressed" && linksTo(o, v) && dateMs(o.at) > act);
629
+ }
630
+
631
+ // ── Card lines ──────────────────────────────────────────────────────────────
632
+ const SEV_W = { P0: 4, P1: 3, P2: 2, P3: 1 };
633
+ function rankOf(v, nowMs) {
634
+ const t = dateMs(v.last_seen);
635
+ const days = Number.isFinite(t) ? Math.max(0, (nowMs - t) / DAY) : WINDOW_DAYS * 2;
636
+ return (SEV_W[v.severity] || 2) * Math.log(1 + Math.max(1, v.hits)) * (1 / (1 + days / WINDOW_DAYS)) * specificity(v.scope || "");
637
+ }
638
+ function lineFor(v) {
639
+ const label = v.rule || v.source;
640
+ const fix = v.fix && !v.fix.startsWith("(") ? ` → ${v.fix}` : "";
641
+ const count = v.hits > 1 ? `(${v.hits}×)` : IN_LANE_SOURCES.includes(v.source) ? "(reproduced)" : `(${v.hits}×)`;
642
+ return `${v.id} ${label} · ${v.scope} · ${v.symptom}${fix} ${count}`;
643
+ }
644
+ function watchLine(c) {
645
+ return `watch: ${c.id} ${c.rule || c.source} · ${c.scope} · ${c.sig || c.category || "a finding"} (seen ${c.addressed}×)`;
646
+ }
647
+ function suppressLine(v) {
648
+ return `do not flag: ${v.id} · ${v.scope} · ${v.symptom} (the team disputed this ${v.hits}×)`;
649
+ }
650
+
651
+ // ── Quality (acceptance of ORC's own findings) ──────────────────────────────
652
+ // v2.1.0 — A REVIEW IS ALSO READ FROM THE TRACES. Until 2.1.0 a review counted
653
+ // only when a lane piped an observation with `run`, and no lane did so in
654
+ // practice: the panel said "5 reviews needed — 0 so far" over 23 reviewer
655
+ // returns. A trace run in the window that has a `FINDING-OUTCOME` line OR a
656
+ // hook `RETURN orc-reviewer-*` line (a reviewer the user spawned directly gets a
657
+ // bootstrap trace with that pair) is a review. A clean review (zero findings)
658
+ // counts as a review and adds to nothing else (DE-11). Acceptance and the
659
+ // categories still come from the observations only.
660
+ const TRACE_REVIEW = /^\[[^\]]*\]\s+(?:\S+\s+FINDING-OUTCOME\b|hook\s+RETURN\s+orc-reviewer-)/m;
661
+ function tracedReviewRuns(claudeDir, deps, windowDays, nowMs) {
662
+ if (!deps || typeof deps.listTraces !== "function") return [];
663
+ let runs = [];
664
+ try {
665
+ runs = deps.listTraces(claudeDir).runs;
666
+ } catch (_) {
667
+ return [];
668
+ }
669
+ const out = [];
670
+ for (const r of runs) {
671
+ const t = r.date ? Date.parse(r.date + "T00:00:00Z") : r.mtime;
672
+ if (Number.isFinite(t) && nowMs - t > windowDays * DAY) continue;
673
+ let text = "";
674
+ try {
675
+ text = fs.readFileSync(r.path, "utf8");
676
+ } catch (_) {
677
+ continue;
678
+ }
679
+ if (TRACE_REVIEW.test(text)) out.push(r.name.replace(/\.txt$/, ""));
680
+ }
681
+ return out;
682
+ }
683
+ function fixSummary(fixes) {
684
+ const by = (key, vocab) => {
685
+ const out = {};
686
+ for (const v of vocab) out[v] = 0;
687
+ for (const o of fixes) {
688
+ const k = o[key] || "unknown";
689
+ out[k] = (out[k] || 0) + 1;
690
+ }
691
+ return out;
692
+ };
693
+ // `share` is the bar width, so the panel computes no number itself.
694
+ const causes = FIX_CAUSES.map((id) => {
695
+ const n = fixes.filter((o) => fixCause(o) === id).length;
696
+ return { id, n, share: fixes.length ? Math.round((n / fixes.length) * 1000) / 1000 : 0 };
697
+ });
698
+ return { total: fixes.length, by_introduced_by: by("introduced_by", INTRODUCED_BY), by_source: by("source", []), causes };
699
+ }
700
+ function missSummary(fixes) {
701
+ const miss = fixes.filter((o) => o.miss === true);
702
+ const cats = {};
703
+ for (const o of miss) {
704
+ const c = o.category || "uncategorized";
705
+ cats[c] = (cats[c] || 0) + 1;
706
+ }
707
+ return {
708
+ total: miss.length,
709
+ by_category: Object.entries(cats)
710
+ .map(([category, n]) => ({ category, n }))
711
+ .sort((a, b) => b.n - a.n || a.category.localeCompare(b.category)),
712
+ list: miss
713
+ .slice()
714
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)))
715
+ .slice(0, 10)
716
+ .map((o) => ({ obs: o.obs.slice(0, 8), at: o.at, path: o.path, lines: o.lines, source: o.source, rule: o.rule, category: o.category, introduced_by: o.introduced_by || null, missed_by: o.missed_by || null, fix_run: o.fix_run || null })),
717
+ };
718
+ }
719
+ function quality(obs, entries, windowDays, nowMs, traced) {
720
+ const fixes = fixesIn(obs, windowDays, nowMs);
721
+ const orc = obs.filter((o) => o.author === "orc" && (() => {
722
+ const t = dateMs(o.at);
723
+ return !Number.isFinite(t) || nowMs - t <= windowDays * DAY;
724
+ })());
725
+ // v2.1.0 §3.4 — the headline counts ORC reviews. A review the project asked for
726
+ // (`reviewer: "<name>"`) is counted in its own line, `by_reviewer`.
727
+ const external = orc.filter((o) => o.reviewer);
728
+ const own = orc.filter((o) => !o.reviewer);
729
+ const observedRuns = [...new Set(own.map((o) => o.run).filter(Boolean))];
730
+ const tracedRuns = [...new Set(traced || [])];
731
+ const runs = [...new Set(observedRuns.concat(tracedRuns))];
732
+ const byReviewer = { orc: runs.length };
733
+ for (const o of external) {
734
+ const k = o.reviewer;
735
+ byReviewer[k] = byReviewer[k] || new Set();
736
+ byReviewer[k].add(o.run || o.ref);
737
+ }
738
+ for (const k of Object.keys(byReviewer)) if (byReviewer[k] instanceof Set) byReviewer[k] = byReviewer[k].size;
739
+ const cats = {};
740
+ for (const o of orc) {
741
+ const c = o.category || "uncategorized";
742
+ const r = (cats[c] = cats[c] || { category: c, findings: 0, addressed: 0, disputed: 0, wontfix: 0, open: 0 });
743
+ r.findings++;
744
+ if (r[o.outcome] !== undefined) r[o.outcome]++;
745
+ }
746
+ const categories = Object.values(cats)
747
+ .map((r) => {
748
+ const decided = r.addressed + r.disputed + r.wontfix;
749
+ const acceptance = decided ? Math.round((r.addressed / decided) * 1000) / 1000 : null;
750
+ return Object.assign(r, { acceptance, noisy: r.findings >= NOISE_MIN_FINDINGS && acceptance !== null && acceptance < NOISE_ACCEPTANCE });
751
+ })
752
+ .sort((a, b) => b.findings - a.findings || a.category.localeCompare(b.category));
753
+ const p3 = orc.filter((o) => o.severity === "P3").length;
754
+ const sorted = orc.filter((o) => Number.isFinite(dateMs(o.at))).sort((a, b) => dateMs(a.at) - dateMs(b.at));
755
+ const half = Math.floor(sorted.length / 2);
756
+ const acc = (xs) => {
757
+ const d = xs.filter((o) => ["addressed", "disputed", "wontfix"].includes(o.outcome));
758
+ return d.length ? Math.round((d.filter((o) => o.outcome === "addressed").length / d.length) * 1000) / 1000 : null;
759
+ };
760
+ const first = acc(sorted.slice(0, half));
761
+ const second = acc(sorted.slice(half));
762
+ const gotchas = entries.map(entryView).map((v) => ({
763
+ id: v.id,
764
+ helpful: v.helpful,
765
+ harmful: v.harmful,
766
+ retire: v.harmful >= 3 && v.harmful >= v.helpful,
767
+ }));
768
+ return {
769
+ window_days: windowDays,
770
+ reviews: runs.length,
771
+ reviews_traced: tracedRuns.length,
772
+ reviews_observed: observedRuns.length,
773
+ by_reviewer: byReviewer,
774
+ project_reviews: Object.entries(byReviewer).filter(([k]) => k !== "orc").reduce((n, [, v]) => n + v, 0),
775
+ // v2.1.0 §4.2 — the fixes made after a review (`orc fix record`). A fix's
776
+ // author is the finder, so none of these is ever counted as a review above.
777
+ fixes: fixSummary(fixes),
778
+ misses: missSummary(fixes),
779
+ floor: QUALITY_FLOOR,
780
+ below_floor: runs.length < QUALITY_FLOOR,
781
+ findings: orc.length,
782
+ categories,
783
+ gotchas,
784
+ p3_per_review: runs.length ? Math.round((p3 / runs.length) * 100) / 100 : null,
785
+ trend: { first_half: first, second_half: second, direction: first === null || second === null ? null : second > first ? "up" : second < first ? "down" : "flat" },
786
+ // v2.0.0 W7 — the Behaviour panel's review-quality tab. The target, the
787
+ // bands and the floor sentence are the CLI's, so the panel computes none.
788
+ target: QUALITY_TARGET,
789
+ bands: { ok: QUALITY_TARGET, warn: NOISE_ACCEPTANCE },
790
+ series: reviewSeries(orc),
791
+ floor_line: runs.length < QUALITY_FLOOR ? `${QUALITY_FLOOR} reviews needed — ${runs.length} so far.` : null,
792
+ };
793
+ }
794
+ // One point per review (run), oldest first, the last 12: its acceptance
795
+ // (null when nothing in it was decided) and its advice-only (P3) count.
796
+ function reviewSeries(orc) {
797
+ const by = new Map();
798
+ for (const o of orc) {
799
+ if (!o.run) continue;
800
+ const t = dateMs(o.at);
801
+ const r = by.get(o.run) || { run: o.run, at: Number.isFinite(t) ? t : 0, list: [] };
802
+ if (Number.isFinite(t) && t > r.at) r.at = t;
803
+ r.list.push(o);
804
+ by.set(o.run, r);
805
+ }
806
+ return [...by.values()]
807
+ .sort((a, b) => a.at - b.at || a.run.localeCompare(b.run))
808
+ .slice(-QUALITY_SERIES)
809
+ .map((r) => {
810
+ const d = r.list.filter((o) => ["addressed", "disputed", "wontfix"].includes(o.outcome));
811
+ return {
812
+ run: r.run,
813
+ at: r.at ? new Date(r.at).toISOString() : null,
814
+ findings: r.list.length,
815
+ acceptance: d.length ? Math.round((d.filter((o) => o.outcome === "addressed").length / d.length) * 1000) / 1000 : null,
816
+ p3: r.list.filter((o) => o.severity === "P3").length,
817
+ };
818
+ });
819
+ }
820
+
821
+ // ── The panel view of the ledger (v2.0.0 W7) ────────────────────────────────
822
+ // `orc gotcha list --json` carries this beside the v1 rows: each entry's source,
823
+ // its computed status and a 7-week hits line, plus the sync state as it was
824
+ // last written. It READS the sync file; it never runs a sync.
825
+ const STATUSES = ["active", "candidate", "quiet", "orphaned"];
826
+ function panelView(claudeDir, deps, entries) {
827
+ const repoRoot = deps.repoRootOf(claudeDir);
828
+ const store = makeStore(claudeDir, deps);
829
+ const obs = store.readObs();
830
+ const now = Date.now();
831
+ const tracked = trackedFiles(repoRoot);
832
+ const rows = entries.map((e) => {
833
+ const v = entryView(e);
834
+ const why = orphanReason(v, tracked, repoRoot);
835
+ const status = why ? "orphaned" : isQuiet(v, now) ? "quiet" : "active";
836
+ const weeks = Array(7).fill(0);
837
+ for (const o of obs) {
838
+ if (!linksTo(o, v) && o.gotcha !== v.id) continue;
839
+ const t = dateMs(o.at);
840
+ if (!Number.isFinite(t)) continue;
841
+ const w = Math.floor((now - t) / (7 * DAY));
842
+ if (w >= 0 && w < 7) weeks[6 - w]++;
843
+ }
844
+ return { id: v.id, source: v.source, status, status_reason: why, rule: v.rule, scope: v.scope, fix: v.fix, symptom: v.symptom, polarity: v.polarity, origin: v.origin, weeks };
845
+ });
846
+ let cands = [];
847
+ try {
848
+ cands = candidates(entries, obs, now);
849
+ } catch (_) {}
850
+ let sync = null;
851
+ try {
852
+ const s = JSON.parse(fs.readFileSync(path.join(claudeDir, "orc", "gotchas-sync.json"), "utf8"));
853
+ sync = {
854
+ last_sync: s.last_sync || null,
855
+ sources: Object.entries(s.sources || {}).map(([name, c]) => ({
856
+ name,
857
+ state: c && c.last_error ? (/timed out/.test(c.last_error) ? "timed out" : "failed") : c && c.last_ok ? "ok" : "not configured",
858
+ last_ok: (c && c.last_ok) || null,
859
+ last_error: (c && c.last_error) || null,
860
+ })),
861
+ };
862
+ } catch (_) {}
863
+ const bySource = {};
864
+ for (const r of rows) bySource[r.source] = (bySource[r.source] || 0) + 1;
865
+ const status_counts = Object.fromEntries(STATUSES.map((s) => [s, s === "candidate" ? cands.filter((c) => c.kind === "candidate").length : rows.filter((r) => r.status === s).length]));
866
+ return {
867
+ sources: Object.keys(SOURCE_KIND),
868
+ statuses: STATUSES,
869
+ by_source: Object.keys(SOURCE_KIND).filter((s) => bySource[s]).map((s) => ({ source: s, n: bySource[s] })),
870
+ status_counts,
871
+ rows,
872
+ sync,
873
+ };
874
+ }
875
+
876
+ // ── Observations: validate one body, record a batch (W6a) ────────────────────
877
+ // `observe -` and every importer (`bin/gotcha-import.js`) go through these two,
878
+ // so there is ONE writer of observations.jsonl and ONE promotion pass.
879
+ // A body → { o } or { field, err }. Pure: no file is read or written.
880
+ function normalizeObservation(b, now) {
881
+ const bad = (field, err) => ({ field, err });
882
+ if (!b || typeof b !== "object" || Array.isArray(b)) return bad("body", "must be one JSON object");
883
+ if (!SOURCE_KIND[b.source]) return bad("source", `must be one of: ${Object.keys(SOURCE_KIND).join(", ")}`);
884
+ if (!b.ref || typeof b.ref !== "string") return bad("ref", "is required (e.g. \"PR 142 · thread 3\")");
885
+ if (!OUTCOMES.includes(b.outcome)) return bad("outcome", `must be one of: ${OUTCOMES.join(", ")}`);
886
+ if (!AUTHORS.includes(b.author)) return bad("author", `must be one of: ${AUTHORS.join(", ")}`);
887
+ // v2.1.0 — an ORC finding without its run counted as a finding and never as a
888
+ // review. A `ref` that starts with the run id fills `run`; otherwise refuse.
889
+ if (b.author === "orc" && !b.run) {
890
+ const m = typeof b.ref === "string" && /^(run-[a-z0-9][a-z0-9-]*-\d{6}-\d{6})\b/.exec(b.ref);
891
+ if (m) b = Object.assign({}, b, { run: m[1] });
892
+ else return bad("run", "is required when author is orc (the trace name, e.g. run-orc-add-billing-050826-141233)");
893
+ }
894
+ if (b.category !== undefined && b.category !== null && !CATEGORIES.includes(b.category)) return bad("category", `must be one of: ${CATEGORIES.join(", ")}`);
895
+ if (b.severity !== undefined && b.severity !== null && !SEVERITIES.includes(b.severity)) return bad("severity", "must be P0, P1, P2 or P3");
896
+ if (b.cwe !== undefined && b.cwe !== null && !/^CWE-\d+$/.test(b.cwe)) return bad("cwe", "must be CWE-<number>");
897
+ if (b.gotcha !== undefined && b.gotcha !== null && !/^G-\d{3}$/.test(b.gotcha)) return bad("gotcha", "must be a G-### id");
898
+ const sig = b.sig || b.text ? normalizeSig(b.sig || b.text) : null;
899
+ if (!b.rule && !sig) return bad("rule", "or `sig` is required (a stable rule id, or the finding text)");
900
+ const at = b.at && Number.isFinite(dateMs(b.at)) ? String(b.at).slice(0, 10) : isoDay(new Date(now));
901
+ const o = {
902
+ obs: sha1(`${b.source}|${b.ref}`),
903
+ source: b.source,
904
+ // No names (§8): the ref is kept only as the caller's structured locator.
905
+ ref: oneLine(b.ref).slice(0, 120),
906
+ commit: b.commit ? oneLine(b.commit).slice(0, 40) : null,
907
+ at,
908
+ rule: b.rule ? oneLine(b.rule) : null,
909
+ sig: b.rule ? sig || null : sig,
910
+ category: b.category || null,
911
+ severity: b.severity || null,
912
+ path: b.path ? posix(b.path) : null,
913
+ lines: Array.isArray(b.lines) ? b.lines.filter((n) => Number.isInteger(n)).slice(0, 2) : null,
914
+ lang: b.lang ? oneLine(b.lang) : null,
915
+ outcome: b.outcome,
916
+ author: b.author,
917
+ run: b.run ? oneLine(b.run) : null,
918
+ };
919
+ if (b.pr !== undefined && b.pr !== null) o.pr = String(b.pr);
920
+ if (b.cwe) o.cwe = b.cwe;
921
+ if (b.impact) o.impact = String(b.impact).toUpperCase();
922
+ if (b.reproduced === true) o.reproduced = true;
923
+ if (b.miss === true) o.miss = true;
924
+ if (b.gotcha) o.gotcha = b.gotcha;
925
+ // v2.1.0 — a review the PROJECT asked for (review-slice.md §0 option 2). An
926
+ // OPTIONAL field after the nine, so a 1.9.2 / 2.0.x reader still reads the row.
927
+ if (b.reviewer && typeof b.reviewer === "string" && b.reviewer !== "orc") o.reviewer = oneLine(b.reviewer).slice(0, 60);
928
+ // v2.1.0 §4.2 — a FIX record. OPTIONAL fields after the existing ones. The
929
+ // author is the FINDER (bot or human), never orc: an orc author would make
930
+ // Review Quality count the fix as an ORC review.
931
+ if (b.introduced_by !== undefined && b.introduced_by !== null) {
932
+ if (!INTRODUCED_BY.includes(b.introduced_by)) return bad("introduced_by", `must be one of: ${INTRODUCED_BY.join(", ")}`);
933
+ o.introduced_by = b.introduced_by;
934
+ }
935
+ if (b.via !== undefined && b.via !== null) {
936
+ if (!FIX_VIA.includes(b.via)) return bad("via", `must be one of: ${FIX_VIA.join(", ")}`);
937
+ o.via = b.via;
938
+ }
939
+ if (b.fix_run) o.fix_run = oneLine(b.fix_run).slice(0, 120);
940
+ if (b.missed_by) o.missed_by = oneLine(b.missed_by).slice(0, 120);
941
+ if (b.class_by === "user" || b.class_by === "evidence") o.class_by = b.class_by;
942
+ if ((o.introduced_by || o.via || o.fix_run) && o.author === "orc")
943
+ return bad("author", "of a fix is the FINDER (bot or human) — never orc, or Review Quality counts the fix as an ORC review");
944
+ return { o };
945
+ }
946
+ const isFixRecord = (o) => !!(o && (o.via || o.introduced_by));
947
+
948
+ // ── The reviewed ranges (DE-13) ─────────────────────────────────────────────
949
+ // `.claude/orc/review-scope.jsonl`: one line per review close,
950
+ // `{run, commit, at, files: {path: [[start,end],…]}}`. Class b user data: never
951
+ // pruned by age, never in the install manifest. The last SCOPE_CAP reviews are
952
+ // kept; what is dropped is COUNTED in the first line (`{"meta": …, "dropped": n}`).
953
+ function scopeFile(claudeDir) {
954
+ return path.join(claudeDir, "orc", "review-scope.jsonl");
955
+ }
956
+ function readScopes(claudeDir) {
957
+ const f = scopeFile(claudeDir);
958
+ const out = { rows: [], dropped: 0 };
959
+ if (!fs.existsSync(f)) return out;
960
+ for (const line of fs.readFileSync(f, "utf8").replace(/\r\n/g, "\n").split("\n")) {
961
+ if (!line.trim()) continue;
962
+ let r;
963
+ try {
964
+ r = JSON.parse(line);
965
+ } catch (_) {
966
+ continue;
967
+ }
968
+ if (r && r.meta === "review-scope") out.dropped = Number(r.dropped) || 0;
969
+ else if (r && r.run && r.files) out.rows.push(r);
970
+ }
971
+ return out;
972
+ }
973
+ // A body → { row } or { field, err }. Pure.
974
+ function normalizeScope(b, now) {
975
+ const bad = (field, err) => ({ field, err });
976
+ if (!b.run || typeof b.run !== "string") return bad("run", "is required (the trace name of the review's run)");
977
+ if (!b.files || typeof b.files !== "object" || Array.isArray(b.files)) return bad("files", "must be {path: [[start,end], …]}");
978
+ const files = {};
979
+ for (const [p, ranges] of Object.entries(b.files)) {
980
+ if (!Array.isArray(ranges)) return bad("files", `${p}: must be a list of [start,end] pairs`);
981
+ const list = [];
982
+ for (const r of ranges) {
983
+ const pair = Array.isArray(r) ? r : [r, r];
984
+ const s = pair[0];
985
+ const e = pair.length > 1 ? pair[1] : pair[0];
986
+ if (!Number.isInteger(s) || !Number.isInteger(e) || s < 1 || e < s) return bad("files", `${p}: ${JSON.stringify(r)} is not a [start,end] line pair`);
987
+ list.push([s, e]);
988
+ }
989
+ if (list.length) files[posix(p)] = list;
990
+ }
991
+ if (!Object.keys(files).length) return bad("files", "names no reviewed range");
992
+ return { row: { run: oneLine(b.run).slice(0, 120), commit: b.commit ? oneLine(b.commit).slice(0, 40) : null, at: new Date(now).toISOString(), files } };
993
+ }
994
+ function recordScope(claudeDir, row) {
995
+ const f = scopeFile(claudeDir);
996
+ const cur = readScopes(claudeDir);
997
+ const rows = cur.rows.concat(row);
998
+ const over = Math.max(0, rows.length - SCOPE_CAP);
999
+ fs.mkdirSync(path.dirname(f), { recursive: true });
1000
+ if (!over) {
1001
+ fs.appendFileSync(f, JSON.stringify(row) + "\n");
1002
+ return { kept: rows.length, dropped: cur.dropped, dropped_now: 0 };
1003
+ }
1004
+ const dropped = cur.dropped + over;
1005
+ const body = [JSON.stringify({ meta: "review-scope", dropped })].concat(rows.slice(over).map((r) => JSON.stringify(r))).join("\n") + "\n";
1006
+ const tmp = f + ".tmp";
1007
+ fs.writeFileSync(tmp, body);
1008
+ fs.renameSync(tmp, f);
1009
+ return { kept: rows.length - over, dropped, dropped_now: over };
1010
+ }
1011
+ // The latest stored review of an EARLIER run whose ranges overlap the fix's
1012
+ // lines → that scope row, or null. A review older than the commit that wrote
1013
+ // the bad lines could not have seen them, so it is never a miss.
1014
+ function findMiss(scopes, o, introducedMs) {
1015
+ if (!o || !o.path || !Array.isArray(o.lines) || !o.lines.length) return null;
1016
+ const s = o.lines[0];
1017
+ const e = o.lines.length > 1 ? o.lines[1] : o.lines[0];
1018
+ let best = null;
1019
+ for (const r of scopes) {
1020
+ if (!r.run || (o.fix_run && r.run === o.fix_run)) continue;
1021
+ const ranges = r.files && r.files[o.path];
1022
+ if (!Array.isArray(ranges) || !ranges.some((x) => x[0] <= e && s <= x[1])) continue;
1023
+ const at = Date.parse(r.at);
1024
+ if (Number.isFinite(introducedMs) && Number.isFinite(at) && at < introducedMs) continue;
1025
+ if (!best || at > Date.parse(best.at)) best = r;
1026
+ }
1027
+ return best;
1028
+ }
1029
+
1030
+ // The CLI computes `miss` for a fix record (§4.2 item 4) from the stored scopes.
1031
+ function applyMiss(claudeDir, o, introducedMs) {
1032
+ const hit = findMiss(readScopes(claudeDir).rows, o, introducedMs);
1033
+ return hit ? Object.assign({}, o, { miss: true, missed_by: hit.run }) : o;
1034
+ }
1035
+
1036
+ // ── The fix records in a window: the card line and the quality block ────────
1037
+ function fixesIn(obs, windowDays, nowMs) {
1038
+ return obs.filter((o) => {
1039
+ if (!isFixRecord(o)) return false;
1040
+ const t = dateMs(o.at);
1041
+ return !Number.isFinite(t) || nowMs - t <= windowDays * DAY;
1042
+ });
1043
+ }
1044
+ const FIX_LABEL = { sonar: "Sonar", ci: "CI", defect: "defect", pr: "PR review", review: "review", sarif: "SARIF" };
1045
+ function fixLine(o, n) {
1046
+ const what = o.rule ? o.rule.replace(/^sonar:/, "") : o.sig ? o.sig.slice(0, 60) : "a fix";
1047
+ const after =
1048
+ o.introduced_by === "orc" ? ` after ORC run ${o.run || "(run unknown)"}` : o.introduced_by === "ai" ? " after AI-written code" : "";
1049
+ return `fix: ${o.path} — ${FIX_LABEL[o.source] || o.source} ${what}${o.category ? ` (${o.category})` : ""}${after}${o.miss ? " · missed by review" : ""}${n > 1 ? ` (+${n - 1} more)` : ""}`;
1050
+ }
1051
+ // The cause buckets the panel draws, in its order. Computed HERE, so the panel
1052
+ // computes no number the CLI could give.
1053
+ const FIX_CAUSES = ["sonar", "orc", "ai", "other"];
1054
+ function fixCause(o) {
1055
+ if (o.source === "sonar") return "sonar";
1056
+ if (o.introduced_by === "orc") return "orc";
1057
+ if (o.introduced_by === "ai") return "ai";
1058
+ return "other";
1059
+ }
1060
+
1061
+ // Append each normalized observation, bump what an active entry already covers,
1062
+ // then run promotion ONCE. `skipUnchanged`: an importer re-reading the same
1063
+ // source never appends a line whose outcome did not move (the file stays small).
1064
+ function recordObservations(store, led, list, now, opts) {
1065
+ const skipUnchanged = !!(opts && opts.skipUnchanged);
1066
+ const known = new Map(store.readObs().map((x) => [x.obs, x]));
1067
+ const changes = { bumped: [], promoted: [], helpful: [], harmful: [] };
1068
+ const recorded = [];
1069
+ let unchanged = 0;
1070
+ for (let o of list) {
1071
+ const prev = known.get(o.obs) || null;
1072
+ // v2.1.0 §4.2 — an import that upserts a FIX record's row (the same id,
1073
+ // `sha1("sonar|sonar <key>")`) adds its outcome and keeps the fix fields.
1074
+ if (prev && isFixRecord(prev)) {
1075
+ const keep = {};
1076
+ for (const k of FIX_FIELDS) if (prev[k] !== undefined && prev[k] !== null && (o[k] === undefined || o[k] === null)) keep[k] = prev[k];
1077
+ if (Object.keys(keep).length) o = Object.assign({}, o, keep);
1078
+ }
1079
+ if (skipUnchanged && prev && prev.outcome === o.outcome) {
1080
+ unchanged++;
1081
+ continue;
1082
+ }
1083
+ store.appendObs(o);
1084
+ known.set(o.obs, o);
1085
+ recorded.push({ o, prev });
1086
+ const firstAddressed = o.outcome === "addressed" && !(prev && prev.outcome === "addressed");
1087
+ // A citation of a gotcha by ORC's reviewer: the ACE / ExpeL counters.
1088
+ if (o.gotcha && (!prev || prev.outcome !== o.outcome)) {
1089
+ const e = led.entries.find((x) => x.id === o.gotcha);
1090
+ if (e && o.outcome === "addressed") {
1091
+ bumpEntry(e, { by: 0, helpful: 1 });
1092
+ changes.helpful.push(e.id);
1093
+ } else if (e && o.outcome === "disputed") {
1094
+ bumpEntry(e, { by: 0, harmful: 1 });
1095
+ changes.harmful.push(e.id);
1096
+ }
1097
+ }
1098
+ // A mined case an ACTIVE entry already covers: bump it, never a second entry.
1099
+ const linked = isMined(o) ? led.entries.find((e) => entryView(e).polarity === "flag" && linksTo(o, entryView(e))) : null;
1100
+ if (linked && firstAddressed) {
1101
+ bumpEntry(linked, { seen: dmyUtc(dateMs(o.at)), evidence: o.path ? [o.path + (o.lines && o.lines.length ? ":" + o.lines[0] : "")] : [] });
1102
+ changes.bumped.push(linked.id);
1103
+ }
1104
+ }
1105
+ // Promotion — computed, and it names why in `origin`.
1106
+ const all = store.readObs();
1107
+ if (recorded.length) {
1108
+ for (const c of candidates(led.entries, all, now).filter((x) => x.promote)) {
1109
+ const body = entryFromCandidate(c, `promoted (${c.why})`, now);
1110
+ const e = appendEntry(store, led, body);
1111
+ if (e) changes.promoted.push({ candidate: c.id, id: e.id, rule: c.promote_rule, why: c.why });
1112
+ }
1113
+ }
1114
+ if (changes.bumped.length || changes.promoted.length || changes.helpful.length || changes.harmful.length) store.save(led);
1115
+ return { recorded, unchanged, changes, all };
1116
+ }
1117
+
1118
+ // ── The CLI ─────────────────────────────────────────────────────────────────
1119
+ const USAGE =
1120
+ "Usage: orc gotcha add <file|-> add one entry (dedupes; exit 0 added · 3 bumped · 2 malformed)\n" +
1121
+ " orc gotcha match --files <csv> the executor block (exit 0 · 1 no match · 4 off)\n" +
1122
+ " orc gotcha card --files <csv> [--lane <l>] [--full] the reviewer card (exit 0 · 1 no match)\n" +
1123
+ " orc gotcha filter --findings <file|-> drop suppressed / folded / noisy advice; never a P0 or P1\n" +
1124
+ " orc gotcha observe <file|-> append one observation, then run promotion (exit 0 · 2 malformed);\n" +
1125
+ " a {kind: \"review-scope\"} body stores the ranges a review read\n" +
1126
+ " orc gotcha list --candidates computed candidates and proposed suppressions (exit 0 · 1 none)\n" +
1127
+ " orc gotcha accept <C-id> a person promotes a candidate (exit 0 · 2 unknown)\n" +
1128
+ " orc gotcha quality [--window <days>] acceptance per category (exit 0 · 1 below the 5-review floor)\n" +
1129
+ " orc gotcha why <G-id|C-id> every observation behind it (exit 0 · 2 unknown)\n" +
1130
+ " orc gotcha export --review-md the active entries as a REVIEW.md (exit 0 · 1 none)\n" +
1131
+ " orc gotcha import sarif <file> SARIF 2.1.0 → observations (exit 0 · 2 not SARIF)\n" +
1132
+ " orc gotcha import sonar [--url U --project K] [--pr N|--branch B] [--org O] [--file <saved answer>]\n" +
1133
+ " Sonar issues → observations; token from SONAR_TOKEN only (exit 0 · 2 · 5 auth · 6 network)\n" +
1134
+ " orc gotcha import pr <n> PR review threads (humans only) → observations (exit 0 · 5 gh missing/not authed)\n" +
1135
+ " orc gotcha import issues [--label bug] closed defects with a closing PR → observations (exit 0 · 5)\n" +
1136
+ " orc gotcha sync [--force] every available source, incremental, time-boxed (exit 0 synced · 1 nothing new · 6 a source failed)";
1137
+
1138
+ function gotchaCmd(deps, sub) {
1139
+ const { emitJson, wantsJson, args } = deps;
1140
+ const asJson = wantsJson();
1141
+ const claudeDir = deps.resolveClaudeDir();
1142
+ const repoRoot = deps.repoRootOf(claudeDir);
1143
+ const store = makeStore(claudeDir, deps);
1144
+ const now = Date.now();
1145
+ const valueOf = (name) => {
1146
+ const i = args.indexOf(name);
1147
+ if (i === -1) return undefined;
1148
+ const v = args[i + 1];
1149
+ return v !== undefined && (v === "-" || !v.startsWith("--")) ? v : "";
1150
+ };
1151
+ const pos = deps.positionals();
1152
+ const out = (obj, code, human) => {
1153
+ if (asJson) emitJson(obj, code);
1154
+ if (human) human();
1155
+ process.exit(code);
1156
+ };
1157
+ const fail = (code, reason, message, extra) =>
1158
+ out(Object.assign({ ok: false, reason }, extra || {}, { message }), code, () => console.error(message));
1159
+ const readInput = (src) => {
1160
+ if (src === undefined || src === "" || src === true) return { err: "names no input — pass a file path or - for stdin" };
1161
+ try {
1162
+ const text = src === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(String(src)), "utf8");
1163
+ return { value: JSON.parse(text.replace(/^/, "")) };
1164
+ } catch (e) {
1165
+ return { err: `is not valid JSON (${e.message.split("\n")[0]})` };
1166
+ }
1167
+ };
1168
+ const cfg = () => deps.readOverride(claudeDir).map;
1169
+ const filesArg = () =>
1170
+ String(valueOf("--files") || "")
1171
+ .split(",")
1172
+ .map((f) => posix(f.trim()))
1173
+ .filter(Boolean);
1174
+
1175
+ // ── add ──
1176
+ if (sub === "add") {
1177
+ const inp = readInput(pos[2] || (args.includes("-") ? "-" : undefined));
1178
+ if (inp.err) return fail(2, "malformed", `gotcha add: the input ${inp.err}`, { field: "body" });
1179
+ const v = validateAdd(inp.value);
1180
+ if (v.err) return fail(2, "malformed", `gotcha add: field \`${v.field}\` ${v.err}`, { field: v.field });
1181
+ const led = store.load();
1182
+ const dup = findDuplicate(led.entries, v.body);
1183
+ if (dup) {
1184
+ const ev = v.body.fields.evidence ? String(v.body.fields.evidence).split(/\s+·\s+/) : [];
1185
+ bumpEntry(dup, { by: Number(v.body.fields.hits) || 1, seen: v.body.fields.last_seen, evidence: ev });
1186
+ store.save(led);
1187
+ return out({ ok: true, action: "bumped", id: dup.id, hits: dup.hits, last_seen: dup.fields.last_seen, file: store.file, text: dup.text }, 3, () =>
1188
+ console.log(`✓ ${dup.id} already covers this — hits ${dup.hits}, last seen ${dup.fields.last_seen}. Nothing new was added.`)
1189
+ );
1190
+ }
1191
+ const e = appendEntry(store, led, v.body);
1192
+ if (!e) return fail(2, "ids-exhausted", "gotcha add: G-999 is the last id the heading allows — archive with `orc gotcha prune`.", { field: "id" });
1193
+ store.save(led);
1194
+ return out({ ok: true, action: "added", id: e.id, hits: e.hits, last_seen: e.fields.last_seen, file: store.file, text: e.text }, 0, () =>
1195
+ console.log(`✓ added ${e.id} · ${e.area} · ${e.kind} → ${store.file}`)
1196
+ );
1197
+ }
1198
+
1199
+ const led = store.load();
1200
+ const views = led.entries.map(entryView);
1201
+ const obs = store.readObs();
1202
+
1203
+ // ── match — the executor block, the v1 rule ──
1204
+ if (sub === "match") {
1205
+ const files = filesArg();
1206
+ if (!files.length) return fail(2, "usage", "gotcha match: --files <csv> is required");
1207
+ // The v0.40.0 key stays for compatibility (DE-27): a 1.x config that says
1208
+ // `gotchas: off` is never silently overridden for the EXECUTOR slice. The
1209
+ // reviewer card has no off answer (DE-10).
1210
+ if (String(cfg().gotchas) === "off")
1211
+ return out({ ok: true, off: true, text: "", ids: [], matched: 0, known: views.length }, 4, () => console.log("gotchas: off — no executor block"));
1212
+ const hit = views.filter((v) => v.polarity === "flag" && doLine(v, obs) && files.some((f) => inScope(v.scope, f)));
1213
+ hit.sort((a, b) => b.hits - a.hits || dateMs(b.last_seen) - dateMs(a.last_seen));
1214
+ const top = hit.slice(0, MATCH_CAP);
1215
+ const text = top.map((v) => v.raw.text).join("\n\n");
1216
+ return out({ ok: true, text, ids: top.map((v) => v.id), matched: hit.length, known: views.length }, top.length ? 0 : 1, () =>
1217
+ console.log(top.length ? text : `no gotcha matches these files (${views.length} known) — no block`)
1218
+ );
1219
+ }
1220
+
1221
+ // ── card — the reviewer card, ALWAYS built when anything matches ──
1222
+ if (sub === "card") {
1223
+ const files = filesArg();
1224
+ if (!files.length) return fail(2, "usage", "gotcha card: --files <csv> is required");
1225
+ const lane = valueOf("--lane") || null;
1226
+ const budget = Math.max(CARD_BUDGET_MIN, Number(cfg().gotcha_card_budget) || CARD_BUDGET_DEFAULT);
1227
+ const tracked = trackedFiles(repoRoot);
1228
+ const orphaned = [];
1229
+ let quiet = 0;
1230
+ const rows = [];
1231
+ for (const v of views) {
1232
+ if (!files.some((f) => inScope(v.scope, f))) continue;
1233
+ const why = orphanReason(v, tracked, repoRoot);
1234
+ if (why) {
1235
+ orphaned.push({ id: v.id, why });
1236
+ continue;
1237
+ }
1238
+ if (isQuiet(v, now)) {
1239
+ quiet++;
1240
+ continue;
1241
+ }
1242
+ const miss = obs.some((o) => o.miss === true && linksTo(o, v));
1243
+ if (v.polarity === "suppress") rows.push({ id: v.id, type: "suppress", text: suppressLine(v), rank: rankOf(v, now), first: false });
1244
+ else rows.push({ id: v.id, type: "flag", text: lineFor(v), rank: rankOf(v, now), first: miss || v.category === "security", miss });
1245
+ }
1246
+ const cands = candidates(led.entries, obs, now).filter((c) => c.watch && files.some((f) => inScope(c.scope, f)));
1247
+ for (const c of cands)
1248
+ rows.push({ id: c.id, type: "watch", text: watchLine(c), rank: (SEV_W[c.severity] || 2) * Math.log(1 + c.addressed) * specificity(c.scope), first: false });
1249
+ // v2.1.0 §4.2 item 5 — ONE `fix` line per changed file that has a fix
1250
+ // record in the window: the newest, a miss first. Inside the budget; a line
1251
+ // that does not fit is counted like any other.
1252
+ const fixes = fixesIn(obs, WINDOW_DAYS, now);
1253
+ for (const f of files) {
1254
+ const mine = fixes.filter((o) => o.path === f);
1255
+ if (!mine.length) continue;
1256
+ mine.sort((a, b) => (b.miss ? 1 : 0) - (a.miss ? 1 : 0) || String(b.at).localeCompare(String(a.at)));
1257
+ rows.push({ id: "fix:" + f, type: "fix", text: fixLine(mine[0], mine.length), rank: mine.length, first: !!mine[0].miss, miss: !!mine[0].miss });
1258
+ }
1259
+ const byRank = (a, b) => (b.first ? 1 : 0) - (a.first ? 1 : 0) || b.rank - a.rank || a.id.localeCompare(b.id);
1260
+ const ordered = rows.filter((r) => r.type === "flag").sort(byRank)
1261
+ .concat(rows.filter((r) => r.type === "fix").sort(byRank))
1262
+ .concat(rows.filter((r) => r.type === "watch").sort(byRank))
1263
+ .concat(rows.filter((r) => r.type === "suppress").sort(byRank));
1264
+ const matched = ordered.length;
1265
+ if (!matched)
1266
+ return out({ ok: true, lane, text: "", ids: [], matched: 0, known: views.length, dropped: 0, orphaned: orphaned.map((o) => o.id), quiet, budget, tokens: 0 }, 1, () =>
1267
+ console.log(`no gotcha matches these files (${views.length} known) — no card`)
1268
+ );
1269
+ const kept = [];
1270
+ let nFlag = 0;
1271
+ let nWatch = 0;
1272
+ let nSup = 0;
1273
+ const headerFor = (n, d) => `gotchas (${n} of ${views.length} match${d ? ` · ${d} dropped by budget` : ""}):`;
1274
+ for (const r of ordered) {
1275
+ if (r.type === "watch" && nWatch >= CARD_WATCH_CAP) {
1276
+ r.dropped = "watch cap";
1277
+ continue;
1278
+ }
1279
+ if ((r.type === "flag" || r.type === "watch") && nFlag + nWatch >= CARD_FLAG_CAP) {
1280
+ r.dropped = "line cap";
1281
+ continue;
1282
+ }
1283
+ if (r.type === "suppress" && nSup >= CARD_SUPPRESS_CAP) {
1284
+ r.dropped = "suppress cap";
1285
+ continue;
1286
+ }
1287
+ const trial = [headerFor(matched, matched - kept.length - 1)].concat(kept.map((k) => k.text), r.text).join("\n");
1288
+ if (estTokens(trial) > budget) {
1289
+ r.dropped = "token budget";
1290
+ continue;
1291
+ }
1292
+ kept.push(r);
1293
+ if (r.type === "flag") nFlag++;
1294
+ else if (r.type === "watch") nWatch++;
1295
+ else if (r.type === "suppress") nSup++;
1296
+ }
1297
+ const dropped = matched - kept.length;
1298
+ const text = [headerFor(matched, dropped)].concat(kept.map((k) => k.text)).join("\n");
1299
+ const payload = {
1300
+ ok: true,
1301
+ lane,
1302
+ text,
1303
+ ids: kept.map((k) => k.id),
1304
+ matched,
1305
+ known: views.length,
1306
+ dropped,
1307
+ orphaned: orphaned.map((o) => o.id),
1308
+ quiet,
1309
+ budget,
1310
+ tokens: estTokens(text),
1311
+ };
1312
+ // BRIEF by default (T17 b): `text` + counts. `--full` adds every row.
1313
+ if (args.includes("--full"))
1314
+ Object.assign(payload, {
1315
+ rows: ordered.map((r) => ({ id: r.id, type: r.type, text: r.text, rank: Math.round(r.rank * 1000) / 1000, miss: !!r.miss, kept: !r.dropped, dropped: r.dropped || null })),
1316
+ orphaned_rows: orphaned,
1317
+ });
1318
+ return out(payload, 0, () => {
1319
+ console.log(text);
1320
+ if (orphaned.length) console.log(` (${orphaned.length} orphaned, left out: ${orphaned.map((o) => o.id).join(", ")})`);
1321
+ });
1322
+ }
1323
+
1324
+ // ── filter — after the review; NEVER touches a P0 or P1 ──
1325
+ if (sub === "filter") {
1326
+ const inp = readInput(valueOf("--findings"));
1327
+ if (inp.err) return fail(2, "malformed", `gotcha filter: --findings ${inp.err}`, { field: "findings" });
1328
+ const list = Array.isArray(inp.value) ? inp.value : inp.value && Array.isArray(inp.value.findings) ? inp.value.findings : null;
1329
+ if (!list) return fail(2, "malformed", "gotcha filter: --findings must be a JSON array (or {findings: [...]})", { field: "findings" });
1330
+ const sevOf = (f) => String((f && (f.severity || f.priority)) || "").toUpperCase();
1331
+ const pathOf = (f) => posix((f && (f.path || f.file)) || "");
1332
+ const gating = (f) => sevOf(f) === "P0" || sevOf(f) === "P1";
1333
+ const removed = [];
1334
+ const suppressed = {};
1335
+ let rest = [];
1336
+ const sup = views.filter((v) => v.polarity === "suppress" && v.category);
1337
+ for (const f of list) {
1338
+ if (!gating(f) && ["P2", "P3"].includes(sevOf(f))) {
1339
+ const by = sup.find((v) => v.category === (f && f.category) && pathOf(f) && inScope(v.scope, pathOf(f)));
1340
+ if (by) {
1341
+ removed.push({ finding: f, by: by.id });
1342
+ suppressed[by.id] = (suppressed[by.id] || 0) + 1;
1343
+ continue;
1344
+ }
1345
+ }
1346
+ rest.push(f);
1347
+ }
1348
+ // P3 capped at 5; the rest fold into `+N similar`.
1349
+ let p3 = 0;
1350
+ let folded = 0;
1351
+ rest = rest.filter((f) => {
1352
+ if (sevOf(f) !== "P3") return true;
1353
+ p3++;
1354
+ if (p3 <= P3_CAP) return true;
1355
+ folded++;
1356
+ removed.push({ finding: f, by: "fold" });
1357
+ return false;
1358
+ });
1359
+ // The per-category noise budget from `quality`.
1360
+ const q = quality(obs, led.entries, WINDOW_DAYS, now);
1361
+ const noisy = new Set(q.categories.filter((c) => c.noisy).map((c) => c.category));
1362
+ const seenNoisy = {};
1363
+ rest = rest.filter((f) => {
1364
+ const cat = f && f.category;
1365
+ if (gating(f) || !noisy.has(cat)) return true;
1366
+ seenNoisy[cat] = (seenNoisy[cat] || 0) + 1;
1367
+ if (seenNoisy[cat] <= 1) return true;
1368
+ removed.push({ finding: f, by: `noise:${cat}` });
1369
+ return false;
1370
+ });
1371
+ const payload = { ok: true, kept: rest, removed, folded, suppressed, noisy: [...noisy], counts: { in: list.length, kept: rest.length, removed: removed.length } };
1372
+ return out(payload, 0, () => {
1373
+ console.log(`gotcha filter: ${list.length} in · ${rest.length} kept · ${removed.length} removed${folded ? ` (+${folded} similar P3 folded)` : ""}`);
1374
+ for (const r of removed) console.log(` removed by ${r.by}: ${oneLine((r.finding && (r.finding.title || r.finding.summary || r.finding.id)) || "(finding)")}`);
1375
+ });
1376
+ }
1377
+
1378
+ // ── observe — append one observation, then promotion ──
1379
+ if (sub === "observe") {
1380
+ const inp = readInput(pos[2] || (args.includes("-") ? "-" : undefined));
1381
+ if (inp.err) return fail(2, "malformed", `gotcha observe: the input ${inp.err}`, { field: "body" });
1382
+ // v2.1.0 DE-13 — a review close stores the ranges it read. Not an
1383
+ // observation: it goes to review-scope.jsonl, which `orc fix record` reads.
1384
+ if (inp.value && inp.value.kind === "review-scope") {
1385
+ const s = normalizeScope(inp.value, now);
1386
+ if (s.err) return fail(2, "malformed", `gotcha observe: review-scope field \`${s.field}\` ${s.err}`, { field: s.field });
1387
+ const w = recordScope(claudeDir, s.row);
1388
+ return out({ ok: true, kind: "review-scope", run: s.row.run, files: Object.keys(s.row.files).length, kept: w.kept, dropped: w.dropped, cap: SCOPE_CAP, file: scopeFile(claudeDir) }, 0, () =>
1389
+ console.log(`✓ review scope stored for ${s.row.run} (${Object.keys(s.row.files).length} file(s) · ${w.kept} of the last ${SCOPE_CAP} reviews kept${w.dropped ? ` · ${w.dropped} older dropped` : ""})`)
1390
+ );
1391
+ }
1392
+ const n = normalizeObservation(inp.value, now);
1393
+ if (n.err) return fail(2, "malformed", `gotcha observe: field \`${n.field}\` ${n.err}`, { field: n.field });
1394
+ let o = n.o;
1395
+ if (isFixRecord(o) && !o.miss) o = applyMiss(claudeDir, o, NaN);
1396
+ const { recorded, changes, all } = recordObservations(store, led, [o], now);
1397
+ const prev = recorded[0].prev;
1398
+ o = recorded[0].o;
1399
+ const after = candidates(led.entries, all, now);
1400
+ const mine = after.find((c) => c.obs.includes(o.obs));
1401
+ const payload = {
1402
+ ok: true,
1403
+ obs: o.obs,
1404
+ replaced: !!prev,
1405
+ observation: o,
1406
+ candidate: mine ? publicCand(mine) : null,
1407
+ watch: !!(mine && mine.watch),
1408
+ bumped: changes.bumped,
1409
+ promoted: changes.promoted,
1410
+ helpful: changes.helpful,
1411
+ harmful: changes.harmful,
1412
+ file: store.obsFile,
1413
+ };
1414
+ return out(payload, 0, () => {
1415
+ console.log(`✓ observed ${o.obs.slice(0, 8)} (${o.source} · ${o.outcome})${prev ? " — replaces the earlier line" : ""}`);
1416
+ for (const p of changes.promoted) console.log(` promoted ${p.candidate} → ${p.id}: ${p.why}`);
1417
+ for (const id of changes.bumped) console.log(` bumped ${id}`);
1418
+ if (mine) console.log(` candidate ${mine.id}: ${mine.needs || "ready"}`);
1419
+ });
1420
+ }
1421
+
1422
+ // ── list --candidates ──
1423
+ if (sub === "list" && args.includes("--candidates")) {
1424
+ const cands = candidates(led.entries, obs, now).map(publicCand);
1425
+ const tracked = trackedFiles(repoRoot);
1426
+ const orphaned = views.map((v) => ({ id: v.id, why: orphanReason(v, tracked, repoRoot) })).filter((x) => x.why);
1427
+ const quiet = views.filter((v) => isQuiet(v, now)).map((v) => v.id);
1428
+ const retire = views.filter((v) => v.harmful >= 3 && v.harmful >= v.helpful).map((v) => ({ id: v.id, helpful: v.helpful, harmful: v.harmful, proposal: "retire — ORC never applies it; delete the block yourself" }));
1429
+ const payload = {
1430
+ ok: true,
1431
+ count: cands.length,
1432
+ candidates: cands.filter((c) => c.kind === "candidate"),
1433
+ suppressions: cands.filter((c) => c.kind === "suppression"),
1434
+ project_actions: cands.filter((c) => c.kind === "project-action"),
1435
+ retire,
1436
+ quiet,
1437
+ orphaned,
1438
+ observations: obs.length,
1439
+ rules: { cases: PROMOTE_CASES, prs: PROMOTE_PRS, window_days: WINDOW_DAYS, jaccard_min: JACCARD_MIN, suppress_disputes: SUPPRESS_DISPUTES, do_line_cases: DO_LINE_CASES },
1440
+ };
1441
+ return out(payload, cands.length ? 0 : 1, () => {
1442
+ if (!cands.length) console.log(`no candidates (${obs.length} observation${obs.length === 1 ? "" : "s"})`);
1443
+ for (const c of cands) console.log(` ${c.id} · ${c.kind} · ${c.rule || c.sig} · ${c.scope || "(no scope)"} · ${c.addressed} addressed / ${c.disputed} disputed\n ${c.needs || "ready"}`);
1444
+ for (const r of retire) console.log(` retire? ${r.id} — harmful ${r.harmful}, helpful ${r.helpful}`);
1445
+ });
1446
+ }
1447
+
1448
+ // ── accept <C-id> ──
1449
+ if (sub === "accept") {
1450
+ const id = String(pos[2] || "").trim();
1451
+ const c = candidates(led.entries, obs, now).find((x) => x.id === id);
1452
+ if (!c) return fail(2, "unknown", `gotcha accept: no candidate "${id}". See: orc gotcha list --candidates`, { id: id || null });
1453
+ if (c.kind === "project-action") return fail(2, "lint-rule", `gotcha accept: ${id} is a lint rule — turn the linter rule on (${c.rule}); it is never a review item`, { id });
1454
+ if (!c.scope) return fail(2, "no-scope", `gotcha accept: ${id} has no scope — ${c.scope_refused}`, { id });
1455
+ const e = appendEntry(store, led, entryFromCandidate(c, "accepted by a person", now));
1456
+ if (!e) return fail(2, "ids-exhausted", "gotcha accept: G-999 is the last id the heading allows.", { id });
1457
+ store.save(led);
1458
+ return out({ ok: true, candidate: id, id: e.id, polarity: e.fields.polarity, file: store.file, text: e.text }, 0, () => console.log(`✓ ${id} → ${e.id} (${e.fields.polarity})`));
1459
+ }
1460
+
1461
+ // ── quality ──
1462
+ if (sub === "quality") {
1463
+ const w = Number(valueOf("--window"));
1464
+ const windowDays = Number.isInteger(w) && w > 0 ? w : WINDOW_DAYS;
1465
+ const q = quality(obs, led.entries, windowDays, now, tracedReviewRuns(claudeDir, deps, windowDays, now));
1466
+ return out(Object.assign({ ok: true }, q), q.below_floor ? 1 : 0, () => {
1467
+ if (q.below_floor) console.log(`gotcha quality: ${q.reviews} review(s) in ${windowDays} days — below the ${QUALITY_FLOOR}-review floor, no answer yet`);
1468
+ for (const c of q.categories) console.log(` ${c.category.padEnd(28)} ${c.findings} findings · acceptance ${c.acceptance === null ? "—" : Math.round(c.acceptance * 100) + " %"}${c.noisy ? " · noisy" : ""}`);
1469
+ });
1470
+ }
1471
+
1472
+ // ── why <G-id|C-id> ──
1473
+ if (sub === "why") {
1474
+ const id = String(pos[2] || "").trim().toUpperCase();
1475
+ let list = null;
1476
+ let kind = null;
1477
+ const v = views.find((x) => x.id === id);
1478
+ if (v) {
1479
+ kind = "gotcha";
1480
+ list = obs.filter((o) => o.gotcha === id || linksTo(o, v));
1481
+ } else {
1482
+ const c = candidates(led.entries, obs, now).find((x) => x.id.toUpperCase() === id);
1483
+ if (c) {
1484
+ kind = c.kind;
1485
+ list = c._cases;
1486
+ }
1487
+ }
1488
+ if (!list) return fail(2, "unknown", `gotcha why: no gotcha or candidate "${pos[2] || ""}"`, { id: pos[2] || null });
1489
+ return out({ ok: true, id, kind, count: list.length, observations: list, origin: v ? v.origin : null }, 0, () => {
1490
+ console.log(`${id} (${kind}) — ${list.length} observation${list.length === 1 ? "" : "s"}${v && v.origin ? " · origin: " + v.origin : ""}`);
1491
+ for (const o of list) console.log(` ${o.at} · ${o.source} · ${o.outcome} · ${o.path || "-"} · ${o.rule || o.sig || ""}`);
1492
+ });
1493
+ }
1494
+
1495
+ // ── export --review-md (DE-15) ──
1496
+ if (sub === "export") {
1497
+ if (!args.includes("--review-md")) return fail(2, "usage", "gotcha export: only --review-md is supported");
1498
+ const tracked = trackedFiles(repoRoot);
1499
+ const live = views.filter((v) => !orphanReason(v, tracked, repoRoot));
1500
+ const flags = live.filter((v) => v.polarity === "flag").sort((a, b) => b.hits - a.hits || a.id.localeCompare(b.id));
1501
+ const sups = live.filter((v) => v.polarity === "suppress");
1502
+ const md = [
1503
+ "# REVIEW.md — what this project has already gotten wrong",
1504
+ "",
1505
+ "Generated by `orc gotcha export --review-md` from `.claude/orc/gotchas.md`. Do not edit here: edit the ledger and export again.",
1506
+ "",
1507
+ ];
1508
+ if (flags.length) md.push("## Always check", "", ...flags.map((v) => `- ${lineFor(v)}`), "");
1509
+ if (sups.length) md.push("## Do not flag", "", ...sups.map((v) => `- ${suppressLine(v)}`), "");
1510
+ const markdown = md.join("\n");
1511
+ const count = flags.length + sups.length;
1512
+ return out({ ok: true, count, ids: flags.concat(sups).map((v) => v.id), markdown }, count ? 0 : 1, () =>
1513
+ process.stdout.write(count ? markdown : "no active gotchas — nothing to export\n")
1514
+ );
1515
+ }
1516
+
1517
+ return fail(sub ? 2 : 1, "usage", USAGE);
1518
+ }
1519
+
1520
+ module.exports = {
1521
+ KINDS,
1522
+ SOURCE_KIND,
1523
+ V1_SOURCES,
1524
+ IN_LANE_SOURCES,
1525
+ CATEGORIES,
1526
+ REQUIRED,
1527
+ OPTIONAL,
1528
+ JACCARD_MIN,
1529
+ WINDOW_DAYS,
1530
+ CARD_BUDGET_DEFAULT,
1531
+ CARD_BUDGET_MIN,
1532
+ globRe,
1533
+ inScope,
1534
+ deriveScope,
1535
+ normalizeSig,
1536
+ jaccard,
1537
+ promotion,
1538
+ candidates,
1539
+ validateAdd,
1540
+ renderEntry,
1541
+ entryView,
1542
+ quality,
1543
+ panelView,
1544
+ STATUSES,
1545
+ gotchaCmd,
1546
+ USAGE,
1547
+ // W6a — the importers (bin/gotcha-import.js) record through these.
1548
+ makeStore,
1549
+ normalizeObservation,
1550
+ recordObservations,
1551
+ // v2.1.0 W6 — `orc fix` (bin/fix.js) records through these.
1552
+ INTRODUCED_BY,
1553
+ FIX_CAUSES,
1554
+ SCOPE_CAP,
1555
+ isFixRecord,
1556
+ readScopes,
1557
+ normalizeScope,
1558
+ recordScope,
1559
+ findMiss,
1560
+ applyMiss,
1561
+ fixesIn,
1562
+ fixLine,
1563
+ dateMs,
1564
+ redact,
1565
+ sha1,
1566
+ };