@azure-id/orc 2.0.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 (121) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/README-id.md +52 -23
  3. package/README.md +50 -22
  4. package/bin/build-agents.js +24 -25
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +1155 -158
  7. package/bin/fix.js +459 -0
  8. package/bin/gotcha.js +288 -8
  9. package/bin/habit.js +1538 -1453
  10. package/bin/mockrun-catalog.js +3 -0
  11. package/bin/onboarding-content.js +2 -1
  12. package/bin/pricing.json +28 -21
  13. package/bin/trace-write.js +869 -657
  14. package/bin/verify-contracts.js +108 -27
  15. package/bin/verify-package.js +17 -6
  16. package/bin/webui/api.js +125 -6
  17. package/bin/webui/css/panels/hookui.css +79 -0
  18. package/bin/webui/fixtures/behaviour.js +30 -0
  19. package/bin/webui/fixtures/extra.js +9 -9
  20. package/bin/webui/fixtures/flow.js +81 -81
  21. package/bin/webui/fixtures/hookui.js +506 -431
  22. package/bin/webui/fixtures/index.js +32 -3
  23. package/bin/webui/fixtures/knowledge.js +2 -2
  24. package/bin/webui/fixtures/maintenance.js +123 -42
  25. package/bin/webui/fixtures/rules.js +1007 -1002
  26. package/bin/webui/fixtures/settings.js +6 -7
  27. package/bin/webui/fixtures/stats.js +9 -6
  28. package/bin/webui/fixtures/wait.js +98 -97
  29. package/bin/webui/i18n/en/behaviour.json +13 -0
  30. package/bin/webui/i18n/en/hookui.json +290 -194
  31. package/bin/webui/i18n/en/maintenance.json +71 -52
  32. package/bin/webui/i18n/en/settings.json +60 -60
  33. package/bin/webui/i18n/id/behaviour.json +13 -0
  34. package/bin/webui/i18n/id/hookui.json +290 -194
  35. package/bin/webui/i18n/id/maintenance.json +71 -52
  36. package/bin/webui/i18n/id/settings.json +60 -60
  37. package/bin/webui/js/05-banners.js +181 -172
  38. package/bin/webui/js/06-edit.js +193 -183
  39. package/bin/webui/js/panels/behaviour.js +77 -12
  40. package/bin/webui/js/panels/hookui.js +2196 -1691
  41. package/bin/webui/js/panels/maintenance.js +336 -235
  42. package/bin/webui/js/panels/settings.js +726 -719
  43. package/mock-run/INDEX.md +1 -0
  44. package/mock-run/extra-slots.md +1 -1
  45. package/mock-run/habits.md +1 -1
  46. package/mock-run/orc-aftermath.md +392 -392
  47. package/mock-run/orc-budget.md +29 -33
  48. package/mock-run/orc-cli.md +200 -200
  49. package/mock-run/orc-explain.md +86 -86
  50. package/mock-run/orc-extra.md +389 -392
  51. package/mock-run/orc-fix.md +87 -0
  52. package/mock-run/orc-pattern.md +112 -112
  53. package/mock-run/orc.md +157 -157
  54. package/package.json +1 -1
  55. package/templates/agents/MODEL-MAPPING.md +32 -29
  56. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -2
  57. package/templates/agents/{orc-claude-writer-opus-5-med.md → orc-claude-writer-opus-5-low.md} +4 -4
  58. package/templates/agents/orc-executor-haiku-4-5.md +1 -1
  59. package/templates/agents/orc-executor-opus-5-low.md +1 -1
  60. package/templates/agents/orc-executor-sonnet-4-6-high.md +1 -1
  61. package/templates/agents/orc-executor-sonnet-4-6-med.md +1 -1
  62. package/templates/agents/orc-executor-sonnet-5-high.md +2 -2
  63. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  64. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  65. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +2 -2
  66. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -2
  67. package/templates/agents/orc-retro-sonnet-5-high.md +2 -2
  68. package/templates/agents/{orc-reviewer-opus-5-med.md → orc-reviewer-opus-5-low.md} +4 -4
  69. package/templates/agents/{orc-wiki-scanner-opus-5-med.md → orc-wiki-scanner-opus-5-low.md} +3 -3
  70. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +2 -2
  71. package/templates/commands/orc-fix.md +18 -0
  72. package/templates/commands/orc-wiki.md +64 -64
  73. package/templates/hooks/README.md +75 -7
  74. package/templates/hooks/orc-session-hook.js +78 -3
  75. package/templates/hooks/orc-statusline-render.js +317 -23
  76. package/templates/hooks/orc-statusline.js +162 -49
  77. package/templates/hooks/orc-subagent-line.js +23 -5
  78. package/templates/hooks/orc-weather-fetch.js +123 -0
  79. package/templates/skills/_shared/extra-dispatch.md +5 -5
  80. package/templates/skills/_shared/gotchas.md +27 -7
  81. package/templates/skills/_shared/habits.md +2 -2
  82. package/templates/skills/_shared/opus5-only.md +14 -14
  83. package/templates/skills/_shared/phases/review.md +2 -2
  84. package/templates/skills/_shared/phases/trace-verbs.md +17 -3
  85. package/templates/skills/_shared/phases/trace.md +136 -136
  86. package/templates/skills/_shared/review-slice.md +28 -1
  87. package/templates/skills/_shared/wait.md +1 -0
  88. package/templates/skills/orc/README.md +2 -2
  89. package/templates/skills/orc/SKILL.md +1 -1
  90. package/templates/skills/orc/config.md +25 -27
  91. package/templates/skills/orc/examples/full-run-mock.md +2 -1
  92. package/templates/skills/orc/references/effort-and-mode.md +17 -17
  93. package/templates/skills/orc/references/preflight-report.md +3 -3
  94. package/templates/skills/orc/references/ultra-mode.md +1 -1
  95. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  96. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +3 -3
  97. package/templates/skills/orc/subskills/orc-review-verify/core.md +1 -1
  98. package/templates/skills/orc-analyze/SKILL.md +4 -2
  99. package/templates/skills/orc-claude/SKILL.md +14 -14
  100. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  101. package/templates/skills/orc-diy/SKILL.md +5 -5
  102. package/templates/skills/orc-fast/SKILL.md +8 -6
  103. package/templates/skills/orc-fix/SKILL.md +110 -0
  104. package/templates/skills/orc-mini/SKILL.md +4 -4
  105. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  106. package/templates/skills/orc-quick/README.md +1 -1
  107. package/templates/skills/orc-quick/SKILL.md +2 -2
  108. package/templates/skills/orc-quick/references/dispatch-gate.md +3 -2
  109. package/templates/skills/orc-quick/references/gh-mode.md +4 -2
  110. package/templates/skills/orc-retro/SKILL.md +1 -1
  111. package/templates/skills/orc-retro/examples/retro-mock.md +16 -16
  112. package/templates/skills/orc-wiki/README.md +325 -325
  113. package/templates/skills/orc-wiki/SKILL.md +195 -195
  114. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  115. package/templates/skills/orc-wiki/references/extra.md +2 -2
  116. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  117. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -1
  118. package/templates/skills/orc-wiki/references/phases/phase-2.md +3 -3
  119. package/templates/skills/orc-wiki/references/phases/phase-3c.md +3 -2
  120. package/templates/agents/orc-claude-writer-opus-4-8-high.md +0 -48
  121. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +0 -74
