@azure-id/orc 1.9.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/README-id.md +20 -36
  3. package/README.md +24 -34
  4. package/bin/build-agents.js +206 -109
  5. package/bin/cli.js +760 -62
  6. package/bin/gotcha-import.js +1081 -0
  7. package/bin/gotcha.js +1286 -0
  8. package/bin/graph-query.js +1 -1
  9. package/bin/graph.js +717 -717
  10. package/bin/habit.js +1453 -0
  11. package/bin/mockrun-catalog.js +281 -276
  12. package/bin/onboarding-content.js +4 -4
  13. package/bin/pricing.json +207 -200
  14. package/bin/run-undo.js +398 -0
  15. package/bin/trace-write.js +657 -0
  16. package/bin/verify-contracts.js +527 -81
  17. package/bin/verify-package.js +58 -11
  18. package/bin/webui/api.js +26 -0
  19. package/bin/webui/app.html +239 -232
  20. package/bin/webui/css/00-tokens.css +110 -92
  21. package/bin/webui/css/04-motion.css +249 -152
  22. package/bin/webui/css/06-responsive.css +203 -178
  23. package/bin/webui/css/panels/behaviour.css +205 -0
  24. package/bin/webui/css/panels/knowledge.css +563 -116
  25. package/bin/webui/fixtures/behaviour.js +532 -0
  26. package/bin/webui/fixtures/extra.js +2036 -2036
  27. package/bin/webui/fixtures/hookui.js +5 -5
  28. package/bin/webui/fixtures/index.js +34 -0
  29. package/bin/webui/fixtures/knowledge.js +7 -1
  30. package/bin/webui/fixtures/settings.js +305 -305
  31. package/bin/webui/fixtures/stats.js +16 -0
  32. package/bin/webui/i18n/en/behaviour.json +143 -0
  33. package/bin/webui/i18n/en/knowledge.json +117 -2
  34. package/bin/webui/i18n/en/nav.json +25 -24
  35. package/bin/webui/i18n/en/tour.json +37 -35
  36. package/bin/webui/i18n/id/behaviour.json +143 -0
  37. package/bin/webui/i18n/id/knowledge.json +117 -2
  38. package/bin/webui/i18n/id/nav.json +25 -24
  39. package/bin/webui/i18n/id/tour.json +37 -35
  40. package/bin/webui/js/01-i18n.js +155 -154
  41. package/bin/webui/js/90-tour.js +498 -494
  42. package/bin/webui/js/91-shortcuts.js +126 -126
  43. package/bin/webui/js/99-boot.js +121 -118
  44. package/bin/webui/js/panels/behaviour.js +1022 -0
  45. package/bin/webui/js/panels/knowledge.js +552 -7
  46. package/bin/webui/js/panels/overview.js +13 -4
  47. package/mock-run/INDEX.md +109 -107
  48. package/mock-run/context-combiner.md +100 -100
  49. package/mock-run/gotcha-import.md +118 -0
  50. package/mock-run/habits.md +129 -0
  51. package/mock-run/orc-budget.md +534 -534
  52. package/mock-run/orc-challenge-council.md +262 -262
  53. package/mock-run/orc-quick.md +6 -1
  54. package/mock-run/orc-ultra.md +103 -103
  55. package/package.json +1 -1
  56. package/templates/agents/MODEL-MAPPING.md +49 -49
  57. package/templates/agents/orc-advisor-opus-5-xhigh.md +50 -56
  58. package/templates/agents/orc-analyze-mini-opus-5-med.md +55 -60
  59. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +55 -58
  60. package/templates/agents/orc-challenge-advisor-opus-5-med.md +75 -75
  61. package/templates/agents/orc-challenge-contrarian-opus-5-high.md +110 -110
  62. package/templates/agents/orc-challenge-executor-opus-5-med.md +114 -114
  63. package/templates/agents/orc-challenge-expansionist-opus-5-med.md +112 -112
  64. package/templates/agents/orc-challenge-judge-opus-5-high.md +132 -132
  65. package/templates/agents/orc-challenge-outsider-opus-5-low.md +109 -109
  66. package/templates/agents/orc-challenge-principles-opus-5-high.md +109 -109
  67. package/templates/agents/orc-challenge-reader-opus-5-low.md +90 -90
  68. package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
  69. package/templates/agents/orc-claude-writer-opus-5-med.md +48 -55
  70. package/templates/agents/orc-context-combiner-opus-5-high.md +78 -88
  71. package/templates/agents/orc-doc-checker-opus-5-low.md +108 -108
  72. package/templates/agents/orc-doc-writer-opus-5-med.md +134 -134
  73. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  74. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  75. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  76. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  77. package/templates/agents/orc-executor-opus-5-high.md +15 -7
  78. package/templates/agents/orc-executor-opus-5-low.md +15 -7
  79. package/templates/agents/orc-executor-opus-5-med.md +15 -7
  80. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  81. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  82. package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
  83. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  84. package/templates/agents/orc-judge-opus-5-xhigh.md +81 -85
  85. package/templates/agents/orc-learn-writer-opus-5-low.md +67 -73
  86. package/templates/agents/orc-pattern-codifier-opus-5-med.md +58 -65
  87. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  88. package/templates/agents/orc-planner-mini-opus-5-med.md +4 -7
  89. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -3
  90. package/templates/agents/orc-planner-opus-5-med.md +157 -160
  91. package/templates/agents/orc-recon-opus-5-low.md +3 -10
  92. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  93. package/templates/agents/orc-retro-opus-5-med.md +8 -10
  94. package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
  95. package/templates/agents/orc-reviewer-opus-5-med.md +96 -60
  96. package/templates/agents/orc-scout-opus-5-low.md +35 -40
  97. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  98. package/templates/agents/orc-system-analyst-opus-5-high.md +115 -120
  99. package/templates/agents/orc-test-author-opus-5-med.md +70 -71
  100. package/templates/agents/orc-test-designer-opus-5-high.md +158 -158
  101. package/templates/agents/orc-test-interpreter-opus-5-low.md +130 -130
  102. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  103. package/templates/agents/orc-verifier-opus-5-med.md +77 -69
  104. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
  105. package/templates/agents/orc-wiki-scanner-opus-5-med.md +74 -81
  106. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  107. package/templates/commands/orc-analyze.md +13 -21
  108. package/templates/commands/orc-fast.md +10 -15
  109. package/templates/commands/orc-poly.md +12 -21
  110. package/templates/commands/orc-pr-driver.md +11 -30
  111. package/templates/commands/orc-pr-setup.md +10 -31
  112. package/templates/commands/orc-route.md +11 -41
  113. package/templates/commands/orc-test.md +5 -60
  114. package/templates/commands/orc-ultra.md +17 -17
  115. package/templates/commands/orc-verify.md +11 -11
  116. package/templates/hooks/README.md +36 -2
  117. package/templates/hooks/orc-effort-guard.js +178 -178
  118. package/templates/hooks/orc-session-hook.js +264 -0
  119. package/templates/hooks/orc-statusline.js +9 -7
  120. package/templates/skills/_shared/README.md +9 -0
  121. package/templates/skills/_shared/code-graph.md +48 -56
  122. package/templates/skills/_shared/config-precedence.md +200 -198
  123. package/templates/skills/_shared/extra-dispatch.md +1331 -1346
  124. package/templates/skills/_shared/gotchas.md +228 -177
  125. package/templates/skills/_shared/habits.md +101 -0
  126. package/templates/skills/_shared/lane-contract.md +84 -0
  127. package/templates/skills/_shared/opus5-only.md +8 -8
  128. package/templates/skills/_shared/phases/README.md +142 -83
  129. package/templates/skills/_shared/phases/analyst-gates.md +125 -136
  130. package/templates/skills/_shared/phases/execution.md +8 -14
  131. package/templates/skills/_shared/phases/house-rules.md +27 -32
  132. package/templates/skills/_shared/phases/intake.md +127 -133
  133. package/templates/skills/_shared/phases/mock-example.md +46 -56
  134. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  135. package/templates/skills/_shared/phases/planning.md +7 -17
  136. package/templates/skills/_shared/phases/preflight.md +19 -42
  137. package/templates/skills/_shared/phases/review.md +23 -27
  138. package/templates/skills/_shared/phases/rules.md +18 -42
  139. package/templates/skills/_shared/phases/scoring.md +55 -65
  140. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  141. package/templates/skills/_shared/phases/security.md +45 -55
  142. package/templates/skills/_shared/phases/ship.md +6 -15
  143. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  144. package/templates/skills/_shared/phases/summary.md +73 -48
  145. package/templates/skills/_shared/phases/testgen.md +41 -51
  146. package/templates/skills/_shared/phases/trace-verbs.md +433 -0
  147. package/templates/skills/_shared/phases/trace.md +136 -367
  148. package/templates/skills/_shared/phases/verify.md +62 -70
  149. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  150. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  151. package/templates/skills/_shared/read-ladder.md +2 -55
  152. package/templates/skills/_shared/return-validation.md +17 -70
  153. package/templates/skills/_shared/review-slice.md +79 -0
  154. package/templates/skills/_shared/smoke-gate.md +46 -28
  155. package/templates/skills/context-combiner/SKILL.md +16 -45
  156. package/templates/skills/context-combiner/schemas/combined-report.md +78 -78
  157. package/templates/skills/orc/README.md +2 -2
  158. package/templates/skills/orc/SKILL.md +41 -69
  159. package/templates/skills/orc/config.md +9 -9
  160. package/templates/skills/orc/examples/full-run-mock.md +73 -73
  161. package/templates/skills/orc/references/effort-and-mode.md +222 -222
  162. package/templates/skills/orc/references/pattern-gate.md +89 -89
  163. package/templates/skills/orc/references/phases/intake.md +41 -47
  164. package/templates/skills/orc/references/phases/integration.md +13 -19
  165. package/templates/skills/orc/references/preflight-report.md +10 -11
  166. package/templates/skills/orc/references/ultra-mode.md +11 -9
  167. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  168. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  169. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  170. package/templates/skills/orc/subskills/orc-planner/SKILL.md +2 -2
  171. package/templates/skills/orc/subskills/orc-planner-mini/SKILL.md +121 -121
  172. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +35 -76
  173. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  174. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  175. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +34 -45
  176. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  177. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  178. package/templates/skills/orc-advisor/SKILL.md +56 -60
  179. package/templates/skills/orc-analyze/SKILL.md +32 -68
  180. package/templates/skills/orc-analyze/examples/analyze-mock.md +42 -42
  181. package/templates/skills/orc-analyze/schemas/report-audit.md +84 -83
  182. package/templates/skills/orc-analyze/schemas/report-prose.md +64 -63
  183. package/templates/skills/orc-analyze/schemas/report-requirement.md +78 -78
  184. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  185. package/templates/skills/orc-challenge/README.md +142 -142
  186. package/templates/skills/orc-challenge/examples/council-full-roster.md +273 -273
  187. package/templates/skills/orc-challenge/references/council.md +315 -315
  188. package/templates/skills/orc-challenge/references/intake.md +171 -171
  189. package/templates/skills/orc-claude/SKILL.md +14 -14
  190. package/templates/skills/orc-diy/README.md +174 -143
  191. package/templates/skills/orc-diy/SKILL.md +23 -74
  192. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  193. package/templates/skills/orc-diy/references/flow-schema.md +1 -1
  194. package/templates/skills/orc-doc/README.md +229 -229
  195. package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +325 -325
  196. package/templates/skills/orc-doc/references/chunking.md +527 -527
  197. package/templates/skills/orc-fast/SKILL.md +45 -73
  198. package/templates/skills/orc-judge/SKILL.md +77 -82
  199. package/templates/skills/orc-learn/SKILL.md +14 -14
  200. package/templates/skills/orc-learn/examples/learn-run-mock.md +61 -61
  201. package/templates/skills/orc-mini/SKILL.md +65 -113
  202. package/templates/skills/orc-pattern/SKILL.md +27 -48
  203. package/templates/skills/orc-poly/SKILL.md +32 -61
  204. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  205. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  206. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  207. package/templates/skills/orc-quick/README.md +43 -2
  208. package/templates/skills/orc-quick/SKILL.md +76 -107
  209. package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
  210. package/templates/skills/orc-quick/references/gh-mode.md +48 -1
  211. package/templates/skills/orc-quick/references/look.md +3 -1
  212. package/templates/skills/orc-retro/SKILL.md +19 -18
  213. package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
  214. package/templates/skills/orc-route/SKILL.md +25 -45
  215. package/templates/skills/orc-test/SKILL.md +16 -37
  216. package/templates/skills/orc-verify/SKILL.md +21 -39
  217. package/templates/skills/orc-verify/examples/verify-mock.md +33 -33
  218. package/templates/skills/orc-wait/SKILL.md +156 -163
  219. package/templates/skills/orc-wiki/references/extra.md +1 -1
  220. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
  221. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  222. package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
  223. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  224. package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
