@navels/neal 0.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,391 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { writeTextAtomic } from './atomic-write.js';
4
+ import { getChangedFilesForRange } from './git.js';
5
+ import { renderInteractiveBlockedRecoveryHistoryLines } from './recovery-artifacts.js';
6
+ import { renderRunMetricsMarkdown, summarizeRunMetrics } from './run-metrics.js';
7
+ import { getCurrentScopeLabel, getParentScopeLabel } from './scopes.js';
8
+ import { getDerivedPlanView, getFinalCompletionView } from './state-views.js';
9
+ import { extractVerificationCommandResults, summarizeVerificationCommandResults, } from './verification-events.js';
10
+ function getEventsPath(runDir) {
11
+ return join(runDir, 'events.ndjson');
12
+ }
13
+ function getCurrentRetrospectivePath(runDir) {
14
+ return join(runDir, 'RETROSPECTIVE.md');
15
+ }
16
+ function getArchivedRetrospectivePath(state, kind) {
17
+ const scopeLabel = getCurrentScopeLabel(state);
18
+ if (kind === 'scope_accepted') {
19
+ const suffix = state.finalCommit ? `-${state.finalCommit}` : '';
20
+ return join(state.runDir, `RETROSPECTIVE-scope-${scopeLabel}${suffix}.md`);
21
+ }
22
+ if (kind === 'blocked') {
23
+ return join(state.runDir, `RETROSPECTIVE-blocked-scope-${scopeLabel}.md`);
24
+ }
25
+ if (kind === 'failed') {
26
+ return join(state.runDir, `RETROSPECTIVE-failed-scope-${scopeLabel}.md`);
27
+ }
28
+ const suffix = state.finalCommit ? `-${state.finalCommit}` : '';
29
+ return join(state.runDir, `RETROSPECTIVE-final${suffix}.md`);
30
+ }
31
+ async function loadRunEvents(runDir) {
32
+ try {
33
+ const content = await readFile(getEventsPath(runDir), 'utf8');
34
+ return content
35
+ .split('\n')
36
+ .filter(Boolean)
37
+ .map((line) => JSON.parse(line));
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ function getScopeEvents(events, scopeNumber) {
44
+ const scopeStartIndexes = events.reduce((indexes, event, index) => {
45
+ if (event.type === 'phase.start' && event.data?.phase === 'coder_scope') {
46
+ indexes.push(index);
47
+ }
48
+ return indexes;
49
+ }, []);
50
+ const startIndex = scopeStartIndexes[scopeNumber - 1] ?? 0;
51
+ const endIndex = scopeStartIndexes[scopeNumber] ?? events.length;
52
+ return events.slice(startIndex, endIndex);
53
+ }
54
+ function countFindingsBySeverity(state) {
55
+ return state.findings.reduce((counts, finding) => {
56
+ counts.total += 1;
57
+ counts[finding.severity] += 1;
58
+ return counts;
59
+ }, { total: 0, blocking: 0, non_blocking: 0 });
60
+ }
61
+ function countDispositions(state) {
62
+ return state.findings.reduce((counts, finding) => {
63
+ if (finding.status === 'fixed' || finding.status === 'rejected' || finding.status === 'deferred') {
64
+ counts[finding.status] += 1;
65
+ }
66
+ return counts;
67
+ }, { fixed: 0, rejected: 0, deferred: 0 });
68
+ }
69
+ function summarizeVerification(events) {
70
+ const verificationResults = extractVerificationCommandResults(events);
71
+ const commands = verificationResults.map((result) => result.command);
72
+ const lintRuns = commands.filter((command) => /\blint\b/.test(command));
73
+ const focusedTests = commands.filter((command) => /\btest:.*\b(unit|integration|acceptance|focused|changed)\b/.test(command));
74
+ const fullSuites = commands.filter((command) => /\btest(:all|:full|:ci)?\s*$/.test(command));
75
+ const lines = [];
76
+ if (lintRuns.length > 0) {
77
+ lines.push(`- Lint commands: ${lintRuns.length}`);
78
+ }
79
+ if (focusedTests.length > 0) {
80
+ lines.push(`- Focused test commands: ${focusedTests.length}`);
81
+ }
82
+ if (fullSuites.length > 0) {
83
+ lines.push(`- Full-suite commands: ${fullSuites.length}`);
84
+ }
85
+ if (verificationResults.length === 0) {
86
+ lines.push('- No verification commands were recorded in the wrapper event log.');
87
+ }
88
+ else {
89
+ lines.push(summarizeVerificationCommandResults(verificationResults));
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ function sentenceFromLines(text) {
94
+ return text
95
+ .split('\n')
96
+ .map((line) => line.replace(/^-+\s*/, '').trim())
97
+ .filter(Boolean)
98
+ .join(' ');
99
+ }
100
+ function pluralize(count, singular, plural = `${singular}s`) {
101
+ return count === 1 ? singular : plural;
102
+ }
103
+ function summarizeReviewLoopForNarrative(state) {
104
+ const findings = countFindingsBySeverity(state);
105
+ const dispositions = countDispositions(state);
106
+ if (findings.total === 0) {
107
+ return `The reviewer completed ${state.rounds.length} ${pluralize(state.rounds.length, 'round')} without recording findings.`;
108
+ }
109
+ const dispositionParts = [
110
+ dispositions.fixed > 0 ? `${dispositions.fixed} fixed` : null,
111
+ dispositions.rejected > 0 ? `${dispositions.rejected} rejected` : null,
112
+ dispositions.deferred > 0 ? `${dispositions.deferred} deferred` : null,
113
+ ].filter(Boolean);
114
+ const dispositionSummary = dispositionParts.length > 0 ? ` The coder dispositions were ${dispositionParts.join(', ')}.` : '';
115
+ return `Across ${state.rounds.length} ${pluralize(state.rounds.length, 'review round')}, the reviewer recorded ${findings.total} ${pluralize(findings.total, 'finding')} (${findings.blocking} blocking, ${findings.non_blocking} non-blocking).${dispositionSummary}`;
116
+ }
117
+ function summarizeWorkForNarrative(state, kind) {
118
+ const planName = basename(state.planDoc);
119
+ const scopeLabel = getCurrentScopeLabel(state);
120
+ const finalSummary = getFinalCompletionView(state)?.summary ?? null;
121
+ if (kind === 'done' && finalSummary?.whatChangedOverall.trim()) {
122
+ return finalSummary.whatChangedOverall.trim();
123
+ }
124
+ if (kind === 'scope_accepted') {
125
+ return `Neal accepted scope ${scopeLabel} for ${planName} and recorded the checkpoint at commit ${state.finalCommit ?? 'n/a'}.`;
126
+ }
127
+ if (kind === 'blocked') {
128
+ return `Neal stopped on scope ${scopeLabel} for ${planName} because the current work needs operator guidance or a narrower recovery path.`;
129
+ }
130
+ if (kind === 'failed') {
131
+ return `Neal failed while working on scope ${scopeLabel} for ${planName}.`;
132
+ }
133
+ const acceptedScopes = state.completedScopes.filter((scope) => scope.result === 'accepted').length;
134
+ return `Neal completed ${acceptedScopes} accepted ${pluralize(acceptedScopes, 'scope')} for ${planName}.`;
135
+ }
136
+ function summarizeOutcomeForNarrative(state, kind) {
137
+ const finalSummary = getFinalCompletionView(state)?.summary ?? null;
138
+ if (kind === 'done' && finalSummary) {
139
+ return finalSummary.planGoalSatisfied
140
+ ? 'The final completion check marked the plan goal as satisfied.'
141
+ : 'The final completion check did not mark the plan goal as fully satisfied.';
142
+ }
143
+ if (kind === 'blocked') {
144
+ const latestBlocker = state.completedScopes.at(-1)?.blocker?.trim() || state.interactiveBlockedRecovery?.blockedReason?.trim();
145
+ return latestBlocker ? `The active blocker is: ${latestBlocker}` : 'The run is blocked and needs follow-up before it can continue.';
146
+ }
147
+ if (kind === 'failed') {
148
+ return 'The run ended in a failed state. Inspect the event log and recent artifacts before resuming.';
149
+ }
150
+ return `The run status is ${state.status}.`;
151
+ }
152
+ function summarizeKnownGapsForNarrative(state) {
153
+ const finalCompletion = getFinalCompletionView(state);
154
+ const gaps = finalCompletion?.summary?.remainingKnownGaps.filter((gap) => gap.trim()) ?? [];
155
+ const missingWork = finalCompletion?.reviewVerdict?.missingWork;
156
+ if (gaps.length > 0) {
157
+ const display = gaps.slice(0, 3).join(' ');
158
+ const omitted = gaps.length > 3 ? ` ${gaps.length - 3} additional gap(s) were omitted from this short retrospective.` : '';
159
+ return `Known remaining gaps: ${display}${omitted}`;
160
+ }
161
+ if (missingWork) {
162
+ return `The final reviewer still wanted follow-up: ${missingWork.summary}`;
163
+ }
164
+ return 'No remaining gaps were recorded in the final completion artifacts.';
165
+ }
166
+ function buildNarrativeRetrospective(args) {
167
+ const { state, kind, outcomeTitle, verificationSummary } = args;
168
+ const paragraphs = [
169
+ `Neal reached "${outcomeTitle}" in ${state.topLevelMode} mode. ${summarizeWorkForNarrative(state, kind)}`,
170
+ `${summarizeOutcomeForNarrative(state, kind)} ${summarizeReviewLoopForNarrative(state)}`,
171
+ `Verification evidence: ${sentenceFromLines(verificationSummary)}`,
172
+ ];
173
+ if (kind === 'done') {
174
+ paragraphs.push(summarizeKnownGapsForNarrative(state));
175
+ }
176
+ const finalReview = getFinalCompletionView(state)?.reviewVerdict ?? null;
177
+ if (finalReview?.summary.trim()) {
178
+ paragraphs.push(`Final reviewer verdict: ${finalReview.summary.trim()}`);
179
+ }
180
+ return paragraphs.join('\n\n');
181
+ }
182
+ function buildAssessment(state, scopeEvents) {
183
+ const findingCounts = countFindingsBySeverity(state);
184
+ const dispositions = countDispositions(state);
185
+ const continuationCount = scopeEvents.filter((event) => event.type === 'advisor.round_continuation' || event.type === 'claude.review_continuation').length;
186
+ const phaseErrors = scopeEvents.filter((event) => event.type === 'phase.error');
187
+ const assessments = [];
188
+ if (findingCounts.blocking > 0 && dispositions.fixed > 0) {
189
+ assessments.push(`- The reviewer added clear value: it surfaced ${findingCounts.blocking} blocking finding(s) and the coder fixed ${dispositions.fixed} before acceptance.`);
190
+ }
191
+ else if (findingCounts.total === 0) {
192
+ assessments.push('- The reviewer did not raise any findings.');
193
+ }
194
+ else if (findingCounts.non_blocking > 0 && findingCounts.blocking === 0) {
195
+ assessments.push(`- The reviewer found only non-blocking issues (${findingCounts.non_blocking}). Review added polish more than risk reduction.`);
196
+ }
197
+ if (state.rounds.length > 1) {
198
+ assessments.push(`- The review loop required ${state.rounds.length} passes. This scope may be slightly too broad or under-specified.`);
199
+ }
200
+ if (state.createdCommits.length > 1) {
201
+ assessments.push(`- The coder created ${state.createdCommits.length} commits before execute finalization. That suggests rework during the scope, which may be acceptable but is worth watching.`);
202
+ }
203
+ if (continuationCount > 0) {
204
+ assessments.push(`- The reviewer needed ${continuationCount} same-session continuation(s) to finish the review. Review prompt scope or tool usage may still be inefficient.`);
205
+ }
206
+ if (phaseErrors.length > 0) {
207
+ assessments.push(`- The wrapper recorded ${phaseErrors.length} phase error event(s) during this checkpoint. Inspect events.ndjson for the exact failure path.`);
208
+ }
209
+ if (assessments.length === 0) {
210
+ assessments.push('- The loop behaved normally and did not expose obvious inefficiencies in this checkpoint.');
211
+ }
212
+ return assessments.join('\n');
213
+ }
214
+ function summarizeFindings(state) {
215
+ if (state.findings.length === 0) {
216
+ return '- No review findings recorded.';
217
+ }
218
+ return state.findings
219
+ .map((finding, index) => {
220
+ const files = finding.files.length > 0 ? finding.files.join(', ') : 'n/a';
221
+ const disposition = finding.coderDisposition ? ` | Coder: ${finding.coderDisposition}` : '';
222
+ return `- ${index + 1}. [${finding.severity}] ${finding.claim} | Files: ${files}${disposition}`;
223
+ })
224
+ .join('\n');
225
+ }
226
+ function summarizeBlocker(state) {
227
+ const latestCompletedScope = state.completedScopes.at(-1) ?? null;
228
+ const persistedBlocker = latestCompletedScope?.blocker?.trim() || null;
229
+ const recovery = state.interactiveBlockedRecovery;
230
+ const recoveryBlocker = recovery?.blockedReason?.trim() || null;
231
+ const advice = recovery?.adjudicatorAdvice ?? null;
232
+ const lines = [];
233
+ if (persistedBlocker) {
234
+ lines.push(`- Final blocker: ${persistedBlocker}`);
235
+ }
236
+ if (recoveryBlocker && recoveryBlocker !== persistedBlocker) {
237
+ lines.push(`- Recovery blocker: ${recoveryBlocker}`);
238
+ }
239
+ if (advice) {
240
+ lines.push(`- Adjudicator triage: ${advice.triageCategory} (recoverable: ${advice.recoverable ? 'yes' : 'no'})`);
241
+ if (advice.resolutionDirective.trim()) {
242
+ lines.push(`- Adjudicator suggested directive: ${advice.resolutionDirective.trim()}`);
243
+ }
244
+ }
245
+ if (lines.length === 0) {
246
+ lines.push('- No blocker summary was captured.');
247
+ }
248
+ return lines.join('\n');
249
+ }
250
+ function summarizeCompletedScopes(state) {
251
+ if (state.completedScopes.length === 0) {
252
+ return '- No completed scopes recorded yet.';
253
+ }
254
+ return state.completedScopes
255
+ .map((scope) => {
256
+ const commit = scope.finalCommit ? ` | Commit: ${scope.finalCommit}` : '';
257
+ const blocker = scope.blocker ? ` | Blocker: ${scope.blocker}` : '';
258
+ const parent = scope.derivedFromParentScope ? ` | Parent: ${scope.derivedFromParentScope}` : '';
259
+ const derivedPlan = scope.replacedByDerivedPlanPath ? ` | Derived plan: ${scope.replacedByDerivedPlanPath}` : '';
260
+ return `- Scope ${scope.number}: ${scope.result} (${scope.marker}) | Review rounds: ${scope.reviewRounds} | Findings: ${scope.findings}${commit}${blocker}${parent}${derivedPlan}`;
261
+ })
262
+ .join('\n');
263
+ }
264
+ function getLatestReviewerSessionHandle(state) {
265
+ return state.reviewerSessionHandle ?? state.rounds.at(-1)?.reviewerSessionHandle ?? null;
266
+ }
267
+ async function summarizeChangedFiles(state) {
268
+ if (!state.baseCommit || !state.finalCommit) {
269
+ return '- Changed files unavailable for this checkpoint.';
270
+ }
271
+ const files = await getChangedFilesForRange(state.cwd, state.baseCommit, state.finalCommit);
272
+ if (files.length === 0) {
273
+ return '- No changed files recorded.';
274
+ }
275
+ const displayFiles = files.slice(0, 12);
276
+ const lines = displayFiles.map((file) => `- ${file}`);
277
+ if (files.length > displayFiles.length) {
278
+ lines.push(`- ...and ${files.length - displayFiles.length} more`);
279
+ }
280
+ return lines.join('\n');
281
+ }
282
+ async function renderRetrospective(state, kind) {
283
+ const events = await loadRunEvents(state.runDir);
284
+ const scopeEvents = state.topLevelMode === 'execute'
285
+ ? getScopeEvents(events, state.currentScopeNumber)
286
+ : events;
287
+ const findings = countFindingsBySeverity(state);
288
+ const dispositions = countDispositions(state);
289
+ const planName = basename(state.planDoc);
290
+ const outcomeTitle = kind === 'scope_accepted'
291
+ ? `Scope ${getCurrentScopeLabel(state)} accepted`
292
+ : kind === 'blocked'
293
+ ? `Scope ${getCurrentScopeLabel(state)} blocked`
294
+ : kind === 'failed'
295
+ ? `Scope ${getCurrentScopeLabel(state)} failed`
296
+ : state.topLevelMode === 'plan'
297
+ ? 'Planning run complete'
298
+ : 'Implementation complete';
299
+ const outcomeStatus = kind === 'scope_accepted'
300
+ ? 'accepted'
301
+ : kind === 'blocked'
302
+ ? 'blocked'
303
+ : kind === 'failed'
304
+ ? 'failed'
305
+ : 'done';
306
+ const changedFiles = await summarizeChangedFiles(state);
307
+ const verificationSummary = summarizeVerification(scopeEvents);
308
+ const metricsEvents = kind === 'done' ? events : scopeEvents;
309
+ const runMetricsSummary = renderRunMetricsMarkdown(summarizeRunMetrics(metricsEvents));
310
+ const assessment = buildAssessment(state, scopeEvents);
311
+ const narrativeRetrospective = buildNarrativeRetrospective({
312
+ state,
313
+ kind,
314
+ outcomeTitle,
315
+ verificationSummary,
316
+ });
317
+ const completedScopesSummary = kind === 'done' ? summarizeCompletedScopes(state) : null;
318
+ const latestReviewerSessionHandle = getLatestReviewerSessionHandle(state);
319
+ const blockerSummary = kind === 'blocked' || kind === 'failed' ? summarizeBlocker(state) : null;
320
+ const derivedPlan = getDerivedPlanView(state);
321
+ const showDerivedPlanContext = Boolean(derivedPlan);
322
+ const parentScopeLabel = derivedPlan?.executing ? getParentScopeLabel(state) : null;
323
+ return [
324
+ `# Neal Retrospective`,
325
+ '',
326
+ `## Outcome`,
327
+ `- Plan: ${planName}`,
328
+ `- Mode: ${state.topLevelMode}`,
329
+ `- Summary: ${outcomeTitle}`,
330
+ `- Scope: ${getCurrentScopeLabel(state)}`,
331
+ `- Status: ${outcomeStatus}`,
332
+ `- Final commit: ${state.finalCommit ?? 'n/a'}`,
333
+ `- Coder session: ${state.coderSessionHandle ?? 'n/a'}`,
334
+ `- Reviewer session: ${latestReviewerSessionHandle ?? 'n/a'}`,
335
+ `- Reviewer rounds: ${state.rounds.length}`,
336
+ `- Findings: ${findings.total} total (${findings.blocking} blocking, ${findings.non_blocking} non-blocking)`,
337
+ `- Coder dispositions: ${dispositions.fixed} fixed, ${dispositions.rejected} rejected, ${dispositions.deferred} deferred`,
338
+ ...(showDerivedPlanContext
339
+ ? [
340
+ `- Parent scope: ${parentScopeLabel ?? 'none'}`,
341
+ `- Derived plan: ${derivedPlan?.path ?? 'none'}`,
342
+ `- Derived plan status: ${derivedPlan?.status ?? 'none'}`,
343
+ ]
344
+ : []),
345
+ '',
346
+ `## Run Metrics`,
347
+ runMetricsSummary,
348
+ '',
349
+ `## Retrospective`,
350
+ narrativeRetrospective,
351
+ '',
352
+ `## Work Summary`,
353
+ changedFiles,
354
+ '',
355
+ `## Review Summary`,
356
+ summarizeFindings(state),
357
+ '',
358
+ `## Verification`,
359
+ verificationSummary,
360
+ ...(blockerSummary
361
+ ? [
362
+ '',
363
+ `## Blocker Summary`,
364
+ blockerSummary,
365
+ ]
366
+ : []),
367
+ '',
368
+ `## Assessment`,
369
+ assessment,
370
+ ...(completedScopesSummary
371
+ ? [
372
+ '',
373
+ `## Completed Scopes`,
374
+ completedScopesSummary,
375
+ ]
376
+ : []),
377
+ ...renderInteractiveBlockedRecoveryHistoryLines(state.interactiveBlockedRecoveryHistory),
378
+ '',
379
+ ].join('\n');
380
+ }
381
+ async function writeRetrospectiveFile(path, content) {
382
+ await writeTextAtomic(path, content);
383
+ }
384
+ export async function writeCheckpointRetrospective(state, kind) {
385
+ const content = await renderRetrospective(state, kind);
386
+ const currentPath = getCurrentRetrospectivePath(state.runDir);
387
+ const archivedPath = getArchivedRetrospectivePath(state, kind);
388
+ await writeRetrospectiveFile(currentPath, content);
389
+ await writeRetrospectiveFile(archivedPath, content);
390
+ return { currentPath, archivedPath };
391
+ }
@@ -0,0 +1,18 @@
1
+ function isResidualReviewDebtFinding(finding) {
2
+ return finding.severity === 'non_blocking' && (finding.status === 'open' || finding.status === 'deferred');
3
+ }
4
+ export function toResidualReviewDebt(findings) {
5
+ return findings
6
+ .filter(isResidualReviewDebtFinding)
7
+ .map((finding) => ({
8
+ id: finding.id,
9
+ canonicalId: finding.canonicalId,
10
+ status: finding.status,
11
+ files: [...finding.files],
12
+ claim: finding.claim,
13
+ evidence: finding.evidence ?? null,
14
+ requiredAction: finding.requiredAction,
15
+ coderDisposition: finding.coderDisposition,
16
+ coderCommit: finding.coderCommit,
17
+ }));
18
+ }
@@ -0,0 +1,173 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { writeJsonAtomic, writeTextAtomic } from '../atomic-write.js';
4
+ import { getReviewsDir } from '../storage-paths.js';
5
+ export function getReviewFindingsArtifactPaths(cwd, reviewId) {
6
+ const reviewDir = join(getReviewsDir(cwd), reviewId);
7
+ return {
8
+ reviewDir,
9
+ meta: join(reviewDir, 'meta.json'),
10
+ events: join(reviewDir, 'events.ndjson'),
11
+ request: join(reviewDir, 'REVIEW_REQUEST.md'),
12
+ context: join(reviewDir, 'REVIEW_CONTEXT.json'),
13
+ draft: join(reviewDir, 'REVIEW_DRAFT.md'),
14
+ review: join(reviewDir, 'REVIEW_REVIEW.json'),
15
+ rounds: join(reviewDir, 'REVIEW_ROUNDS.json'),
16
+ final: join(reviewDir, 'REVIEW_FINAL.md'),
17
+ };
18
+ }
19
+ export async function writeReviewFindingsMeta(paths, meta) {
20
+ await writeJsonAtomic(paths.meta, meta);
21
+ }
22
+ export async function writeReviewFindingsEvent(paths, type, data) {
23
+ await mkdir(paths.reviewDir, { recursive: true });
24
+ await appendFile(paths.events, JSON.stringify({
25
+ ts: new Date().toISOString(),
26
+ type,
27
+ data,
28
+ }) + '\n', 'utf8');
29
+ }
30
+ export async function writeReviewFindingsRequest(paths, meta, rounds) {
31
+ await writeTextAtomic(paths.request, [
32
+ '# Review Request',
33
+ '',
34
+ `Review ID: ${meta.reviewId}`,
35
+ `Created: ${meta.createdAt}`,
36
+ `Instruction source: ${meta.instructionSource}`,
37
+ '',
38
+ '## Instruction',
39
+ '',
40
+ meta.instruction,
41
+ '',
42
+ '## Selector',
43
+ '',
44
+ ...formatSelector(meta.selector),
45
+ '',
46
+ '## Resolved Range',
47
+ '',
48
+ `- External base commit: ${meta.target.externalBaseCommit}`,
49
+ `- External head commit: ${meta.target.externalHeadCommit}`,
50
+ `- External commit count: ${meta.target.externalCommits.length}`,
51
+ '',
52
+ '## Loop',
53
+ '',
54
+ `Outcome: ${meta.outcome}`,
55
+ `Max rounds: ${meta.maxRounds}`,
56
+ '',
57
+ ...rounds.flatMap((round) => [
58
+ `## Round ${round.round} Draft Prompt`,
59
+ '',
60
+ round.draftPrompt,
61
+ '',
62
+ `## Round ${round.round} Review Prompt`,
63
+ '',
64
+ round.reviewPrompt,
65
+ '',
66
+ ]),
67
+ ].join('\n') + '\n');
68
+ }
69
+ export async function writeReviewFindingsContext(paths, context) {
70
+ await writeJsonAtomic(paths.context, context);
71
+ }
72
+ export async function writeReviewFindingsDraft(paths, rounds) {
73
+ await writeTextAtomic(paths.draft, [
74
+ '# Review Draft',
75
+ '',
76
+ ...rounds.flatMap((round) => [
77
+ `## Round ${round.round}`,
78
+ '',
79
+ round.draft.summary,
80
+ '',
81
+ '### Findings',
82
+ '',
83
+ ...formatFindings(round.draft.findings),
84
+ '',
85
+ '### Warnings',
86
+ '',
87
+ ...formatWarnings(round.draft.warnings ?? []),
88
+ '',
89
+ ]),
90
+ ].join('\n') + '\n');
91
+ }
92
+ export async function writeReviewFindingsReview(paths, artifact) {
93
+ await writeJsonAtomic(paths.review, artifact);
94
+ }
95
+ export async function writeReviewFindingsRounds(paths, artifact) {
96
+ await writeJsonAtomic(paths.rounds, artifact);
97
+ }
98
+ export async function writeReviewFindingsFinal(paths, finalMarkdown, acceptedRound, cwd) {
99
+ const resumeSection = formatReviewResumeSection(cwd, acceptedRound);
100
+ await writeTextAtomic(paths.final, [
101
+ finalMarkdown.trimEnd(),
102
+ '',
103
+ '## Accepted Review Round',
104
+ '',
105
+ `Round: ${acceptedRound.round}`,
106
+ `Verdict: ${acceptedRound.review.verdict}`,
107
+ ...(resumeSection.length > 0 ? ['', ...resumeSection] : []),
108
+ '',
109
+ ].join('\n'));
110
+ }
111
+ // The reviewer/draft turns run through the Claude Agent SDK, whose sessions are
112
+ // persisted to ~/.claude/projects but deliberately hidden from the interactive
113
+ // `/resume` picker — they are only resumable by id. Surface those ids so an
114
+ // operator can reopen the reviewer's full context to ask "why did it conclude
115
+ // X". Returns [] when no handles were captured (e.g. non-SDK/test providers).
116
+ export function formatReviewResumeSection(cwd, round) {
117
+ const resumable = collectResumableHandles(round);
118
+ if (resumable.length === 0) {
119
+ return [];
120
+ }
121
+ return [
122
+ '## Resume Sessions',
123
+ '',
124
+ `These reviewer/coder turns ran through the Claude Agent SDK. Such sessions do not appear in the interactive \`/resume\` picker, but you can resume one by id from the reviewed directory (${cwd}):`,
125
+ '',
126
+ ...resumable.map(([label, handle]) => `- ${label}: \`claude --resume ${handle}\``),
127
+ ];
128
+ }
129
+ // Compact one-line resume hint for stdout, preferring the reviewer session
130
+ // (the one most useful for interrogating the verdict). Null when no handle was
131
+ // captured.
132
+ export function formatReviewResumeStdoutLine(cwd, round) {
133
+ const reviewer = nonEmptyHandle(round.reviewSessionHandle);
134
+ const draft = nonEmptyHandle(round.draftSessionHandle);
135
+ const handle = reviewer ?? draft;
136
+ if (!handle) {
137
+ return null;
138
+ }
139
+ const which = reviewer ? 'reviewer' : 'draft';
140
+ return `Resume the ${which} session: (cd ${cwd} && claude --resume ${handle})`;
141
+ }
142
+ function collectResumableHandles(round) {
143
+ const entries = [
144
+ ['Reviewer', round.reviewSessionHandle],
145
+ ['Draft (coder)', round.draftSessionHandle],
146
+ ];
147
+ return entries
148
+ .map(([label, handle]) => [label, nonEmptyHandle(handle)])
149
+ .filter((entry) => entry[1] !== null);
150
+ }
151
+ function nonEmptyHandle(handle) {
152
+ return typeof handle === 'string' && handle.trim() !== '' ? handle : null;
153
+ }
154
+ function formatSelector(selector) {
155
+ switch (selector.kind) {
156
+ case 'last':
157
+ return [`- Form: --last`, `- Count: ${selector.count}`];
158
+ case 'since':
159
+ return [`- Form: --since`, `- Base ref: ${selector.baseRef}`, `- Head ref: HEAD (implicit)`];
160
+ }
161
+ }
162
+ function formatFindings(findings) {
163
+ if (findings.length === 0) {
164
+ return ['- (none)'];
165
+ }
166
+ return findings.map((finding, index) => `- F${index + 1} [${finding.severity}] ${finding.claim} (${finding.files.join(', ') || 'no files'}): ${finding.evidence} Required action: ${finding.requiredAction}`);
167
+ }
168
+ function formatWarnings(warnings) {
169
+ if (warnings.length === 0) {
170
+ return ['- (none)'];
171
+ }
172
+ return warnings.map((warning) => `- ${warning}`);
173
+ }