package/bin/gotcha.js CHANGED
@@ -63,6 +63,13 @@ const SEVERITIES = ["P0", "P1", "P2", "P3"];
63
63
  const POLARITIES = ["flag", "suppress"];
64
64
  const OUTCOMES = ["addressed", "open", "disputed", "wontfix", "flaky"];
65
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)
66
73
  // The 8 required fields, in their FIXED order (v1, unchanged) …
67
74
  const REQUIRED = ["trigger", "symptom", "cause", "fix", "scope", "origin", "hits", "last_seen"];
68
75
  // … then the optional ones, fixed order when present. `cwe` rides beside
@@ -642,12 +649,93 @@ function suppressLine(v) {
642
649
  }
643
650
 
644
651
  // ── Quality (acceptance of ORC's own findings) ──────────────────────────────
645
- function quality(obs, entries, windowDays, nowMs) {
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);
646
721
  const orc = obs.filter((o) => o.author === "orc" && (() => {
647
722
  const t = dateMs(o.at);
648
723
  return !Number.isFinite(t) || nowMs - t <= windowDays * DAY;
649
724
  })());
650
- const runs = [...new Set(orc.map((o) => o.run).filter(Boolean))];
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;
651
739
  const cats = {};
652
740
  for (const o of orc) {
653
741
  const c = o.category || "uncategorized";
@@ -680,6 +768,14 @@ function quality(obs, entries, windowDays, nowMs) {
680
768
  return {
681
769
  window_days: windowDays,
682
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),
683
779
  floor: QUALITY_FLOOR,
684
780
  below_floor: runs.length < QUALITY_FLOOR,
685
781
  findings: orc.length,
@@ -788,6 +884,13 @@ function normalizeObservation(b, now) {
788
884
  if (!b.ref || typeof b.ref !== "string") return bad("ref", "is required (e.g. \"PR 142 · thread 3\")");
789
885
  if (!OUTCOMES.includes(b.outcome)) return bad("outcome", `must be one of: ${OUTCOMES.join(", ")}`);
790
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
+ }
791
894
  if (b.category !== undefined && b.category !== null && !CATEGORIES.includes(b.category)) return bad("category", `must be one of: ${CATEGORIES.join(", ")}`);
792
895
  if (b.severity !== undefined && b.severity !== null && !SEVERITIES.includes(b.severity)) return bad("severity", "must be P0, P1, P2 or P3");
793
896
  if (b.cwe !== undefined && b.cwe !== null && !/^CWE-\d+$/.test(b.cwe)) return bad("cwe", "must be CWE-<number>");
@@ -819,8 +922,141 @@ function normalizeObservation(b, now) {
819
922
  if (b.reproduced === true) o.reproduced = true;
820
923
  if (b.miss === true) o.miss = true;
821
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");
822
944
  return { o };
823
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
+ }
824
1060
 
825
1061
  // Append each normalized observation, bump what an active entry already covers,
826
1062
  // then run promotion ONCE. `skipUnchanged`: an importer re-reading the same
@@ -831,8 +1067,15 @@ function recordObservations(store, led, list, now, opts) {
831
1067
  const changes = { bumped: [], promoted: [], helpful: [], harmful: [] };
832
1068
  const recorded = [];
833
1069
  let unchanged = 0;
834
- for (const o of list) {
1070
+ for (let o of list) {
835
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
+ }
836
1079
  if (skipUnchanged && prev && prev.outcome === o.outcome) {
837
1080
  unchanged++;
838
1081
  continue;
@@ -878,7 +1121,8 @@ const USAGE =
878
1121
  " orc gotcha match --files <csv> the executor block (exit 0 · 1 no match · 4 off)\n" +
879
1122
  " orc gotcha card --files <csv> [--lane <l>] [--full] the reviewer card (exit 0 · 1 no match)\n" +
880
1123
  " orc gotcha filter --findings <file|-> drop suppressed / folded / noisy advice; never a P0 or P1\n" +
881
- " orc gotcha observe <file|-> append one observation, then run promotion (exit 0 · 2 malformed)\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" +
882
1126
  " orc gotcha list --candidates computed candidates and proposed suppressions (exit 0 · 1 none)\n" +
883
1127
  " orc gotcha accept <C-id> a person promotes a candidate (exit 0 · 2 unknown)\n" +
884
1128
  " orc gotcha quality [--window <days>] acceptance per category (exit 0 · 1 below the 5-review floor)\n" +
@@ -1002,8 +1246,19 @@ function gotchaCmd(deps, sub) {
1002
1246
  const cands = candidates(led.entries, obs, now).filter((c) => c.watch && files.some((f) => inScope(c.scope, f)));
1003
1247
  for (const c of cands)
1004
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
+ }
1005
1259
  const byRank = (a, b) => (b.first ? 1 : 0) - (a.first ? 1 : 0) || b.rank - a.rank || a.id.localeCompare(b.id);
1006
1260
  const ordered = rows.filter((r) => r.type === "flag").sort(byRank)
1261
+ .concat(rows.filter((r) => r.type === "fix").sort(byRank))
1007
1262
  .concat(rows.filter((r) => r.type === "watch").sort(byRank))
1008
1263
  .concat(rows.filter((r) => r.type === "suppress").sort(byRank));
1009
1264
  const matched = ordered.length;
@@ -1021,7 +1276,7 @@ function gotchaCmd(deps, sub) {
1021
1276
  r.dropped = "watch cap";
1022
1277
  continue;
1023
1278
  }
1024
- if (r.type !== "suppress" && nFlag + nWatch >= CARD_FLAG_CAP) {
1279
+ if ((r.type === "flag" || r.type === "watch") && nFlag + nWatch >= CARD_FLAG_CAP) {
1025
1280
  r.dropped = "line cap";
1026
1281
  continue;
1027
1282
  }
@@ -1037,7 +1292,7 @@ function gotchaCmd(deps, sub) {
1037
1292
  kept.push(r);
1038
1293
  if (r.type === "flag") nFlag++;
1039
1294
  else if (r.type === "watch") nWatch++;
1040
- else nSup++;
1295
+ else if (r.type === "suppress") nSup++;
1041
1296
  }
1042
1297
  const dropped = matched - kept.length;
1043
1298
  const text = [headerFor(matched, dropped)].concat(kept.map((k) => k.text)).join("\n");
@@ -1124,11 +1379,23 @@ function gotchaCmd(deps, sub) {
1124
1379
  if (sub === "observe") {
1125
1380
  const inp = readInput(pos[2] || (args.includes("-") ? "-" : undefined));
1126
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
+ }
1127
1392
  const n = normalizeObservation(inp.value, now);
1128
1393
  if (n.err) return fail(2, "malformed", `gotcha observe: field \`${n.field}\` ${n.err}`, { field: n.field });
1129
- const o = n.o;
1394
+ let o = n.o;
1395
+ if (isFixRecord(o) && !o.miss) o = applyMiss(claudeDir, o, NaN);
1130
1396
  const { recorded, changes, all } = recordObservations(store, led, [o], now);
1131
1397
  const prev = recorded[0].prev;
1398
+ o = recorded[0].o;
1132
1399
  const after = candidates(led.entries, all, now);
1133
1400
  const mine = after.find((c) => c.obs.includes(o.obs));
1134
1401
  const payload = {
@@ -1195,7 +1462,7 @@ function gotchaCmd(deps, sub) {
1195
1462
  if (sub === "quality") {
1196
1463
  const w = Number(valueOf("--window"));
1197
1464
  const windowDays = Number.isInteger(w) && w > 0 ? w : WINDOW_DAYS;
1198
- const q = quality(obs, led.entries, windowDays, now);
1465
+ const q = quality(obs, led.entries, windowDays, now, tracedReviewRuns(claudeDir, deps, windowDays, now));
1199
1466
  return out(Object.assign({ ok: true }, q), q.below_floor ? 1 : 0, () => {
1200
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`);
1201
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" : ""}`);
@@ -1281,6 +1548,19 @@ module.exports = {
1281
1548
  makeStore,
1282
1549
  normalizeObservation,
1283
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,
1284
1564
  redact,
1285
1565
  sha1,
1286
1566
  };