@@ -0,0 +1,1081 @@
1
+ "use strict";
2
+ // ── The gotcha importers + the read-only `gh` reads (v2.0.0 W6a) ────────────
3
+ //
4
+ // orc gotcha import sarif <file> SARIF 2.1.0 → observations
5
+ // orc gotcha import sonar [...] Sonar REST (or --file <saved answer>) → observations
6
+ // orc gotcha import pr <n> PR review threads (humans only) → observations
7
+ // orc gotcha import issues [--label bug] closed defects with a closing PR → observations
8
+ // orc gotcha sync [--force] every available source, incremental, time-boxed
9
+ // orc pr threads <n> [--all] the unresolved review threads, for real (Q1)
10
+ // orc ci failed [--pr <n>|--run <id>] the failing CI steps, flaky re-attempts named (Q3)
11
+ // orc ci flaky --first <log> [--rerun <log>] a LOCAL red run: flaky or real (Q5, W6b)
12
+ //
13
+ // Every importer is a PURE parser (exported, tested against test/fixtures/)
14
+ // plus a thin network half. The parsers never decide an outcome by judgement:
15
+ // `05-gotchas-v2-spec.md` §3.1 is a table, and it is coded as one.
16
+ //
17
+ // The importers FEED `observe` (bin/gotcha.js `recordObservations`) — one
18
+ // writer of observations.jsonl, one promotion pass. They never write a
19
+ // gotchas.md entry themselves: promotion (§4) does, or a person does.
20
+ //
21
+ // Network: the user's own `gh` (a READ, always — never a reply, a resolve or a
22
+ // review) and Node's `fetch` for Sonar. No new dependency. The Sonar token
23
+ // comes from SONAR_TOKEN in the ENVIRONMENT only: never a config key, never
24
+ // printed, never in a URL (it rides in the Authorization header).
25
+ //
26
+ // State: `.claude/orc/gotchas-sync.json` (last_sync, per-source cursors, the
27
+ // last SARIF fingerprints). User data — never in the install manifest, so
28
+ // `orc update`, `--prune` and `doctor --fix` never touch it.
29
+ //
30
+ // Test seams: ORC_GH (the `gh` binary; a .js path runs through node) and
31
+ // ORC_TEST_SYNC_BOX_MS (the per-source time box, 10 s by default).
32
+
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const { execFileSync } = require("child_process");
36
+ const G = require("./gotcha.js");
37
+
38
+ const SYNC_BOX_MS = Number(process.env.ORC_TEST_SYNC_BOX_MS) || 10000;
39
+ const SONAR_PAGE = 500;
40
+ const SONAR_CAP = 10000; // the API's hard result cap — sliced by createdAfter
41
+ const FIRST_MAX = 300; // the first comment, marked as untrusted data
42
+ const CI_TAIL = 60; // last lines per job
43
+ const CI_ERR_MAX = 8; // error lines kept per failure
44
+ const SARIF_FP_MAX = 5000;
45
+ const VALUE_FLAGS = new Set(["--url", "--project", "--pr", "--branch", "--org", "--file", "--label", "--run", "--dir", "--limit", "--first", "--rerun", "--rerun-exit"]);
46
+
47
+ // ── Small helpers ───────────────────────────────────────────────────────────
48
+ const oneLine = (v) => String(v == null ? "" : v).replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
49
+ const clip = (s, n) => {
50
+ const t = oneLine(s);
51
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
52
+ };
53
+ const safe = (s, n) => clip(G.redact(s), n || FIRST_MAX);
54
+ const posix = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "");
55
+ const isBotLogin = (login, typename) => typename === "Bot" || /\[bot\]$/i.test(String(login || ""));
56
+ const isTestPath = (p) => /(^|\/)(tests?|__tests__|spec)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
57
+
58
+ // Concatenated JSON values (`gh --paginate` prints one document per page) or a
59
+ // single value → an array of values. Throws on anything that is not JSON.
60
+ function parseJsonStream(text) {
61
+ const t = String(text || "").replace(/^/, "").trim();
62
+ if (!t) return [];
63
+ const out = [];
64
+ let depth = 0;
65
+ let inStr = false;
66
+ let esc = false;
67
+ let start = -1;
68
+ for (let i = 0; i < t.length; i++) {
69
+ const c = t[i];
70
+ if (inStr) {
71
+ if (esc) esc = false;
72
+ else if (c === "\\") esc = true;
73
+ else if (c === '"') inStr = false;
74
+ continue;
75
+ }
76
+ if (c === '"') inStr = true;
77
+ else if (c === "{" || c === "[") {
78
+ if (depth === 0) start = i;
79
+ depth++;
80
+ } else if (c === "}" || c === "]") {
81
+ depth--;
82
+ if (depth === 0) out.push(JSON.parse(t.slice(start, i + 1)));
83
+ }
84
+ }
85
+ if (depth !== 0 || !out.length) return [JSON.parse(t)];
86
+ return out;
87
+ }
88
+
89
+ // Pages that are arrays → one flat array; pages that are objects stay pages.
90
+ const flatPages = (pages) => pages.reduce((acc, p) => acc.concat(Array.isArray(p) ? p : [p]), []);
91
+
92
+ // ── SARIF 2.1.0 (`05` §7 `import sarif`, §3.1) ──────────────────────────────
93
+ // `ruleId` | `ruleIndex` | `rule{id,index}` → the rule; `level` → the rule's
94
+ // default → `warning`; the first location; `partialFingerprints`;
95
+ // `baselineState`; `suppressions`. `absent` (or a fingerprint the previous
96
+ // import had and this one does not) → addressed · a non-empty `suppressions[]`
97
+ // → disputed · `new` / `unchanged` / none → open.
98
+ const LEVEL_SEV = { error: "P1", warning: "P2", note: "P3", none: "P3" };
99
+ function toolPrefix(name) {
100
+ const n = String(name || "").toLowerCase();
101
+ if (/eslint/.test(n)) return "eslint";
102
+ if (/semgrep/.test(n)) return "semgrep";
103
+ if (/ruff/.test(n)) return "ruff";
104
+ if (/codeql/.test(n)) return "codeql";
105
+ return n.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "sarif";
106
+ }
107
+ function sarifUri(loc, run) {
108
+ const al = loc && loc.physicalLocation && loc.physicalLocation.artifactLocation;
109
+ if (!al) return null;
110
+ let uri = al.uri;
111
+ if (!uri && Number.isInteger(al.index) && run.artifacts && run.artifacts[al.index]) {
112
+ const a = run.artifacts[al.index].location;
113
+ uri = a && a.uri;
114
+ }
115
+ if (!uri) return null;
116
+ let p = decodeURIComponent(String(uri)).replace(/^file:\/\/\/?/, "");
117
+ // A uriBaseId makes the uri RELATIVE to a root the tool named — keep it relative.
118
+ if (/^[A-Za-z]:\//.test(p) || p.startsWith("/")) {
119
+ const base = al.uriBaseId && run.originalUriBaseIds && run.originalUriBaseIds[al.uriBaseId];
120
+ const root = base && base.uri ? decodeURIComponent(base.uri).replace(/^file:\/\/\/?/, "") : null;
121
+ if (root && p.startsWith(root)) p = p.slice(root.length);
122
+ }
123
+ return posix(p);
124
+ }
125
+ function parseSarif(doc, previous) {
126
+ if (!doc || typeof doc !== "object" || !Array.isArray(doc.runs) || !/^2\.1\./.test(String(doc.version || "")))
127
+ return { err: "not SARIF 2.1.0 (it needs `version: 2.1.0` and a `runs[]` array)" };
128
+ const items = [];
129
+ const seen = {};
130
+ for (const run of doc.runs) {
131
+ const driver = (run.tool && run.tool.driver) || {};
132
+ const tool = toolPrefix(driver.name);
133
+ const rules = Array.isArray(driver.rules) ? driver.rules : [];
134
+ seen[tool] = seen[tool] || {};
135
+ for (const r of Array.isArray(run.results) ? run.results : []) {
136
+ const idx = Number.isInteger(r.ruleIndex) ? r.ruleIndex : r.rule && Number.isInteger(r.rule.index) ? r.rule.index : null;
137
+ const desc = idx !== null && rules[idx] ? rules[idx] : rules.find((x) => x.id === (r.ruleId || (r.rule && r.rule.id))) || null;
138
+ const ruleId = r.ruleId || (r.rule && r.rule.id) || (desc && desc.id) || null;
139
+ const level = r.level || (desc && desc.defaultConfiguration && desc.defaultConfiguration.level) || "warning";
140
+ let text = r.message && r.message.text;
141
+ if (!text && r.message && r.message.id && desc && desc.messageStrings && desc.messageStrings[r.message.id])
142
+ text = desc.messageStrings[r.message.id].text;
143
+ if (!text && desc) text = (desc.shortDescription && desc.shortDescription.text) || (desc.fullDescription && desc.fullDescription.text);
144
+ const loc = Array.isArray(r.locations) ? r.locations[0] : null;
145
+ const file = sarifUri(loc, run);
146
+ const region = (loc && loc.physicalLocation && loc.physicalLocation.region) || {};
147
+ const lines = Number.isInteger(region.startLine) ? [region.startLine].concat(Number.isInteger(region.endLine) && region.endLine !== region.startLine ? [region.endLine] : []) : null;
148
+ const pf = r.partialFingerprints && Object.values(r.partialFingerprints)[0];
149
+ const fp = String(pf || r.fingerprints && Object.values(r.fingerprints)[0] || G.sha1(`${ruleId}|${file}|${region.startLine}|${text}`)).slice(0, 40);
150
+ const tags = [].concat((desc && desc.properties && desc.properties.tags) || [], (r.properties && r.properties.tags) || []).map(String);
151
+ const cweTag = tags.map((t) => /CWE-(\d+)/i.exec(t)).find(Boolean);
152
+ const security = !!cweTag || tags.some((t) => /^security$/i.test(t));
153
+ const suppressed = Array.isArray(r.suppressions) && r.suppressions.length > 0;
154
+ const outcome = suppressed ? "disputed" : r.baselineState === "absent" ? "addressed" : "open";
155
+ seen[tool][fp] = { rule: ruleId, path: file, line: lines ? lines[0] : null, sig: text || null };
156
+ items.push({
157
+ body: {
158
+ source: "sarif",
159
+ ref: `sarif ${tool} · ${fp.slice(0, 16)}`,
160
+ rule: ruleId ? `${tool}:${ruleId}` : null,
161
+ sig: text || null,
162
+ path: file,
163
+ lines,
164
+ severity: LEVEL_SEV[level] || "P2",
165
+ category: security ? "security" : null,
166
+ cwe: cweTag ? `CWE-${cweTag[1]}` : null,
167
+ outcome,
168
+ author: "bot",
169
+ },
170
+ view: { rule: ruleId ? `${tool}:${ruleId}` : `${tool}:(no rule)`, path: file, line: lines ? lines[0] : null, message: safe(text, 200), level, outcome, suppressed, baseline: r.baselineState || null },
171
+ });
172
+ }
173
+ }
174
+ // "compared with the previous import": a fingerprint the last import of the
175
+ // SAME tool carried and this one does not → the finding is gone → addressed.
176
+ for (const [tool, prevFps] of Object.entries(previous || {})) {
177
+ if (!seen[tool]) continue; // this file says nothing about that tool
178
+ for (const [fp, m] of Object.entries(prevFps.fps || {})) {
179
+ if (seen[tool][fp]) continue;
180
+ items.push({
181
+ body: { source: "sarif", ref: `sarif ${tool} · ${fp.slice(0, 16)}`, rule: m.rule ? `${tool}:${m.rule}` : null, sig: m.sig, path: m.path, lines: Number.isInteger(m.line) ? [m.line] : null, outcome: "addressed", author: "bot" },
182
+ view: { rule: m.rule ? `${tool}:${m.rule}` : `${tool}:(no rule)`, path: m.path, line: m.line, message: safe(m.sig, 200), level: null, outcome: "addressed", suppressed: false, baseline: "absent (vs the previous import)" },
183
+ });
184
+ }
185
+ }
186
+ const state = {};
187
+ for (const [tool, fps] of Object.entries(seen)) {
188
+ const keys = Object.keys(fps).slice(0, SARIF_FP_MAX);
189
+ state[tool] = { fps: Object.fromEntries(keys.map((k) => [k, fps[k]])) };
190
+ }
191
+ return { items, state };
192
+ }
193
+
194
+ // ── Sonar (`05` §7 `import sonar`, §3.1) ────────────────────────────────────
195
+ // issueStatus: FIXED → addressed · FALSE_POSITIVE → disputed · ACCEPTED →
196
+ // wontfix · OPEN / CONFIRMED → open. A pre-10.4 answer has only
197
+ // status + resolution; it maps the same way.
198
+ const SONAR_SEV = { BLOCKER: "P1", HIGH: "P1", CRITICAL: "P1", MEDIUM: "P2", MAJOR: "P2", LOW: "P3", MINOR: "P3", INFO: "P3" };
199
+ const IMPACT_RANK = { INFO: 0, LOW: 1, MEDIUM: 2, HIGH: 3, BLOCKER: 4 };
200
+ function sonarOutcome(i) {
201
+ const s = String(i.issueStatus || "").toUpperCase();
202
+ if (s === "FIXED") return "addressed";
203
+ if (s === "FALSE_POSITIVE") return "disputed";
204
+ if (s === "ACCEPTED") return "wontfix";
205
+ if (s === "OPEN" || s === "CONFIRMED") return "open";
206
+ const res = String(i.resolution || "").toUpperCase();
207
+ if (res === "FIXED" || res === "REMOVED") return "addressed";
208
+ if (res === "FALSE-POSITIVE") return "disputed";
209
+ if (res === "WONTFIX") return "wontfix";
210
+ return "open";
211
+ }
212
+ function parseSonar(pages, opts) {
213
+ const list = flatPages(Array.isArray(pages) ? pages : [pages]);
214
+ if (!list.length || !list.every((p) => p && typeof p === "object" && Array.isArray(p.issues)))
215
+ return { err: "not a Sonar api/issues/search answer (it needs an `issues[]` array)" };
216
+ const comps = {};
217
+ const ruleNames = {};
218
+ for (const p of list) {
219
+ for (const c of p.components || []) comps[c.key] = c.path || null;
220
+ for (const r of p.rules || []) ruleNames[r.key] = r.name || null;
221
+ }
222
+ const items = [];
223
+ const seenKeys = new Set();
224
+ for (const p of list)
225
+ for (const i of p.issues) {
226
+ if (!i || !i.key || seenKeys.has(i.key)) continue;
227
+ seenKeys.add(i.key);
228
+ const file = comps[i.component] || (String(i.component || "").includes(":") ? String(i.component).split(":").slice(1).join(":") : null);
229
+ const tr = i.textRange || {};
230
+ const start = Number.isInteger(tr.startLine) ? tr.startLine : Number.isInteger(i.line) ? i.line : null;
231
+ const lines = start === null ? null : [start].concat(Number.isInteger(tr.endLine) && tr.endLine !== start ? [tr.endLine] : []);
232
+ const impacts = Array.isArray(i.impacts) ? i.impacts : [];
233
+ const top = impacts.reduce((m, x) => (IMPACT_RANK[String(x.severity).toUpperCase()] > IMPACT_RANK[m] || m === null ? String(x.severity).toUpperCase() : m), null);
234
+ const security = impacts.some((x) => String(x.softwareQuality).toUpperCase() === "SECURITY") || i.type === "VULNERABILITY";
235
+ const cweTag = (i.tags || []).map((t) => /^cwe-?(\d+)$/i.exec(String(t))).find(Boolean);
236
+ const outcome = sonarOutcome(i);
237
+ const rule = `sonar:${i.rule}`;
238
+ items.push({
239
+ body: {
240
+ source: "sonar",
241
+ ref: `sonar ${i.key}`,
242
+ rule,
243
+ sig: i.message || null,
244
+ path: file ? posix(file) : null,
245
+ lines,
246
+ severity: SONAR_SEV[top] || SONAR_SEV[String(i.severity || "").toUpperCase()] || "P2",
247
+ category: security ? "security" : null,
248
+ cwe: cweTag ? `CWE-${cweTag[1]}` : null,
249
+ impact: top || null,
250
+ pr: opts && opts.pr ? String(opts.pr) : null,
251
+ at: i.updateDate || i.creationDate || null,
252
+ outcome,
253
+ author: "bot",
254
+ },
255
+ view: { rule, name: ruleNames[i.rule] || null, key: i.key, path: file ? posix(file) : null, line: start, message: safe(i.message, 200), outcome, impact: top || i.severity || null },
256
+ updated: i.updateDate || i.creationDate || null,
257
+ });
258
+ }
259
+ return { items };
260
+ }
261
+
262
+ // ── GitHub PR review threads (`05` §3.1, `08` Q1) ───────────────────────────
263
+ // addressed = isResolved && isOutdated (the lines changed) · resolved but not
264
+ // outdated → open (a click is not a change) · unresolved at merge → wontfix.
265
+ const THREADS_QUERY =
266
+ "query($owner:String!,$repo:String!,$n:Int!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$n){" +
267
+ "number title state url headRefName reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{" +
268
+ "id isResolved isOutdated path line originalLine subjectType comments(first:50){nodes{author{__typename login} authorAssociation body url createdAt}}}}}}}";
269
+ function parseThreads(pages) {
270
+ const list = flatPages(pages);
271
+ let pr = null;
272
+ const threads = [];
273
+ for (const p of list) {
274
+ const node = p && p.data && p.data.repository && p.data.repository.pullRequest;
275
+ if (!node) {
276
+ if (p && p.errors) return { err: `GitHub answered with an error: ${clip(p.errors.map((e) => e.message).join("; "), 200)}` };
277
+ continue;
278
+ }
279
+ pr = pr || { number: node.number, title: node.title || null, state: node.state || null, url: node.url || null, head: node.headRefName || null };
280
+ for (const t of (node.reviewThreads && node.reviewThreads.nodes) || []) threads.push(t);
281
+ }
282
+ if (!pr) return { err: "not a reviewThreads answer (no data.repository.pullRequest)" };
283
+ const rows = threads.map((t, i) => {
284
+ const cs = (t.comments && t.comments.nodes) || [];
285
+ const first = cs[0] || {};
286
+ const login = first.author ? first.author.login : null;
287
+ const kind = isBotLogin(login, first.author && first.author.__typename) ? "bot" : "human";
288
+ const state = t.isResolved && t.isOutdated ? "resolved+outdated" : t.isResolved ? "resolved" : t.isOutdated ? "outdated" : "open";
289
+ return {
290
+ n: i + 1,
291
+ id: t.id || null,
292
+ path: t.path ? posix(t.path) : null,
293
+ line: Number.isInteger(t.line) ? t.line : null,
294
+ original_line: Number.isInteger(t.originalLine) ? t.originalLine : null,
295
+ subject_type: t.subjectType || null,
296
+ resolved: !!t.isResolved,
297
+ outdated: !!t.isOutdated,
298
+ state,
299
+ author: login,
300
+ author_kind: kind,
301
+ association: first.authorAssociation || null,
302
+ comments: cs.length,
303
+ first: { text: safe(first.body, FIRST_MAX), untrusted: true },
304
+ url: first.url || null,
305
+ created_at: first.createdAt || null,
306
+ };
307
+ });
308
+ return { pr, rows };
309
+ }
310
+ function threadOutcome(row, prState) {
311
+ if (row.resolved && row.outdated) return "addressed";
312
+ if (!row.resolved && String(prState).toUpperCase() === "MERGED") return "wontfix";
313
+ return "open";
314
+ }
315
+ function threadObservations(pr, rows) {
316
+ return rows
317
+ .filter((r) => r.author_kind === "human" && (r.first.text || r.path))
318
+ .map((r) => ({
319
+ source: "pr",
320
+ ref: `PR ${pr.number} · thread ${String(r.id || r.n).slice(-24)}`,
321
+ pr: String(pr.number),
322
+ sig: r.first.text || null,
323
+ path: r.path,
324
+ lines: Number.isInteger(r.line || r.original_line) ? [r.line || r.original_line] : null,
325
+ at: r.created_at,
326
+ outcome: threadOutcome(r, pr.state),
327
+ author: "human",
328
+ }));
329
+ }
330
+
331
+ // ── Issues (defects) (`05` §3.1) ────────────────────────────────────────────
332
+ // stateReason COMPLETED with a closing PR → addressed (the PR's files are the
333
+ // scope) · NOT_PLANNED → disputed · anything else says nothing.
334
+ function issueObservations(issues, filesOf) {
335
+ const out = [];
336
+ for (const i of issues || []) {
337
+ const reason = String(i.stateReason || "").toUpperCase();
338
+ const prs = (i.closedByPullRequestsReferences || []).map((p) => p && p.number).filter(Number.isInteger);
339
+ if (reason === "COMPLETED" && prs.length) {
340
+ const files = (filesOf(prs[0]) || []).map(posix).filter(Boolean);
341
+ const main = files.find((f) => !isTestPath(f)) || files[0] || null;
342
+ out.push({ source: "defect", ref: `issue #${i.number}`, pr: String(prs[0]), sig: i.title || null, path: main, at: i.closedAt || null, outcome: "addressed", author: "human" });
343
+ } else if (reason === "NOT_PLANNED") {
344
+ out.push({ source: "defect", ref: `issue #${i.number}`, sig: i.title || null, at: i.closedAt || null, outcome: "disputed", author: "human" });
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // ── CI logs (`08` Q3) ───────────────────────────────────────────────────────
351
+ // `gh run view <id> --log-failed` prints `<job>\t<step>\t<timestamp> <text>`.
352
+ // Per failed step: the step name, the first error lines of the last 60 lines
353
+ // of its job, a SIGNATURE (timestamps, SHAs, temp paths and numbers removed,
354
+ // then hashed), and the tests it names. A test that failed and then PASSED on
355
+ // a re-attempt inside the same log is FLAKY — never a failure.
356
+ const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
357
+ const TS = /^?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s?/;
358
+ const ERR_RE = /(##\[error\]|\berror\b|\bfailed\b|\bfailure\b|\bFAIL\b|✘|✗|×|AssertionError|Exception|Traceback|exit code [1-9]|exited with code [1-9])/i;
359
+ const NOT_ERR_RE = /\b0 (?:failed|failures|errors?)\b|--fail-fast|continue-on-error/i;
360
+ const LOCAL_CMD_RE =
361
+ /\b(npm (?:run )?test(?::[\w-]+)?|npm run [\w:-]+|yarn (?:run )?[\w:-]+|pnpm (?:run )?[\w:-]+|npx [\w@/.-]+(?: test)?|python -m pytest|pytest(?: [\w./:-]+)?|go test(?: [\w./-]+)?|cargo test|mvnw? [\w:-]+|\.?\/?gradlew? [\w:-]+|make [\w-]+|dotnet test|bundle exec rspec|rspec|(?:vendor\/bin\/)?phpunit)\b/;
362
+ const RETRY_RE = /\s*\((?:retry|attempt) #?\d+\)/gi;
363
+ const DUR_RE = /\s*[([]\d+(?:\.\d+)?\s*(?:ms|s|m)[)\]]\s*$/i;
364
+ function testName(s) {
365
+ return String(s).replace(RETRY_RE, "").replace(DUR_RE, "").replace(/\s+/g, " ").trim();
366
+ }
367
+ // One line → { status: "fail"|"pass", name } | null.
368
+ function testLine(l) {
369
+ let m = /^\s*(?:\d+\)\s*)?(✘|✗|×|FAIL(?:ED)?|RERUN)\s+(.+)$/.exec(l);
370
+ if (m) return { status: "fail", name: testName(m[2]) };
371
+ m = /^\s*(?:\d+\)\s*)?(✓|✔|PASS(?:ED)?|ok)\s+(.+)$/.exec(l);
372
+ if (m) return { status: "pass", name: testName(m[2]) };
373
+ m = /^\s*(\S+::\S+)\s+(PASSED|FAILED|RERUN|ERROR)\b/.exec(l);
374
+ if (m) return { status: m[2] === "PASSED" ? "pass" : "fail", name: testName(m[1]) };
375
+ return null;
376
+ }
377
+ function ciSignature(parts) {
378
+ const norm = parts
379
+ .join("\n")
380
+ .replace(TS, "")
381
+ .replace(/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?\b/g, " ")
382
+ .replace(/\b[0-9a-f]{7,40}\b/gi, " ")
383
+ .replace(/(?:[A-Za-z]:)?[\\/](?:tmp|temp|var[\\/]folders|Users[\\/][^\\/\s]+[\\/]AppData[\\/]Local[\\/]Temp|home[\\/]runner[\\/]work[\\/]_temp)[\\/][^\s:'")]*/gi, " <tmp> ")
384
+ .replace(/\d+(?:\.\d+)?/g, " ")
385
+ .replace(/\s+/g, " ")
386
+ .trim()
387
+ .toLowerCase();
388
+ return G.sha1(norm).slice(0, 12);
389
+ }
390
+ // `/home/runner/work/<repo>/<repo>/src/x.js`, `D:/a/<repo>/<repo>/src/x.js` or a
391
+ // `_temp` copy → `src/x.js`. A path still absolute says nothing about THIS repo.
392
+ function repoRel(p) {
393
+ const s = posix(p)
394
+ .replace(/^.*?\/home\/runner\/work\/_temp\/[^/]+\//, "")
395
+ .replace(/^.*?\/home\/runner\/work\/[^/]+\/[^/]+\//, "")
396
+ .replace(/^[A-Za-z]:\/a\/[^/]+\/[^/]+\//, "");
397
+ return /^(\/|[A-Za-z]:\/)/.test(s) ? null : s;
398
+ }
399
+ function parseCiLog(text) {
400
+ const jobs = new Map();
401
+ for (const raw of String(text || "").replace(/\r\n/g, "\n").split("\n")) {
402
+ if (!raw.trim()) continue;
403
+ const cols = raw.split("\t");
404
+ const job = cols.length >= 3 ? cols[0] : "(job)";
405
+ const step = cols.length >= 3 ? cols[1] : "(step)";
406
+ const body = (cols.length >= 3 ? cols.slice(2).join("\t") : raw).replace(TS, "").replace(ANSI, "");
407
+ if (!jobs.has(job)) jobs.set(job, { job, steps: new Map(), lines: [] });
408
+ const j = jobs.get(job);
409
+ j.lines.push({ step, body });
410
+ if (!j.steps.has(step)) j.steps.set(step, []);
411
+ j.steps.get(step).push(body);
412
+ }
413
+ const failures = [];
414
+ for (const j of jobs.values()) {
415
+ // Test statuses over the WHOLE job log, in order: the last word per test wins.
416
+ const status = new Map();
417
+ const failedOnce = new Set();
418
+ let summaryFlaky = false;
419
+ for (const { body } of j.lines) {
420
+ const t = testLine(body);
421
+ if (t) {
422
+ if (t.status === "fail") failedOnce.add(t.name);
423
+ status.set(t.name, t.status);
424
+ }
425
+ if (/\b\d+ flaky\b/i.test(body)) summaryFlaky = true;
426
+ }
427
+ const flakyTests = [...failedOnce].filter((n) => status.get(n) === "pass");
428
+ const failedTests = [...failedOnce].filter((n) => status.get(n) === "fail");
429
+ const tail = j.lines.slice(-CI_TAIL);
430
+ for (const [step, bodies] of j.steps) {
431
+ const inTail = tail.filter((x) => x.step === step).map((x) => x.body);
432
+ const errLines = inTail
433
+ .filter((b) => ERR_RE.test(b) && !NOT_ERR_RE.test(b))
434
+ .filter((b) => !flakyTests.some((n) => b.includes(n)))
435
+ .slice(0, CI_ERR_MAX)
436
+ .map((b) => safe(b, 240));
437
+ const stepFailed = failedTests.filter((n) => bodies.some((b) => b.includes(n)));
438
+ const stepFlaky = flakyTests.filter((n) => bodies.some((b) => b.includes(n)));
439
+ if (!errLines.length && !stepFailed.length && !stepFlaky.length) continue;
440
+ const cmd = LOCAL_CMD_RE.exec(step) || LOCAL_CMD_RE.exec(bodies.join("\n"));
441
+ const flaky = !stepFailed.length && stepFlaky.length > 0;
442
+ const signature = ciSignature([step].concat(stepFailed.length ? stepFailed : errLines));
443
+ // The first source anchor the step printed, the runner's checkout prefix removed.
444
+ const fileRef = bodies
445
+ .map((b) => /([\w./\\:-]+\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rb|rs|java|kt|cs|php|vue|svelte)):(\d+)/.exec(b))
446
+ .find((m) => m && repoRel(m[1]));
447
+ failures.push({
448
+ job: j.job,
449
+ step,
450
+ lines: errLines,
451
+ tests_failed: stepFailed,
452
+ tests_flaky: stepFlaky,
453
+ flaky,
454
+ flaky_reason: flaky ? `${stepFlaky.length} test(s) failed and then passed on a re-attempt in the same log` : null,
455
+ summary_flaky: summaryFlaky,
456
+ signature,
457
+ rule: `ci:${signature}`,
458
+ path: fileRef ? repoRel(fileRef[1]) : null,
459
+ line: fileRef ? Number(fileRef[2]) : null,
460
+ repro: cmd ? cmd[1] : "none",
461
+ repro_reason: cmd ? null : "the failing step names no local command",
462
+ });
463
+ }
464
+ }
465
+ return failures;
466
+ }
467
+
468
+ // ── The flaky classifier (v2.0.0 W6b, 08 Q5) ────────────────────────────────
469
+ // ONE definition of flaky, shared with `parseCiLog`: a test that failed and
470
+ // then passed. Here the "then" is an identical re-run of ONLY the failing
471
+ // tests. `testLine` reads both logs, so a runner the CI parser understands is
472
+ // a runner this understands, and the last word per test wins in each log.
473
+ function testStatuses(text) {
474
+ const status = new Map();
475
+ for (const raw of String(text || "").replace(/\r\n/g, "\n").split("\n")) {
476
+ const t = testLine(raw.replace(TS, "").replace(ANSI, ""));
477
+ if (t) status.set(t.name, t.status);
478
+ }
479
+ return status;
480
+ }
481
+ // → { state: flaky|red|unknown, tests_failed, flaky, still_failing, missing, reason }
482
+ // `rerunText` null → the first log only: it names what to re-run. A re-run that
483
+ // exited ≠ 0 (`rerunExit`) is red, whatever its test lines say: a build error
484
+ // names no test.
485
+ function classifyRerun(firstText, rerunText, rerunExit) {
486
+ const first = testStatuses(firstText);
487
+ const failed = [...first].filter(([, s]) => s === "fail").map(([n]) => n);
488
+ const base = { tests_failed: failed, flaky: [], still_failing: [], missing: [] };
489
+ if (!failed.length)
490
+ return Object.assign(base, { state: "unknown", reason: "the first log names no failing test — treat the run as red" });
491
+ if (rerunText == null)
492
+ return Object.assign(base, { state: "red", still_failing: failed, reason: `re-run ONLY these ${failed.length} test(s) once, then call again with --rerun` });
493
+ const again = testStatuses(rerunText);
494
+ for (const n of failed) {
495
+ const s = again.get(n);
496
+ (s === "pass" ? base.flaky : s === "fail" ? base.still_failing : base.missing).push(n);
497
+ }
498
+ if (rerunExit != null && Number(rerunExit) !== 0)
499
+ return Object.assign(base, { state: "red", reason: `the re-run exited ${rerunExit}` });
500
+ if (base.still_failing.length) return Object.assign(base, { state: "red", reason: `${base.still_failing.length} test(s) failed again on the re-run` });
501
+ if (base.missing.length) return Object.assign(base, { state: "red", reason: `${base.missing.length} test(s) did not run in the re-run — it was not identical` });
502
+ return Object.assign(base, { state: "flaky", reason: `${base.flaky.length} test(s) failed, then passed on an identical re-run` });
503
+ }
504
+
505
+ // ── The network halves ──────────────────────────────────────────────────────
506
+ // `gh` → { ok, out } | { ok:false, code: 5|6, reason, message }. `deadline`
507
+ // (ms epoch) is the time box: a call past it is killed and says so.
508
+ function ghRun(ghArgs, cwd, deadline) {
509
+ const bin = process.env.ORC_GH || "gh";
510
+ const [file, argv] = /\.js$/i.test(bin) ? [process.execPath, [bin, ...ghArgs]] : [bin, ghArgs];
511
+ const timeout = deadline ? Math.max(1, deadline - Date.now()) : 60000;
512
+ try {
513
+ const out = execFileSync(file, argv, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
514
+ return { ok: true, out };
515
+ } catch (e) {
516
+ if (e.code === "ENOENT") return { ok: false, code: 5, reason: "gh-missing", message: "gh is not installed (https://cli.github.com)" };
517
+ if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") return { ok: false, code: 6, reason: "timed-out", message: "gh timed out", timedOut: true };
518
+ const err = String(e.stderr || e.message || "");
519
+ if (/gh auth login|not logged in|authentication|HTTP 401|bad credentials/i.test(err))
520
+ return { ok: false, code: 5, reason: "gh-not-authed", message: "gh is not logged in — run `gh auth login`" };
521
+ return { ok: false, code: 6, reason: "gh-failed", message: `gh failed: ${safe(err.split("\n").find((l) => l.trim()) || "no message", 200)}`, stderr: err };
522
+ }
523
+ }
524
+ function ghJson(ghArgs, cwd, deadline) {
525
+ const r = ghRun(ghArgs, cwd, deadline);
526
+ if (!r.ok) return r;
527
+ try {
528
+ return { ok: true, pages: parseJsonStream(r.out) };
529
+ } catch (e) {
530
+ return { ok: false, code: 6, reason: "gh-bad-json", message: `gh printed something that is not JSON (${e.message.split("\n")[0]})` };
531
+ }
532
+ }
533
+ function fetchThreads(n, cwd, deadline) {
534
+ const r = ghJson(["api", "graphql", "--paginate", "-F", "owner={owner}", "-F", "repo={repo}", "-F", `n=${n}`, "-f", `query=${THREADS_QUERY}`], cwd, deadline);
535
+ if (!r.ok) return r;
536
+ const p = parseThreads(r.pages);
537
+ if (p.err) return { ok: false, code: 6, reason: "gh-bad-answer", message: p.err };
538
+ return Object.assign({ ok: true }, p);
539
+ }
540
+ function fetchReviews(n, cwd, deadline) {
541
+ const r = ghJson(["api", `repos/{owner}/{repo}/pulls/${n}/reviews`, "--paginate"], cwd, deadline);
542
+ if (!r.ok) return r;
543
+ const reviews = flatPages(r.pages)
544
+ .filter((x) => x && String(x.state).toUpperCase() === "CHANGES_REQUESTED" && String(x.body || "").trim())
545
+ .map((x) => {
546
+ const login = x.user && x.user.login;
547
+ return { author: login || null, author_kind: isBotLogin(login, x.user && x.user.type) ? "bot" : "human", state: "CHANGES_REQUESTED", body: { text: safe(x.body, FIRST_MAX), untrusted: true }, url: x.html_url || null };
548
+ });
549
+ return { ok: true, reviews };
550
+ }
551
+ function currentBranch(cwd) {
552
+ try {
553
+ return execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
554
+ } catch (_) {
555
+ return null;
556
+ }
557
+ }
558
+ // Sonar: `GET api/issues/search`, paged, the 10,000 cap sliced by createdAfter.
559
+ async function fetchSonar(o, deadline) {
560
+ const token = process.env.SONAR_TOKEN;
561
+ const pages = [];
562
+ let createdAfter = o.createdAfter || null;
563
+ const seen = new Set();
564
+ for (let slice = 0; slice < 20; slice++) {
565
+ let got = 0;
566
+ let lastCreated = null;
567
+ let total = 0;
568
+ for (let p = 1; p * SONAR_PAGE <= SONAR_CAP; p++) {
569
+ const q = new URLSearchParams({ components: o.project, issueStatuses: "OPEN,CONFIRMED,FALSE_POSITIVE,ACCEPTED,FIXED", additionalFields: "rules", ps: String(SONAR_PAGE), p: String(p), s: "CREATION_DATE", asc: "true" });
570
+ if (o.pr) q.set("pullRequest", String(o.pr));
571
+ else if (o.branch) q.set("branch", String(o.branch));
572
+ if (o.org) q.set("organization", String(o.org));
573
+ if (createdAfter) q.set("createdAfter", createdAfter);
574
+ const url = String(o.url).replace(/\/+$/, "") + "/api/issues/search?" + q.toString();
575
+ const left = deadline ? deadline - Date.now() : 60000;
576
+ if (left <= 0) return { ok: false, code: 6, reason: "timed-out", message: "sonar timed out", timedOut: true };
577
+ let res;
578
+ try {
579
+ res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, signal: AbortSignal.timeout(left) });
580
+ } catch (e) {
581
+ const to = e && (e.name === "TimeoutError" || e.name === "AbortError");
582
+ return { ok: false, code: 6, reason: to ? "timed-out" : "network", message: to ? "sonar timed out" : `sonar is not reachable (${safe(e.cause && e.cause.code ? e.cause.code : e.message, 120)})`, timedOut: to };
583
+ }
584
+ if (res.status === 401 || res.status === 403) return { ok: false, code: 5, reason: "auth", message: `sonar refused the token (HTTP ${res.status}) — check SONAR_TOKEN` };
585
+ if (!res.ok) return { ok: false, code: 6, reason: "network", message: `sonar answered HTTP ${res.status}` };
586
+ let body;
587
+ try {
588
+ body = await res.json();
589
+ } catch (_) {
590
+ return { ok: false, code: 6, reason: "network", message: "sonar answered with something that is not JSON" };
591
+ }
592
+ pages.push(body);
593
+ const issues = Array.isArray(body.issues) ? body.issues : [];
594
+ total = Number((body.paging && body.paging.total) || body.total || 0);
595
+ for (const i of issues) {
596
+ if (seen.has(i.key)) continue;
597
+ seen.add(i.key);
598
+ got++;
599
+ lastCreated = i.creationDate || lastCreated;
600
+ }
601
+ if (!issues.length || p * SONAR_PAGE >= total) return { ok: true, pages };
602
+ }
603
+ // The cap: start a new slice after the last creation date this one reached.
604
+ if (!got || !lastCreated || total <= SONAR_CAP) break;
605
+ createdAfter = lastCreated;
606
+ }
607
+ return { ok: true, pages };
608
+ }
609
+
610
+ // ── The sync state (`.claude/orc/gotchas-sync.json`) ────────────────────────
611
+ const statePath = (claudeDir) => path.join(claudeDir, "orc", "gotchas-sync.json");
612
+ function readState(claudeDir) {
613
+ try {
614
+ const s = JSON.parse(fs.readFileSync(statePath(claudeDir), "utf8"));
615
+ return s && typeof s === "object" ? Object.assign({ version: 1, last_sync: null, sources: {}, sarif: {} }, s) : { version: 1, last_sync: null, sources: {}, sarif: {} };
616
+ } catch (_) {
617
+ return { version: 1, last_sync: null, sources: {}, sarif: {} };
618
+ }
619
+ }
620
+ function writeState(claudeDir, s) {
621
+ const f = statePath(claudeDir);
622
+ fs.mkdirSync(path.dirname(f), { recursive: true });
623
+ fs.writeFileSync(f + ".tmp", JSON.stringify(s, null, 2) + "\n");
624
+ fs.renameSync(f + ".tmp", f);
625
+ }
626
+
627
+ // ── Recording ───────────────────────────────────────────────────────────────
628
+ // Bodies → normalized observations → one batch through bin/gotcha.js. A body
629
+ // that does not validate is COUNTED and named, never recorded half-way.
630
+ function record(deps, claudeDir, bodies, now) {
631
+ const store = G.makeStore(claudeDir, deps);
632
+ const led = store.load();
633
+ const ok = [];
634
+ const rejected = [];
635
+ for (const b of bodies) {
636
+ const n = G.normalizeObservation(b, now);
637
+ if (n.err) rejected.push({ ref: b && b.ref ? String(b.ref) : null, field: n.field, err: n.err });
638
+ else ok.push(n.o);
639
+ }
640
+ const r = G.recordObservations(store, led, ok, now, { skipUnchanged: true });
641
+ return { parsed: bodies.length, recorded: r.recorded.length, unchanged: r.unchanged, rejected, promoted: r.changes.promoted, bumped: r.changes.bumped, file: store.obsFile };
642
+ }
643
+ // Q4: the list grouped by RULE — fix one rule's issues together.
644
+ function groupByRule(views) {
645
+ const by = new Map();
646
+ for (const v of views) {
647
+ const k = v.rule;
648
+ if (!by.has(k)) by.set(k, { rule: k, name: v.name || null, count: 0, outcomes: {}, files: [], items: [] });
649
+ const g = by.get(k);
650
+ g.count++;
651
+ g.outcomes[v.outcome] = (g.outcomes[v.outcome] || 0) + 1;
652
+ if (v.path && !g.files.includes(v.path)) g.files.push(v.path);
653
+ g.items.push(v);
654
+ }
655
+ return [...by.values()].sort((a, b) => (b.outcomes.open || 0) - (a.outcomes.open || 0) || b.count - a.count || a.rule.localeCompare(b.rule));
656
+ }
657
+
658
+ // ── The CLI ─────────────────────────────────────────────────────────────────
659
+ function ctxOf(deps) {
660
+ const { args } = deps;
661
+ const asJson = deps.wantsJson();
662
+ const claudeDir = deps.resolveClaudeDir();
663
+ const repoRoot = deps.repoRootOf(claudeDir);
664
+ const valueOf = (name) => {
665
+ const i = args.indexOf(name);
666
+ if (i === -1) return undefined;
667
+ const v = args[i + 1];
668
+ return v !== undefined && !v.startsWith("--") ? v : "";
669
+ };
670
+ const pos = [];
671
+ for (let i = 0; i < args.length; i++) {
672
+ if (VALUE_FLAGS.has(args[i])) {
673
+ i++;
674
+ continue;
675
+ }
676
+ if (!args[i].startsWith("-")) pos.push(args[i]);
677
+ }
678
+ const out = (obj, code, human) => {
679
+ if (asJson) deps.emitJson(obj, code);
680
+ if (human) human();
681
+ process.exit(code);
682
+ };
683
+ const fail = (code, reason, message, extra) => out(Object.assign({ ok: false, reason }, extra || {}, { message }), code, () => console.error(message));
684
+ const cfg = () => (deps.readOverride ? deps.readOverride(claudeDir).map : {}) || {};
685
+ return { args, asJson, claudeDir, repoRoot, valueOf, pos, out, fail, cfg };
686
+ }
687
+ const humanRecord = (label, r) => () => {
688
+ console.log(`✓ ${label}: ${r.parsed} parsed · ${r.recorded} recorded · ${r.unchanged} unchanged${r.rejected.length ? ` · ${r.rejected.length} rejected` : ""}`);
689
+ for (const p of r.promoted) console.log(` promoted ${p.candidate} → ${p.id}: ${p.why}`);
690
+ for (const x of r.rejected) console.log(` rejected ${x.ref || "(no ref)"}: \`${x.field}\` ${x.err}`);
691
+ };
692
+ const humanGroups = (groups) => {
693
+ for (const g of groups) {
694
+ console.log(` ${g.rule}${g.name ? ` (${g.name})` : ""} · ${g.count} · ${Object.entries(g.outcomes).map(([k, v]) => `${v} ${k}`).join(", ")}`);
695
+ for (const it of g.items.slice(0, 5)) console.log(` ${it.path || "-"}${it.line ? ":" + it.line : ""} ${it.message}`);
696
+ if (g.items.length > 5) console.log(` … +${g.items.length - 5} more`);
697
+ }
698
+ };
699
+
700
+ async function importCmd(deps) {
701
+ const c = ctxOf(deps);
702
+ const kind = c.pos[2];
703
+ const now = Date.now();
704
+ if (kind === "sarif") {
705
+ const file = c.pos[3];
706
+ if (!file) return c.fail(2, "usage", "gotcha import sarif: name the SARIF file (orc gotcha import sarif <file>)");
707
+ let doc;
708
+ try {
709
+ doc = JSON.parse(fs.readFileSync(path.resolve(file), "utf8").replace(/^/, ""));
710
+ } catch (e) {
711
+ return c.fail(2, "not-sarif", `gotcha import sarif: ${file} is not readable JSON (${e.message.split("\n")[0]})`);
712
+ }
713
+ const state = readState(c.claudeDir);
714
+ const p = parseSarif(doc, state.sarif);
715
+ if (p.err) return c.fail(2, "not-sarif", `gotcha import sarif: ${file} is ${p.err}`);
716
+ const r = record(deps, c.claudeDir, p.items.map((x) => x.body), now);
717
+ state.sarif = Object.assign({}, state.sarif, p.state);
718
+ writeState(c.claudeDir, state);
719
+ const groups = groupByRule(p.items.map((x) => x.view));
720
+ return c.out(Object.assign({ ok: true, source: "sarif", file: posix(file), tools: Object.keys(p.state), groups }, r), 0, () => {
721
+ humanRecord("sarif", r)();
722
+ humanGroups(groups);
723
+ });
724
+ }
725
+ if (kind === "sonar") {
726
+ const opts = { url: c.valueOf("--url") || c.cfg().sonar_url, project: c.valueOf("--project") || c.cfg().sonar_project, org: c.valueOf("--org") || c.cfg().sonar_org, pr: c.valueOf("--pr") || null, branch: c.valueOf("--branch") || null };
727
+ const saved = c.valueOf("--file");
728
+ let pages;
729
+ if (saved) {
730
+ try {
731
+ pages = parseJsonStream(fs.readFileSync(path.resolve(saved), "utf8"));
732
+ } catch (e) {
733
+ return c.fail(2, "not-sonar", `gotcha import sonar: ${saved} is not readable JSON (${e.message.split("\n")[0]})`);
734
+ }
735
+ } else {
736
+ if (!opts.url || !opts.project) return c.fail(2, "usage", "gotcha import sonar: needs --url and --project (or the `sonar_url` / `sonar_project` config keys), or --file <saved answer>");
737
+ if (!process.env.SONAR_TOKEN) return c.fail(5, "auth", "gotcha import sonar: set SONAR_TOKEN in the environment (the token is never a config key)");
738
+ const f = await fetchSonar(opts, Date.now() + 120000);
739
+ if (!f.ok) return c.fail(f.code, f.reason, `gotcha import sonar: ${f.message}`);
740
+ pages = f.pages;
741
+ }
742
+ const p = parseSonar(pages, opts);
743
+ if (p.err) return c.fail(2, "not-sonar", `gotcha import sonar: ${saved || "the answer"} is ${p.err}`);
744
+ const r = record(deps, c.claudeDir, p.items.map((x) => x.body), now);
745
+ const groups = groupByRule(p.items.map((x) => x.view));
746
+ return c.out(Object.assign({ ok: true, source: "sonar", offline: !!saved, project: opts.project || null, pr: opts.pr, branch: opts.branch, groups }, r), 0, () => {
747
+ humanRecord("sonar", r)();
748
+ humanGroups(groups);
749
+ });
750
+ }
751
+ if (kind === "pr") {
752
+ const n = Number(c.pos[3]);
753
+ if (!Number.isInteger(n) || n <= 0) return c.fail(2, "usage", "gotcha import pr: name the PR number (orc gotcha import pr <n>)");
754
+ const t = fetchThreads(n, c.repoRoot);
755
+ if (!t.ok) return c.fail(t.code, t.reason, `gotcha import pr: ${t.message}`);
756
+ const bodies = threadObservations(t.pr, t.rows);
757
+ const r = record(deps, c.claudeDir, bodies, now);
758
+ const bots = t.rows.filter((x) => x.author_kind === "bot").length;
759
+ return c.out(Object.assign({ ok: true, source: "pr", pr: t.pr.number, state: t.pr.state, threads: t.rows.length, bots_skipped: bots }, r), 0, humanRecord(`PR ${n} (${t.rows.length} threads, ${bots} by bots skipped)`, r));
760
+ }
761
+ if (kind === "issues") {
762
+ const label = c.valueOf("--label") || "bug";
763
+ const res = importIssues(c.repoRoot, label, null, null);
764
+ if (!res.ok) return c.fail(res.code, res.reason, `gotcha import issues: ${res.message}`);
765
+ const r = record(deps, c.claudeDir, res.bodies, now);
766
+ return c.out(Object.assign({ ok: true, source: "defect", label, issues: res.issues }, r), 0, humanRecord(`issues labelled ${label} (${res.issues} closed)`, r));
767
+ }
768
+ return c.fail(2, "usage", "Usage: orc gotcha import sarif <file> | sonar [--url U --project K] [--pr N|--branch B] [--org O] [--file <saved answer>] | pr <n> | issues [--label bug]");
769
+ }
770
+ function importIssues(cwd, label, since, deadline) {
771
+ const a = ["issue", "list", "--state", "closed", "--label", label, "--json", "number,title,labels,stateReason,closedByPullRequestsReferences,closedAt", "--limit", "100"];
772
+ if (since) a.push("--search", `closed:>=${String(since).slice(0, 10)}`);
773
+ const r = ghJson(a, cwd, deadline);
774
+ if (!r.ok) return r;
775
+ const issues = flatPages(r.pages);
776
+ const files = {};
777
+ const filesOf = (n) => {
778
+ if (files[n]) return files[n];
779
+ const f = ghJson(["pr", "view", String(n), "--json", "files"], cwd, deadline);
780
+ files[n] = f.ok ? flatPages(f.pages).flatMap((x) => (x && x.files ? x.files.map((y) => y.path) : [])) : [];
781
+ return files[n];
782
+ };
783
+ const bodies = issueObservations(issues, filesOf);
784
+ const newest = issues.map((i) => i.closedAt).filter(Boolean).sort().pop() || null;
785
+ return { ok: true, bodies, issues: issues.length, cursor: newest };
786
+ }
787
+
788
+ // ── CI: failing runs, their failing steps, flaky named ──────────────────────
789
+ const RUN_FIELDS = "databaseId,headSha,headBranch,workflowName,conclusion,status,attempt,createdAt,url,event";
790
+ function ciRuns(cwd, o, deadline) {
791
+ if (o.run) {
792
+ const v = ghJson(["run", "view", String(o.run), "--json", RUN_FIELDS], cwd, deadline);
793
+ if (!v.ok) return v;
794
+ const runs = flatPages(v.pages);
795
+ // The other runs of the SAME sha decide flaky; a failed list is not fatal.
796
+ const sib = runs[0] && runs[0].headSha ? ghJson(["run", "list", "--commit", runs[0].headSha, "--json", RUN_FIELDS, "--limit", "50"], cwd, deadline) : null;
797
+ const all = runs.concat(sib && sib.ok ? flatPages(sib.pages).filter((x) => x && x.databaseId && x.databaseId !== runs[0].databaseId) : []);
798
+ return { ok: true, runs, all, branch: null };
799
+ }
800
+ let branch = o.branch || null;
801
+ if (o.pr) {
802
+ const p = ghJson(["pr", "view", String(o.pr), "--json", "headRefName"], cwd, deadline);
803
+ if (!p.ok) return p;
804
+ branch = (flatPages(p.pages)[0] || {}).headRefName || null;
805
+ }
806
+ const a = ["run", "list", "--json", RUN_FIELDS, "--limit", "50"];
807
+ if (branch) a.push("--branch", branch);
808
+ if (o.since) a.push("--created", `>=${String(o.since).slice(0, 10)}`);
809
+ const l = ghJson(a, cwd, deadline);
810
+ if (!l.ok) return l;
811
+ const all = flatPages(l.pages).filter((x) => x && x.databaseId);
812
+ // The newest run per workflow decides whether that workflow is red now.
813
+ const newest = new Map();
814
+ for (const r of all.slice().sort((x, y) => String(y.createdAt).localeCompare(String(x.createdAt)))) if (!newest.has(r.workflowName)) newest.set(r.workflowName, r);
815
+ const runs = [...newest.values()].filter((r) => String(r.conclusion).toLowerCase() === "failure");
816
+ return { ok: true, runs, all, branch };
817
+ }
818
+ function ciDetail(run, all, cwd, deadline, attempt) {
819
+ const a = ["run", "view", String(run.databaseId), "--log-failed"];
820
+ if (attempt) a.push("--attempt", String(attempt));
821
+ const log = ghRun(a, cwd, deadline);
822
+ if (!log.ok) return log;
823
+ const failures = parseCiLog(log.out);
824
+ const green = all.find((x) => x.headSha === run.headSha && x.workflowName === run.workflowName && String(x.conclusion).toLowerCase() === "success");
825
+ const runFlaky = !!(green || (attempt && String(run.conclusion).toLowerCase() === "success"));
826
+ return {
827
+ ok: true,
828
+ row: {
829
+ id: run.databaseId,
830
+ workflow: run.workflowName || null,
831
+ sha: run.headSha || null,
832
+ branch: run.headBranch || null,
833
+ attempt: run.attempt || 1,
834
+ url: run.url || null,
835
+ flaky: runFlaky,
836
+ flaky_reason: runFlaky ? "the same sha passed on another attempt" : null,
837
+ failures,
838
+ },
839
+ };
840
+ }
841
+ // `orc ci flaky` — exit 0 flaky (no repair round) · 1 red (repair as usual) ·
842
+ // 3 unknown (no test named: red) · 2 usage. It reads two saved logs; it runs nothing.
843
+ function ciFlakyCmd(c) {
844
+ const first = c.valueOf("--first");
845
+ const rerun = c.valueOf("--rerun");
846
+ const exitV = c.valueOf("--rerun-exit");
847
+ const usage = "Usage: orc ci flaky --first <log> [--rerun <log>] [--rerun-exit <n>] [--json] exit 0 flaky · 1 red · 3 unknown";
848
+ if (!first) return c.fail(2, "usage", usage);
849
+ const read = (f) => {
850
+ try {
851
+ return fs.readFileSync(path.resolve(f), "utf8");
852
+ } catch (_) {
853
+ return null;
854
+ }
855
+ };
856
+ const a = read(first);
857
+ if (a == null) return c.fail(2, "unreadable", `ci flaky: cannot read ${first}`);
858
+ const b = rerun ? read(rerun) : null;
859
+ if (rerun && b == null) return c.fail(2, "unreadable", `ci flaky: cannot read ${rerun}`);
860
+ const r = classifyRerun(a, b, exitV === undefined || exitV === "" ? null : exitV);
861
+ const gate_line = r.state === "flaky" ? `GATE flaky :: ${r.flaky.join(", ")}` : null;
862
+ const payload = Object.assign({ ok: true, rerun_read: rerun ? true : false, rerun_exit: exitV ? Number(exitV) : null }, r, { repair_round: r.state !== "flaky", gate_line });
863
+ return c.out(payload, r.state === "flaky" ? 0 : r.state === "red" ? 1 : 3, () => {
864
+ console.log(`ci flaky: ${r.state.toUpperCase()} — ${r.reason}`);
865
+ for (const t of r.flaky) console.log(` flaky: ${t}`);
866
+ for (const t of r.still_failing) console.log(` failed: ${t}`);
867
+ for (const t of r.missing) console.log(` not re-run: ${t}`);
868
+ if (gate_line) console.log(` ${gate_line} (no repair round; the run is not red)`);
869
+ });
870
+ }
871
+
872
+ function ciFailedCmd(deps) {
873
+ const c = ctxOf(deps);
874
+ if (c.pos[1] === "flaky") return ciFlakyCmd(c);
875
+ if (c.pos[1] !== "failed") return c.fail(2, "usage", "Usage: orc ci failed [--pr <n> | --run <id>] [--json] the failing CI steps (read-only; exit 0 failures · 1 none · 5 gh missing/not authed)\n orc ci flaky --first <log> [--rerun <log>] [--rerun-exit <n>] [--json] a local red run: flaky or real");
876
+ const o = { pr: c.valueOf("--pr") || null, run: c.valueOf("--run") || null };
877
+ if (!o.pr && !o.run) o.branch = currentBranch(c.repoRoot);
878
+ const l = ciRuns(c.repoRoot, o, null);
879
+ if (!l.ok) return c.fail(l.code, l.reason, `ci failed: ${l.message}`);
880
+ const rows = [];
881
+ for (const run of l.runs) {
882
+ const d = ciDetail(run, l.all, c.repoRoot, null, null);
883
+ if (!d.ok) return c.fail(d.code, d.reason, `ci failed: ${d.message}`);
884
+ rows.push(d.row);
885
+ }
886
+ const real = rows.reduce((n, r) => n + (r.flaky ? 0 : r.failures.filter((f) => !f.flaky).length), 0);
887
+ const flaky = rows.reduce((n, r) => n + (r.flaky ? r.failures.length : r.failures.filter((f) => f.flaky).length), 0);
888
+ const payload = { ok: true, pr: o.pr ? Number(o.pr) : null, run: o.run ? Number(o.run) : null, branch: l.branch || o.branch || null, runs: rows, counts: { runs: rows.length, failures: real, flaky } };
889
+ return c.out(payload, real ? 0 : 1, () => {
890
+ if (!rows.length) return console.log(`ci: no failing run${payload.branch ? ` on ${payload.branch}` : ""}`);
891
+ for (const r of rows) {
892
+ console.log(`run ${r.id} · ${r.workflow} · ${String(r.sha || "").slice(0, 7)}${r.flaky ? " · FLAKY (" + r.flaky_reason + ")" : ""}`);
893
+ for (const f of r.failures) {
894
+ console.log(` [${f.job}] ${f.step}${f.flaky ? " · flaky — not a failure" : ""} · ${f.rule} · repro: ${f.repro}${f.repro_reason ? ` (${f.repro_reason})` : ""}`);
895
+ for (const t of f.tests_failed) console.log(` failed: ${t}`);
896
+ for (const t of f.tests_flaky) console.log(` flaky: ${t} (failed, then passed on a re-attempt)`);
897
+ for (const x of f.lines) console.log(` ${x}`);
898
+ }
899
+ }
900
+ console.log(`${real} failure(s) · ${flaky} flaky — a flaky failure gets no repair round`);
901
+ });
902
+ }
903
+ // CI → observations for sync: still red → open · a later green run of the same
904
+ // workflow on another sha → addressed · green on the SAME sha → flaky (never a gotcha).
905
+ function ciObservations(cwd, branch, since, deadline) {
906
+ const l = ciRuns(cwd, { branch, since }, deadline);
907
+ if (!l.ok) return l;
908
+ const bodies = [];
909
+ const sorted = l.all.slice().sort((x, y) => String(x.createdAt).localeCompare(String(y.createdAt)));
910
+ for (const run of sorted) {
911
+ const concl = String(run.conclusion).toLowerCase();
912
+ const reattempted = concl === "success" && Number(run.attempt) > 1;
913
+ if (concl !== "failure" && !reattempted) continue;
914
+ const d = ciDetail(run, l.all, cwd, deadline, reattempted ? 1 : null);
915
+ if (!d.ok) return d;
916
+ const laterGreen = sorted.find((x) => x.workflowName === run.workflowName && x.headSha !== run.headSha && String(x.createdAt) > String(run.createdAt) && String(x.conclusion).toLowerCase() === "success");
917
+ for (const f of d.row.failures) {
918
+ const outcome = d.row.flaky || f.flaky ? "flaky" : laterGreen ? "addressed" : "open";
919
+ bodies.push({ source: "ci", ref: `CI ${run.workflowName} · ${f.signature} · ${String(run.headSha || "").slice(0, 12)}`, rule: f.rule, sig: f.tests_failed[0] || f.lines[0] || f.step, path: f.path, lines: f.line ? [f.line] : null, commit: run.headSha || null, at: run.createdAt || null, outcome, author: "bot" });
920
+ }
921
+ }
922
+ const newest = l.all.map((x) => x.createdAt).filter(Boolean).sort().pop() || null;
923
+ return { ok: true, bodies, cursor: newest };
924
+ }
925
+
926
+ // ── orc pr threads <n> (Q1) ─────────────────────────────────────────────────
927
+ function prThreadsCmd(deps) {
928
+ const c = ctxOf(deps);
929
+ const n = Number(c.pos[2]);
930
+ if (!Number.isInteger(n) || n <= 0) return c.fail(2, "usage", "Usage: orc pr threads <n> [--all] [--json] the unresolved review threads (read-only; exit 0 threads · 1 none open · 5 gh missing/not authed)");
931
+ const all = c.args.includes("--all");
932
+ const t = fetchThreads(n, c.repoRoot);
933
+ if (!t.ok) return c.fail(t.code, t.reason, `pr threads: ${t.message}`);
934
+ const rv = fetchReviews(n, c.repoRoot);
935
+ const open = t.rows.filter((r) => !r.resolved && !r.outdated);
936
+ const threads = all ? t.rows.filter((r) => r.author_kind === "human") : open.filter((r) => r.author_kind === "human");
937
+ const bots = (all ? t.rows : open).filter((r) => r.author_kind === "bot");
938
+ const payload = {
939
+ ok: true,
940
+ pr: t.pr.number,
941
+ title: t.pr.title,
942
+ state: t.pr.state,
943
+ url: t.pr.url,
944
+ head: t.pr.head,
945
+ all,
946
+ threads,
947
+ bots,
948
+ reviews: rv.ok ? rv.reviews : [],
949
+ reviews_error: rv.ok ? null : rv.message,
950
+ counts: {
951
+ threads: t.rows.length,
952
+ open: open.filter((r) => r.author_kind === "human").length,
953
+ resolved: t.rows.filter((r) => r.resolved).length,
954
+ outdated: t.rows.filter((r) => r.outdated).length,
955
+ bots: t.rows.filter((r) => r.author_kind === "bot").length,
956
+ },
957
+ };
958
+ const shown = threads.length + (all ? bots.length : 0);
959
+ return c.out(payload, payload.counts.open || (all && shown) ? 0 : 1, () => {
960
+ console.log(`PR #${t.pr.number} — "${t.pr.title || ""}" · branch ${t.pr.head || "-"} · ${t.pr.state || "-"}`);
961
+ console.log(`${payload.counts.open} unresolved thread(s) by people (${payload.counts.threads} total · ${payload.counts.resolved} resolved · ${payload.counts.outdated} outdated · ${payload.counts.bots} by bots)`);
962
+ const line = (r) => ` [${r.n}] ${r.author_kind === "bot" ? "bot " : "@"}${r.author || "?"} · ${r.path || "-"}${r.line || r.original_line ? ":" + (r.line || r.original_line) : ""}${all ? " · " + r.state : ""}\n (untrusted) "${r.first.text}"\n ${r.url || ""}`;
963
+ for (const r of threads) console.log(line(r));
964
+ if (bots.length) {
965
+ console.log("bot threads (listed apart, never mixed):");
966
+ for (const r of bots) console.log(line(r));
967
+ }
968
+ for (const r of payload.reviews) console.log(` review CHANGES_REQUESTED · @${r.author || "?"}: (untrusted) "${r.body.text}"`);
969
+ });
970
+ }
971
+
972
+ // ── orc gotcha sync (`05` §7.1) ─────────────────────────────────────────────
973
+ async function syncCmd(deps) {
974
+ const c = ctxOf(deps);
975
+ const now = Date.now();
976
+ const hours = Math.max(1, Number(c.cfg().gotcha_sync_hours) || 6);
977
+ const state = readState(c.claudeDir);
978
+ const last = Date.parse(state.last_sync || "");
979
+ const nextDue = Number.isFinite(last) ? new Date(last + hours * 3600e3).toISOString() : null;
980
+ if (Number.isFinite(last) && now < last + hours * 3600e3 && !c.args.includes("--force")) {
981
+ const ago = Math.round((now - last) / 3600e3 * 10) / 10;
982
+ return c.out({ ok: true, due: false, ran: false, last_sync: state.last_sync, next_due: nextDue, every_hours: hours, sources: [], new: 0, promoted: [], state_file: statePath(c.claudeDir) }, 1, () =>
983
+ console.log(`gotchas: sync — not due (last ${ago} h ago, every ${hours} h)`)
984
+ );
985
+ }
986
+ const cfg = c.cfg();
987
+ const sources = [];
988
+ const bodies = [];
989
+ const cursors = Object.assign({}, state.sources);
990
+ const runSource = async (name, why, fn) => {
991
+ const row = { name, ran: false, ok: null, skipped: why || null, error: null, timed_out: false, observations: 0 };
992
+ sources.push(row);
993
+ if (why) return;
994
+ row.ran = true;
995
+ const deadline = Date.now() + SYNC_BOX_MS;
996
+ let r;
997
+ try {
998
+ r = await fn(deadline, (cursors[name] || {}).cursor || null);
999
+ } catch (e) {
1000
+ r = { ok: false, message: e.message };
1001
+ }
1002
+ if (!r.ok) {
1003
+ row.ok = false;
1004
+ row.timed_out = !!r.timedOut;
1005
+ row.error = r.timedOut ? `${name} timed out` : r.message;
1006
+ cursors[name] = Object.assign({}, cursors[name], { last_error: row.error, last_run: new Date(now).toISOString() });
1007
+ return;
1008
+ }
1009
+ row.ok = true;
1010
+ row.observations = r.bodies.length;
1011
+ bodies.push(...r.bodies);
1012
+ cursors[name] = { cursor: r.cursor || (cursors[name] || {}).cursor || null, last_ok: new Date(now).toISOString(), last_error: null };
1013
+ };
1014
+ // Sonar — needs sonar_url + sonar_project AND SONAR_TOKEN in the environment.
1015
+ const sonarWhy = !cfg.sonar_url || !cfg.sonar_project ? "sonar_url / sonar_project not set" : !process.env.SONAR_TOKEN ? "SONAR_TOKEN not in the environment" : null;
1016
+ // gh — one auth probe decides the three gh sources.
1017
+ const auth = ghRun(["auth", "status"], c.repoRoot, Date.now() + SYNC_BOX_MS);
1018
+ // Missing or not logged in is a SKIP (the source is not available). A probe
1019
+ // that timed out or broke is a FAILURE of every gh source, named.
1020
+ const ghWhy = auth.ok ? null : auth.reason === "gh-missing" ? "gh is not installed" : auth.reason === "gh-not-authed" ? "gh is not logged in" : null;
1021
+ const ghBroken = auth.ok || ghWhy ? null : auth;
1022
+ let pr = null;
1023
+ if (!ghWhy && !ghBroken) {
1024
+ const p = ghJson(["pr", "view", "--json", "number,state,headRefName"], c.repoRoot, Date.now() + SYNC_BOX_MS);
1025
+ pr = p.ok ? flatPages(p.pages)[0] || null : null;
1026
+ }
1027
+ const branch = currentBranch(c.repoRoot);
1028
+ await runSource("sonar", sonarWhy, async (deadline, cursor) => {
1029
+ const f = await fetchSonar({ url: cfg.sonar_url, project: cfg.sonar_project, org: cfg.sonar_org, pr: pr && pr.number, branch: pr ? null : branch }, deadline);
1030
+ if (!f.ok) return f;
1031
+ const p = parseSonar(f.pages, { pr: pr && pr.number });
1032
+ if (p.err) return { ok: false, message: p.err };
1033
+ const fresh = p.items.filter((x) => !cursor || !x.updated || String(x.updated) > String(cursor));
1034
+ const newest = p.items.map((x) => x.updated).filter(Boolean).sort().pop() || cursor;
1035
+ return { ok: true, bodies: fresh.map((x) => x.body), cursor: newest };
1036
+ });
1037
+ await runSource("pr", ghWhy || (!pr && !ghBroken ? "no PR for this branch" : null), async (deadline) => {
1038
+ if (ghBroken) return ghBroken;
1039
+ const t = fetchThreads(pr.number, c.repoRoot, deadline);
1040
+ if (!t.ok) return t;
1041
+ return { ok: true, bodies: threadObservations(t.pr, t.rows), cursor: `PR ${pr.number}` };
1042
+ });
1043
+ await runSource("defects", ghWhy, async (deadline, cursor) => {
1044
+ if (ghBroken) return ghBroken;
1045
+ const r = importIssues(c.repoRoot, "bug", cursor, deadline);
1046
+ if (!r.ok) return r;
1047
+ return { ok: true, bodies: r.bodies, cursor: r.cursor || cursor };
1048
+ });
1049
+ await runSource("ci", ghWhy, async (deadline, cursor) => ghBroken || ciObservations(c.repoRoot, pr ? pr.headRefName : branch, cursor, deadline));
1050
+ const r = record(deps, c.claudeDir, bodies, now);
1051
+ state.last_sync = new Date(now).toISOString();
1052
+ state.sources = cursors;
1053
+ writeState(c.claudeDir, state);
1054
+ const failed = sources.filter((s) => s.ran && !s.ok);
1055
+ const okN = sources.filter((s) => s.ran && s.ok).length;
1056
+ const code = failed.length ? 6 : r.recorded ? 0 : 1;
1057
+ const bits = [`${r.recorded} new observation${r.recorded === 1 ? "" : "s"}`, `${okN} source${okN === 1 ? "" : "s"} ok`]
1058
+ .concat(failed.map((s) => (s.timed_out ? `${s.name} timed out` : `${s.name} failed (${s.error})`)))
1059
+ .concat(sources.filter((s) => s.skipped).map((s) => `${s.name} skipped (${s.skipped})`));
1060
+ const payload = Object.assign({ ok: failed.length === 0, due: true, ran: true, last_sync: state.last_sync, next_due: new Date(now + hours * 3600e3).toISOString(), every_hours: hours, sources, new: r.recorded, failed: failed.map((s) => s.name), state_file: statePath(c.claudeDir) }, r);
1061
+ return c.out(payload, code, () => console.log(`gotchas: sync — ${bits.join(" · ")}`));
1062
+ }
1063
+
1064
+ module.exports = {
1065
+ parseJsonStream,
1066
+ parseSarif,
1067
+ parseSonar,
1068
+ parseThreads,
1069
+ threadOutcome,
1070
+ threadObservations,
1071
+ issueObservations,
1072
+ parseCiLog,
1073
+ ciSignature,
1074
+ classifyRerun,
1075
+ groupByRule,
1076
+ THREADS_QUERY,
1077
+ importCmd,
1078
+ syncCmd,
1079
+ prThreadsCmd,
1080
+ ciFailedCmd,
1081
+ };