aiwcli 0.10.3 → 0.11.1

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 (191) hide show
  1. package/bin/run.js +1 -1
  2. package/dist/commands/clear.js +28 -131
  3. package/dist/commands/init/index.js +3 -3
  4. package/dist/lib/gitignore-manager.d.ts +32 -0
  5. package/dist/lib/gitignore-manager.js +141 -2
  6. package/dist/templates/CLAUDE.md +8 -8
  7. package/dist/templates/_shared/.claude/commands/handoff-resume.md +64 -0
  8. package/dist/templates/_shared/.claude/commands/handoff.md +16 -10
  9. package/dist/templates/_shared/.claude/settings.json +7 -7
  10. package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -0
  11. package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -0
  12. package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -0
  13. package/dist/templates/_shared/hooks-ts/file-suggestion.ts +130 -0
  14. package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -0
  15. package/dist/templates/_shared/hooks-ts/session_end.ts +107 -0
  16. package/dist/templates/_shared/hooks-ts/session_start.ts +144 -0
  17. package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -0
  18. package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -0
  19. package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +83 -0
  20. package/dist/templates/_shared/lib-ts/CLAUDE.md +318 -0
  21. package/dist/templates/_shared/lib-ts/base/atomic-write.ts +12 -12
  22. package/dist/templates/_shared/lib-ts/base/constants.ts +22 -15
  23. package/dist/templates/_shared/lib-ts/base/git-state.ts +1 -1
  24. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
  25. package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
  26. package/dist/templates/_shared/lib-ts/base/logger.ts +15 -2
  27. package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
  28. package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
  29. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +142 -0
  30. package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
  31. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
  32. package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
  33. package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
  34. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +43 -23
  35. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
  36. package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
  37. package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +158 -0
  38. package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
  39. package/dist/templates/_shared/lib-ts/types.ts +68 -55
  40. package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
  41. package/dist/templates/_shared/scripts/resume_handoff.ts +345 -0
  42. package/dist/templates/_shared/scripts/save_handoff.ts +3 -3
  43. package/dist/templates/_shared/scripts/status_line.ts +687 -0
  44. package/dist/templates/cc-native/.claude/settings.json +175 -185
  45. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
  46. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
  47. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
  48. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
  49. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +1027 -0
  50. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
  51. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +156 -0
  52. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +792 -0
  53. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
  54. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +144 -0
  55. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
  56. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
  57. package/dist/templates/cc-native/_cc-native/lib-ts/corroboration.ts +115 -0
  58. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
  59. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +120 -0
  60. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +168 -0
  61. package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
  62. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +250 -0
  63. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +275 -0
  64. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
  65. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +107 -0
  66. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
  67. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
  68. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +240 -0
  69. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
  70. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +385 -0
  71. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
  72. package/dist/templates/cc-native/_cc-native/plan-review.config.json +14 -1
  73. package/oclif.manifest.json +1 -1
  74. package/package.json +2 -2
  75. package/dist/templates/_shared/hooks/__init__.py +0 -16
  76. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  77. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  78. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  79. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  80. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  81. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  82. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  84. package/dist/templates/_shared/hooks/__pycache__/task_create_atomicity.cpython-313.pyc +0 -0
  85. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  86. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  87. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  88. package/dist/templates/_shared/hooks/archive_plan.py +0 -177
  89. package/dist/templates/_shared/hooks/context_monitor.py +0 -270
  90. package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
  91. package/dist/templates/_shared/hooks/pre_compact.py +0 -104
  92. package/dist/templates/_shared/hooks/session_end.py +0 -173
  93. package/dist/templates/_shared/hooks/session_start.py +0 -206
  94. package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
  95. package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
  96. package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
  97. package/dist/templates/_shared/lib/__init__.py +0 -1
  98. package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  99. package/dist/templates/_shared/lib/base/__init__.py +0 -65
  100. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
  102. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  103. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  104. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/__pycache__/stop_words.cpython-313.pyc +0 -0
  107. package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
  108. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  109. package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
  110. package/dist/templates/_shared/lib/base/constants.py +0 -358
  111. package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
  112. package/dist/templates/_shared/lib/base/inference.py +0 -307
  113. package/dist/templates/_shared/lib/base/logger.py +0 -305
  114. package/dist/templates/_shared/lib/base/stop_words.py +0 -221
  115. package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
  116. package/dist/templates/_shared/lib/base/utils.py +0 -263
  117. package/dist/templates/_shared/lib/context/__init__.py +0 -102
  118. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  119. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  120. package/dist/templates/_shared/lib/context/__pycache__/context_extractor.cpython-313.pyc +0 -0
  121. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  122. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  123. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  124. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  125. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  126. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  127. package/dist/templates/_shared/lib/context/__pycache__/plan_archive.cpython-313.pyc +0 -0
  128. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  129. package/dist/templates/_shared/lib/context/__pycache__/task_sync.cpython-313.pyc +0 -0
  130. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  131. package/dist/templates/_shared/lib/context/context_formatter.py +0 -317
  132. package/dist/templates/_shared/lib/context/context_selector.py +0 -508
  133. package/dist/templates/_shared/lib/context/context_store.py +0 -653
  134. package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
  135. package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
  136. package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
  137. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  138. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  139. package/dist/templates/_shared/lib/handoff/document_generator.py +0 -278
  140. package/dist/templates/_shared/lib/templates/README.md +0 -206
  141. package/dist/templates/_shared/lib/templates/__init__.py +0 -36
  142. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  143. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  144. package/dist/templates/_shared/lib/templates/__pycache__/persona_questions.cpython-313.pyc +0 -0
  145. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  146. package/dist/templates/_shared/lib/templates/formatters.py +0 -146
  147. package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
  148. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  149. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  150. package/dist/templates/_shared/scripts/save_handoff.py +0 -357
  151. package/dist/templates/_shared/scripts/status_line.py +0 -716
  152. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
  153. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
  154. package/dist/templates/cc-native/MIGRATION.md +0 -86
  155. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  156. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  157. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  158. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  159. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  160. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  161. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +0 -130
  162. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
  163. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
  164. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
  165. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
  166. package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
  167. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  171. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  172. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  173. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  174. package/dist/templates/cc-native/_cc-native/lib/constants.py +0 -45
  175. package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
  176. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
  177. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
  178. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  179. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  180. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  181. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +0 -215
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
  185. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
  186. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
  187. package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
  188. package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
  189. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  190. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
  191. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +0 -134
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Review artifact writing and formatting.
3
+ * See cc-native-plan-review-spec.md §4.3
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { atomicWrite } from "../../_shared/lib-ts/base/atomic-write.js";
9
+ import { logDebug, logWarn, logError } from "../../_shared/lib-ts/base/logger.js";
10
+ import { nowIso } from "../../_shared/lib-ts/base/utils.js";
11
+ import { sanitizeFilename } from "../../_shared/lib-ts/base/constants.js";
12
+ import { ENABLE_ROBUST_PLAN_WRITES } from "./constants.js";
13
+ import type {
14
+ CombinedReviewResult,
15
+ ReviewerResult,
16
+ DisplaySettings,
17
+ CorroborationResult,
18
+ } from "./types.js";
19
+ import { DEFAULT_DISPLAY } from "./types.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Markdown Formatting
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Format review results as markdown (legacy compat format).
27
+ */
28
+ export function formatReviewMarkdown(
29
+ results: ReviewerResult[],
30
+ overall: string,
31
+ title = "CC-Native Plan Review",
32
+ settings?: Record<string, unknown>,
33
+ ): string {
34
+ const display = resolveDisplay(settings);
35
+
36
+ const lines: string[] = [];
37
+ lines.push(`# ${title}\n`);
38
+ lines.push(`**Overall verdict:** \`${overall.toUpperCase()}\`\n`);
39
+
40
+ for (const r of results) {
41
+ const displayName = r.name === r.name.toLowerCase() ? titleCase(r.name) : r.name;
42
+ lines.push(`## ${displayName}\n`);
43
+ lines.push(`- ok: \`${r.ok}\``);
44
+ lines.push(`- verdict: \`${r.verdict}\``);
45
+
46
+ if (r.data && Object.keys(r.data).length > 0) {
47
+ const summary = String(r.data.summary ?? "").trim();
48
+ if (r.data.summary_source === "default") {
49
+ lines.push(`- summary: ⚠️ ${summary} *(reviewer did not return summary)*`);
50
+ } else {
51
+ lines.push(`- summary: ${summary}`);
52
+ }
53
+ appendReviewDetails(lines, r.data, display);
54
+ } else {
55
+ lines.push(`- note: ${r.err || "no structured output"}`);
56
+ }
57
+ lines.push("");
58
+ }
59
+
60
+ return lines.join("\n").trim() + "\n";
61
+ }
62
+
63
+ /**
64
+ * Format combined review result as a single markdown document.
65
+ */
66
+ export function formatCombinedMarkdown(
67
+ result: CombinedReviewResult,
68
+ settings?: Record<string, unknown>,
69
+ corroboration?: CorroborationResult,
70
+ ): string {
71
+ const display = resolveDisplay(settings);
72
+
73
+ const lines: string[] = [];
74
+ lines.push("# CC-Native Plan Review\n");
75
+ lines.push(`**Overall Verdict:** \`${result.overall_verdict.toUpperCase()}\``);
76
+ lines.push(`**Plan Hash:** \`${result.plan_hash}\`\n`);
77
+
78
+ // Corroboration summary
79
+ if (corroboration) {
80
+ lines.push("## Corroboration Analysis\n");
81
+ if (corroboration.blocking.length > 0) {
82
+ lines.push("### Blocking Dimensions\n");
83
+ for (const group of corroboration.blocking) {
84
+ lines.push(`- **${group.dimension}**: ${group.issues.length} issues from ${group.agentCount} agents (threshold: >${group.threshold})`);
85
+ }
86
+ lines.push("");
87
+ }
88
+ if (corroboration.solo.length > 0) {
89
+ lines.push("### Solo Dimensions (informational)\n");
90
+ for (const s of corroboration.solo) {
91
+ lines.push(`- **${s.dimension}**: ${s.issues.length} issues from ${s.agentCount} agents (threshold: >${s.threshold}, not exceeded)`);
92
+ }
93
+ lines.push("");
94
+ }
95
+ if (corroboration.unclassified.length > 0) {
96
+ lines.push(`> ${corroboration.unclassified.length} issue(s) without dimension classification (unclassified, not blocking)\n`);
97
+ }
98
+ }
99
+
100
+ lines.push("---\n");
101
+
102
+ // CLI Reviewers section
103
+ if (Object.keys(result.cli_reviewers).length > 0) {
104
+ lines.push("## CLI Reviewers\n");
105
+ for (const [name, r] of Object.entries(result.cli_reviewers)) {
106
+ lines.push(`### ${titleCase(name)}\n`);
107
+ lines.push(`- verdict: \`${r.verdict}\``);
108
+ if (r.data && Object.keys(r.data).length > 0) {
109
+ appendSummaryLine(lines, r.data);
110
+ appendReviewDetails(lines, r.data, display);
111
+ } else if (r.err) {
112
+ lines.push(`- error: ${r.err}`);
113
+ }
114
+ lines.push("");
115
+ }
116
+ }
117
+
118
+ // Orchestration section
119
+ if (result.orchestration) {
120
+ lines.push("---\n");
121
+ lines.push("## Orchestration\n");
122
+ lines.push(`- **Complexity:** \`${result.orchestration.complexity}\``);
123
+ lines.push(`- **Category:** \`${result.orchestration.category}\``);
124
+ const agentsStr =
125
+ result.orchestration.selected_agents.length > 0
126
+ ? result.orchestration.selected_agents.join(", ")
127
+ : "None";
128
+ lines.push(`- **Agents Selected:** ${agentsStr}`);
129
+ lines.push(`- **Reasoning:** ${result.orchestration.reasoning}`);
130
+ if (result.orchestration.skip_reason) {
131
+ lines.push(`- **Skip Reason:** ${result.orchestration.skip_reason}`);
132
+ }
133
+ if (result.orchestration.error) {
134
+ lines.push(`- **Error:** ${result.orchestration.error}`);
135
+ }
136
+ lines.push("");
137
+ }
138
+
139
+ // Agent Reviews section
140
+ if (Object.keys(result.agents).length > 0) {
141
+ lines.push("---\n");
142
+ lines.push("## Agent Reviews\n");
143
+ for (const [name, r] of Object.entries(result.agents)) {
144
+ lines.push(`### ${name}\n`);
145
+ lines.push(`- verdict: \`${r.verdict}\``);
146
+ if (r.data && Object.keys(r.data).length > 0) {
147
+ appendSummaryLine(lines, r.data);
148
+ appendReviewDetails(lines, r.data, display);
149
+ } else if (r.err) {
150
+ lines.push(`- error: ${r.err}`);
151
+ }
152
+ lines.push("");
153
+ }
154
+ }
155
+
156
+ return lines.join("\n").trim() + "\n";
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Inline Summaries
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Build compact inline summary of HIGH-severity findings for additionalContext.
165
+ * When corroboration data is provided, annotates issues as [CORROBORATED] or [perspective].
166
+ */
167
+ export function buildInlineReviewSummary(
168
+ combined: CombinedReviewResult,
169
+ maxIssues = 5,
170
+ maxChars = 800,
171
+ corroboration?: CorroborationResult,
172
+ ): string {
173
+ const allReviewers = [
174
+ ...Object.values(combined.cli_reviewers),
175
+ ...Object.values(combined.agents),
176
+ ];
177
+
178
+ // Build set of blocking dimensions for annotation
179
+ const blockingDims = new Set(
180
+ corroboration?.blocking.map(g => g.dimension) ?? [],
181
+ );
182
+
183
+ const highIssues: Array<Record<string, unknown>> = [];
184
+ for (const r of allReviewers) {
185
+ if (!r.data) continue;
186
+ const issues = r.data.issues as Array<Record<string, unknown>> | undefined;
187
+ if (!issues) continue;
188
+ for (const issue of issues) {
189
+ if (issue.severity === "high") {
190
+ highIssues.push({ ...issue, _reviewer: r.name });
191
+ }
192
+ }
193
+ }
194
+
195
+ const parts: string[] = [];
196
+
197
+ // Overall verdict line
198
+ const issueCount = highIssues.length;
199
+ const countSuffix =
200
+ issueCount > 0
201
+ ? ` (${issueCount} high-severity issue${issueCount !== 1 ? "s" : ""})`
202
+ : "";
203
+ parts.push(`**Plan Review: ${combined.overall_verdict.toUpperCase()}**${countSuffix}`);
204
+
205
+ // Corroboration summary if available
206
+ if (corroboration) {
207
+ const blockCount = corroboration.blocking.length;
208
+ const soloCount = corroboration.solo.length;
209
+ if (blockCount > 0) {
210
+ parts.push(`**Corroboration:** ${blockCount} dimension${blockCount !== 1 ? "s" : ""} exceeded threshold (blocking), ${soloCount} solo (informational)`);
211
+ } else {
212
+ parts.push(`**Corroboration:** No dimensions exceeded threshold — all ${soloCount} solo (informational)`);
213
+ }
214
+ }
215
+
216
+ // High-severity issue bullets
217
+ for (const issue of highIssues.slice(0, maxIssues)) {
218
+ const cat = (issue.category as string) ?? "general";
219
+ const text = (issue.issue as string) ?? "";
220
+ const fix = (issue.suggested_fix as string) ?? "";
221
+ const reviewer = (issue._reviewer as string) ?? "unknown";
222
+ const dim = issue.dimension as string | undefined;
223
+
224
+ let annotation = "";
225
+ if (corroboration && dim) {
226
+ const group = corroboration.blocking.find(g => g.dimension === dim);
227
+ if (group) {
228
+ annotation = ` [CORROBORATED — ${group.issues.length} issues from ${group.agentCount} agents exceeds threshold ${group.threshold}]`;
229
+ } else {
230
+ annotation = " [perspective]";
231
+ }
232
+ }
233
+
234
+ let line = `- [${cat}] ${text}`;
235
+ if (fix) line += ` \u2192 ${fix}`;
236
+ line += ` (${reviewer})${annotation}`;
237
+ parts.push(line);
238
+ }
239
+
240
+ const remaining = highIssues.length - maxIssues;
241
+ if (remaining > 0) {
242
+ parts.push(` ...and ${remaining} more`);
243
+ }
244
+
245
+ let result = parts.join("\n");
246
+ if (result.length > maxChars) {
247
+ result = result.slice(0, maxChars - 3) + "...";
248
+ }
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * Extract top issues as compact text for permissionDecisionReason.
254
+ */
255
+ export function extractTopIssuesText(
256
+ combined: CombinedReviewResult,
257
+ maxCount = 3,
258
+ severity = "high",
259
+ ): string {
260
+ const allReviewers = [
261
+ ...Object.values(combined.cli_reviewers),
262
+ ...Object.values(combined.agents),
263
+ ];
264
+
265
+ const issues: string[] = [];
266
+ for (const r of allReviewers) {
267
+ if (!r.data) continue;
268
+ const issueList = r.data.issues as Array<Record<string, unknown>> | undefined;
269
+ if (!issueList) continue;
270
+ for (const issue of issueList) {
271
+ if (issue.severity === severity) {
272
+ const text = String(issue.issue ?? "").trim();
273
+ if (text) {
274
+ issues.push(`[${r.name}] ${text}`);
275
+ break; // first high issue per reviewer only
276
+ }
277
+ }
278
+ }
279
+ if (issues.length >= maxCount) break;
280
+ }
281
+
282
+ if (issues.length === 0) return "Review found critical issues";
283
+ return issues.join("; ");
284
+ }
285
+
286
+ /**
287
+ * Build markdown document containing high-severity issues.
288
+ * When corroboration data is provided, only includes corroborated (blocking) issues.
289
+ */
290
+ export function buildHighIssuesDocument(
291
+ combined: CombinedReviewResult,
292
+ corroboration?: CorroborationResult,
293
+ ): string {
294
+ // When corroboration is available, only show blocking groups
295
+ if (corroboration && corroboration.blocking.length > 0) {
296
+ const lines = ["# Corroborated High-Severity Issues\n"];
297
+ lines.push("> Only issues from dimensions where the total count exceeded the proportional threshold are shown.\n");
298
+
299
+ for (const group of corroboration.blocking) {
300
+ lines.push(`## ${group.dimension} (${group.issues.length} issues from ${group.agentCount} agents, threshold: ${group.threshold})\n`);
301
+ for (const { agent, issue } of group.issues) {
302
+ const cat = issue.category ?? "general";
303
+ const text = String(issue.issue ?? "").trim();
304
+ const fix = String(issue.suggested_fix ?? "").trim();
305
+ lines.push(`- **[${cat}]** ${text} *(${agent})*`);
306
+ if (fix) lines.push(` - Fix: ${fix}`);
307
+ }
308
+ lines.push("");
309
+ }
310
+
311
+ if (corroboration.solo.length > 0) {
312
+ lines.push("---\n");
313
+ lines.push(`> ${corroboration.solo.length} dimension${corroboration.solo.length !== 1 ? "s" : ""} had issues below threshold (not blocking): ${corroboration.solo.map(s => `${s.dimension} (${s.issues.length}/${s.threshold})`).join(", ")}\n`);
314
+ }
315
+
316
+ return lines.join("\n");
317
+ }
318
+
319
+ // Fallback: no corroboration data — show all high-severity issues
320
+ const lines = ["# High-Severity Issues\n"];
321
+ const allReviewers = [
322
+ ...Object.values(combined.cli_reviewers),
323
+ ...Object.values(combined.agents),
324
+ ];
325
+
326
+ let foundAny = false;
327
+ for (const r of allReviewers) {
328
+ if (!r.data) continue;
329
+ const issues = r.data.issues as Array<Record<string, unknown>> | undefined;
330
+ if (!issues) continue;
331
+
332
+ const highIssues = issues.filter((i) => i.severity === "high");
333
+ if (highIssues.length === 0) continue;
334
+
335
+ foundAny = true;
336
+ lines.push(`## ${r.name} (${r.verdict})\n`);
337
+ for (const issue of highIssues) {
338
+ const cat = (issue.category as string) ?? "general";
339
+ const text = String(issue.issue ?? "").trim();
340
+ const fix = String(issue.suggested_fix ?? "").trim();
341
+ lines.push(`- **[${cat}]** ${text}`);
342
+ if (fix) lines.push(` - Fix: ${fix}`);
343
+ }
344
+ lines.push("");
345
+ }
346
+
347
+ if (!foundAny) {
348
+ lines.push("No high-severity issues found.\n");
349
+ }
350
+
351
+ return lines.join("\n");
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Index Generation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Generate index.md for a review folder.
360
+ */
361
+ export function generateReviewIndex(
362
+ result: CombinedReviewResult,
363
+ iteration?: number,
364
+ _settings?: Record<string, unknown>,
365
+ ): string {
366
+ const now = new Date();
367
+
368
+ const lines = [
369
+ "---",
370
+ `type: review`,
371
+ `plan_hash: ${result.plan_hash}`,
372
+ `overall_verdict: ${result.overall_verdict}`,
373
+ `created_at: ${result.timestamp}`,
374
+ ];
375
+ if (iteration) lines.push(`iteration: ${iteration}`);
376
+ lines.push(
377
+ "---",
378
+ "",
379
+ `# Plan Review - ${formatDate(now)}`,
380
+ "",
381
+ `**Overall Verdict:** \`${result.overall_verdict.toUpperCase()}\``,
382
+ );
383
+
384
+ if (iteration) lines.push(`**Iteration:** ${iteration}`);
385
+ lines.push(`**Plan Hash:** \`${result.plan_hash}\``, "");
386
+
387
+ // Summary from orchestrator
388
+ if (result.orchestration) {
389
+ lines.push(
390
+ "## Analysis",
391
+ `- **Complexity:** \`${result.orchestration.complexity}\``,
392
+ `- **Category:** \`${result.orchestration.category}\``,
393
+ `- **Reasoning:** ${result.orchestration.reasoning}`,
394
+ "",
395
+ );
396
+ }
397
+
398
+ // Navigation table
399
+ lines.push(
400
+ "## Review Files",
401
+ "",
402
+ "| File | Description |",
403
+ "|------|-------------|",
404
+ "| [review.md](./review.md) | Full review details |",
405
+ "| [review.json](./review.json) | Structured review data |",
406
+ "| [plan.md](./plan.md) | Plan snapshot at review time |",
407
+ );
408
+
409
+ for (const name of Object.keys(result.cli_reviewers)) {
410
+ lines.push(
411
+ `| [${name}.json](./reviewer-output/${name}.json) | ${titleCase(name)} reviewer output |`,
412
+ );
413
+ }
414
+ for (const name of Object.keys(result.agents)) {
415
+ const safeName = sanitizeFilename(name);
416
+ lines.push(
417
+ `| [${safeName}.json](./reviewer-output/${safeName}.json) | ${name} agent output |`,
418
+ );
419
+ }
420
+
421
+ lines.push(
422
+ "",
423
+ "## Verdicts Summary",
424
+ "",
425
+ "| Reviewer | Verdict |",
426
+ "|----------|---------|",
427
+ );
428
+
429
+ for (const [name, r] of Object.entries(result.cli_reviewers)) {
430
+ lines.push(`| ${titleCase(name)} | \`${r.verdict}\` |`);
431
+ }
432
+ for (const [name, r] of Object.entries(result.agents)) {
433
+ lines.push(`| ${name} | \`${r.verdict}\` |`);
434
+ }
435
+ lines.push("");
436
+
437
+ return lines.join("\n");
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // JSON Output
442
+ // ---------------------------------------------------------------------------
443
+
444
+ /**
445
+ * Build combined JSON output structure.
446
+ */
447
+ export function buildCombinedJson(
448
+ result: CombinedReviewResult,
449
+ ): Record<string, unknown> {
450
+ const output: Record<string, unknown> = {
451
+ metadata: {
452
+ timestamp: result.timestamp,
453
+ plan_hash: result.plan_hash,
454
+ },
455
+ overall: {
456
+ verdict: result.overall_verdict,
457
+ },
458
+ };
459
+
460
+ // CLI reviewers
461
+ if (Object.keys(result.cli_reviewers).length > 0) {
462
+ const cliReviewers: Record<string, unknown> = {};
463
+ output.cliReviewers = cliReviewers;
464
+ for (const [name, r] of Object.entries(result.cli_reviewers)) {
465
+ cliReviewers[name] = {
466
+ verdict: r.verdict,
467
+ summary: r.data?.summary ?? null,
468
+ summarySource: r.data?.summary_source ?? null,
469
+ issues: r.data
470
+ ? ((r.data.issues as Array<Record<string, unknown>>) ?? []).filter(
471
+ (i) => i.severity !== "low",
472
+ )
473
+ : [],
474
+ ok: r.ok,
475
+ error: r.err || null,
476
+ };
477
+ }
478
+ }
479
+
480
+ // Orchestration
481
+ if (result.orchestration) {
482
+ output.orchestration = {
483
+ complexity: result.orchestration.complexity,
484
+ category: result.orchestration.category,
485
+ selectedAgents: result.orchestration.selected_agents,
486
+ reasoning: result.orchestration.reasoning,
487
+ skipReason: result.orchestration.skip_reason ?? null,
488
+ error: result.orchestration.error ?? null,
489
+ };
490
+ }
491
+
492
+ // Agents
493
+ if (Object.keys(result.agents).length > 0) {
494
+ const agents: Record<string, unknown> = {};
495
+ output.agents = agents;
496
+ for (const [name, r] of Object.entries(result.agents)) {
497
+ agents[name] = {
498
+ verdict: r.verdict,
499
+ summary: r.data?.summary ?? null,
500
+ summarySource: r.data?.summary_source ?? null,
501
+ issues: r.data
502
+ ? ((r.data.issues as Array<Record<string, unknown>>) ?? []).filter(
503
+ (i) => i.severity !== "low",
504
+ )
505
+ : [],
506
+ missing_sections: r.data?.missing_sections ?? [],
507
+ questions: r.data?.questions ?? [],
508
+ ok: r.ok,
509
+ error: r.err || null,
510
+ };
511
+ }
512
+ }
513
+
514
+ return output;
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // Artifact Writing
519
+ // ---------------------------------------------------------------------------
520
+
521
+ /**
522
+ * Write combined review artifacts to context reviews folder.
523
+ * Uses atomic writes for critical files when ENABLE_ROBUST_PLAN_WRITES is true.
524
+ */
525
+ export function writeCombinedArtifacts(
526
+ base: string,
527
+ plan: string,
528
+ result: CombinedReviewResult,
529
+ payload: Record<string, unknown>,
530
+ settings?: Record<string, unknown>,
531
+ contextReviewsDir?: string,
532
+ reviewFolder?: string,
533
+ iteration?: number,
534
+ corroboration?: CorroborationResult,
535
+ ): string {
536
+ const outDir = reviewFolder ?? contextReviewsDir;
537
+ if (!outDir) {
538
+ throw new Error("Either contextReviewsDir or reviewFolder is required");
539
+ }
540
+
541
+ logDebug("utils", `Using review folder: ${outDir}`);
542
+
543
+ // Create directory
544
+ try {
545
+ fs.mkdirSync(outDir, { recursive: true });
546
+ } catch (e: unknown) {
547
+ logError("utils", `Cannot create directory ${outDir}: ${e}`);
548
+ throw e;
549
+ }
550
+
551
+ // JSON write
552
+ const jsonPath = path.join(outDir, "review.json");
553
+ const jsonData = buildCombinedJson(result);
554
+ writeFile(jsonPath, JSON.stringify(jsonData, null, 2));
555
+
556
+ // Markdown write
557
+ const mdPath = path.join(outDir, "review.md");
558
+ const mdContent = formatCombinedMarkdown(result, settings, corroboration);
559
+ writeFile(mdPath, mdContent);
560
+
561
+ // Individual reviewer writes (non-critical) — in reviewer-output/ subfolder
562
+ const reviewerOutputDir = path.join(outDir, "reviewer-output");
563
+ try {
564
+ fs.mkdirSync(reviewerOutputDir, { recursive: true });
565
+ } catch {
566
+ // Best-effort — non-critical
567
+ }
568
+ for (const [name, r] of Object.entries(result.cli_reviewers)) {
569
+ if (r.data) {
570
+ writeFileNonCritical(
571
+ path.join(reviewerOutputDir, `${name}.json`),
572
+ JSON.stringify(r.data, null, 2),
573
+ );
574
+ }
575
+ }
576
+ for (const [name, r] of Object.entries(result.agents)) {
577
+ if (r.data) {
578
+ writeFileNonCritical(
579
+ path.join(reviewerOutputDir, `${sanitizeFilename(name)}.json`),
580
+ JSON.stringify(r.data, null, 2),
581
+ );
582
+ }
583
+ }
584
+
585
+ // Generate index.md for folder-based reviews
586
+ if (reviewFolder) {
587
+ const indexContent = generateReviewIndex(result, iteration, settings);
588
+ writeFileNonCritical(path.join(outDir, "index.md"), indexContent);
589
+ return path.join(outDir, "index.md");
590
+ }
591
+
592
+ return mdPath;
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Helpers
597
+ // ---------------------------------------------------------------------------
598
+
599
+ function resolveDisplay(
600
+ settings?: Record<string, unknown>,
601
+ ): DisplaySettings {
602
+ if (!settings) return { ...DEFAULT_DISPLAY };
603
+ const display = (settings.display as Partial<DisplaySettings>) ?? {};
604
+ return { ...DEFAULT_DISPLAY, ...display };
605
+ }
606
+
607
+ function appendSummaryLine(lines: string[], data: Record<string, unknown>): void {
608
+ const summary = String(data.summary ?? "").trim();
609
+ if (data.summary_source === "default") {
610
+ lines.push(`- summary: ⚠️ ${summary} *(reviewer did not return summary)*`);
611
+ } else {
612
+ lines.push(`- summary: ${summary}`);
613
+ }
614
+ }
615
+
616
+ function appendReviewDetails(
617
+ lines: string[],
618
+ data: Record<string, unknown>,
619
+ display: DisplaySettings,
620
+ ): void {
621
+ const issues = ((data.issues as Array<Record<string, unknown>>) ?? []).filter(
622
+ (i) => i.severity !== "low",
623
+ );
624
+ if (issues.length > 0) {
625
+ lines.push("\n**Issues:**");
626
+ for (const it of issues.slice(0, display.maxIssues)) {
627
+ const sev = (it.severity as string) ?? "medium";
628
+ const cat = (it.category as string) ?? "general";
629
+ const issue = (it.issue as string) ?? "";
630
+ const fix = (it.suggested_fix as string) ?? "";
631
+ lines.push(`- **[${sev}] ${cat}**: ${issue}`);
632
+ if (fix) lines.push(` - fix: ${fix}`);
633
+ }
634
+ }
635
+
636
+ const missing = (data.missing_sections as string[]) ?? [];
637
+ if (missing.length > 0) {
638
+ lines.push("\n**Missing Sections:**");
639
+ for (const m of missing.slice(0, display.maxMissingSections)) {
640
+ lines.push(`- ${m}`);
641
+ }
642
+ }
643
+
644
+ const qs = (data.questions as string[]) ?? [];
645
+ if (qs.length > 0) {
646
+ lines.push("\n**Questions:**");
647
+ for (const q of qs.slice(0, display.maxQuestions)) {
648
+ lines.push(`- ${q}`);
649
+ }
650
+ }
651
+ }
652
+
653
+ function writeFile(filePath: string, content: string): void {
654
+ try {
655
+ if (ENABLE_ROBUST_PLAN_WRITES) {
656
+ const [success, error] = atomicWrite(filePath, content);
657
+ if (!success) throw new Error(`Atomic write failed: ${error}`);
658
+ } else {
659
+ fs.writeFileSync(filePath, content, "utf-8");
660
+ }
661
+ } catch (e: unknown) {
662
+ logError("utils", `Failed to write ${path.basename(filePath)}: ${e}`);
663
+ throw e;
664
+ }
665
+ }
666
+
667
+ function writeFileNonCritical(filePath: string, content: string): void {
668
+ try {
669
+ if (ENABLE_ROBUST_PLAN_WRITES) {
670
+ const [success, error] = atomicWrite(filePath, content);
671
+ if (!success) {
672
+ logWarn("utils", `Failed to write ${path.basename(filePath)}: ${error}`);
673
+ }
674
+ } else {
675
+ fs.writeFileSync(filePath, content, "utf-8");
676
+ }
677
+ } catch (e: unknown) {
678
+ logWarn("utils", `Failed to write ${path.basename(filePath)}: ${e}`);
679
+ }
680
+ }
681
+
682
+ function titleCase(s: string): string {
683
+ return s.charAt(0).toUpperCase() + s.slice(1);
684
+ }
685
+
686
+ function formatDate(d: Date): string {
687
+ const year = d.getFullYear();
688
+ const month = String(d.getMonth() + 1).padStart(2, "0");
689
+ const day = String(d.getDate()).padStart(2, "0");
690
+ const hours = String(d.getHours()).padStart(2, "0");
691
+ const minutes = String(d.getMinutes()).padStart(2, "0");
692
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Review Tracker
697
+ // ---------------------------------------------------------------------------
698
+
699
+ export interface ReviewTrackerEntry {
700
+ iteration: number;
701
+ timestamp: string;
702
+ planHash: string;
703
+ verdict: string;
704
+ decision: string;
705
+ score: number;
706
+ topIssues: string[];
707
+ reviewFolder: string;
708
+ }
709
+
710
+ /**
711
+ * Build or update the review-tracker.md in the cc-native reviews directory.
712
+ * This file provides a human-readable summary of all review iterations,
713
+ * making it easy to see whether feedback was acted on.
714
+ */
715
+ export function writeReviewTracker(
716
+ ccNativeReviewsDir: string,
717
+ entry: ReviewTrackerEntry,
718
+ ): void {
719
+ const trackerPath = path.join(ccNativeReviewsDir, "review-tracker.md");
720
+
721
+ // Read existing tracker entries if present
722
+ let existingContent = "";
723
+ try {
724
+ if (fs.existsSync(trackerPath)) {
725
+ existingContent = fs.readFileSync(trackerPath, "utf-8");
726
+ }
727
+ } catch {
728
+ // Fresh start
729
+ }
730
+
731
+ // Parse existing entries to detect plan changes
732
+ const previousHashes = extractPreviousHashes(existingContent);
733
+ const hashChanged = previousHashes.length > 0 &&
734
+ previousHashes[previousHashes.length - 1] !== entry.planHash;
735
+
736
+ // Build the new entry section
737
+ const lines: string[] = [];
738
+ const verdictEmoji = entry.decision === "allow" ? "\u2705" : "\u274c";
739
+ const changeNote = previousHashes.length > 0
740
+ ? (hashChanged ? "\u2705 Plan was revised (hash changed)" : "\u26a0\ufe0f Plan unchanged since last review")
741
+ : "Initial review";
742
+
743
+ lines.push(`## Iteration ${entry.iteration} \u2014 ${entry.timestamp} \u2014 ${verdictEmoji} ${entry.verdict.toUpperCase()}`);
744
+ lines.push("");
745
+ lines.push(`- **Decision:** ${entry.decision}`);
746
+ lines.push(`- **Score:** ${entry.score.toFixed(2)}`);
747
+ lines.push(`- **Plan hash:** \`${entry.planHash}\``);
748
+ lines.push(`- **Status:** ${changeNote}`);
749
+ lines.push(`- **Full review:** [\`${path.basename(entry.reviewFolder)}/\`](${path.basename(entry.reviewFolder)}/index.md)`);
750
+
751
+ if (entry.topIssues.length > 0) {
752
+ lines.push("");
753
+ lines.push("**Top issues:**");
754
+ for (const issue of entry.topIssues) {
755
+ lines.push(`- ${issue}`);
756
+ }
757
+ }
758
+ lines.push("");
759
+
760
+ // Build full file
761
+ let output: string;
762
+ if (!existingContent || !existingContent.includes("# Plan Review Tracker")) {
763
+ // New file
764
+ output = [
765
+ "# Plan Review Tracker",
766
+ "",
767
+ "> Auto-generated by plan review hook. Shows review lifecycle across iterations.",
768
+ "> Check `plan.md` in each iteration folder to diff plan changes.",
769
+ "",
770
+ ...lines,
771
+ ].join("\n");
772
+ } else {
773
+ // Append to existing
774
+ output = existingContent.trimEnd() + "\n\n" + lines.join("\n");
775
+ }
776
+
777
+ try {
778
+ fs.writeFileSync(trackerPath, output, "utf-8");
779
+ } catch (e) {
780
+ logWarn("artifacts", `Failed to write review tracker: ${e}`);
781
+ }
782
+ }
783
+
784
+ function extractPreviousHashes(content: string): string[] {
785
+ const hashes: string[] = [];
786
+ const regex = /\*\*Plan hash:\*\* `([a-f0-9]+)`/g;
787
+ let match: RegExpExecArray | null;
788
+ while ((match = regex.exec(content)) !== null) {
789
+ hashes.push(match[1]!);
790
+ }
791
+ return hashes;
792
+ }