@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,373 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, normalize, resolve } from 'node:path';
3
+ import { REVIEW_FINDINGS_ADJUDICATED_LOOP_CONTRACT, resolveReviewedDraftLoopStep, } from '../adjudicator/contracts.js';
4
+ import { getMaxReviewRounds } from '../config.js';
5
+ import { getDiffForRange, getDiffStatForRange, getWorktreeStatus } from '../git.js';
6
+ import { createRunId, RunLogger } from '../logger.js';
7
+ import { resolveReviewTarget } from '../review-mode.js';
8
+ import { getCurrentPlanAndExecuteQueuePointerPath, getCurrentRunPointerPath, getRunsDir, getRunStatePath, } from '../storage-paths.js';
9
+ import { formatDirtyWorktreeDiagnostic, parseWorktreeStatusLine } from '../worktree-status.js';
10
+ import { formatReviewResumeStdoutLine, getReviewFindingsArtifactPaths, writeReviewFindingsContext, writeReviewFindingsDraft, writeReviewFindingsEvent, writeReviewFindingsFinal, writeReviewFindingsMeta, writeReviewFindingsRequest, writeReviewFindingsReview, writeReviewFindingsRounds, } from './artifacts.js';
11
+ import { createAgentReviewFindingsProviderAdapter, renderReviewFindingsFinalMarkdown, validateReviewFindingsDraft, validateReviewFindingsReview, } from './provider.js';
12
+ import { buildReviewFindingsDraftPrompt, buildReviewFindingsReviewPrompt } from './prompts.js';
13
+ export async function runNealReviewCli(args) {
14
+ const cwd = resolve(args.cwd);
15
+ const createdAt = (args.now ?? new Date()).toISOString();
16
+ const reviewId = args.reviewId ?? createRunId();
17
+ const paths = getReviewFindingsArtifactPaths(cwd, reviewId);
18
+ const reviewLogger = args.provider ? undefined : new RunLogger(paths.reviewDir);
19
+ const provider = args.provider ?? createAgentReviewFindingsProviderAdapter({ cwd, logger: reviewLogger });
20
+ const maxRounds = Math.max(1, Math.floor(getMaxReviewRounds(cwd)));
21
+ await reportActivity(args, {
22
+ activity: 'resolving review target',
23
+ detailContext: { runId: reviewId, phase: 'review' },
24
+ });
25
+ const target = await resolveReviewTarget(cwd, args.parsed);
26
+ writeReviewStartupDiagnostics(args.stderr, target.externalCommits.length, target.externalBaseCommit, target.externalHeadCommit, args.parsed);
27
+ await reportActivity(args, {
28
+ activity: 'building review context',
29
+ detailContext: { runId: reviewId, phase: 'review' },
30
+ });
31
+ const beforeState = await captureProtectedStateSnapshot(cwd);
32
+ const beforeWorktreeStatus = await captureReviewReadOnlyWorktreeStatus(cwd, reviewId);
33
+ const context = await buildReviewFindingsContext(cwd, args.parsed, target);
34
+ let outcome = 'failed';
35
+ let finalMarkdown = null;
36
+ const rounds = [];
37
+ let previousDraft = null;
38
+ let reviewFindings = [];
39
+ const buildMeta = (nextOutcome) => ({
40
+ version: 1,
41
+ reviewId,
42
+ createdAt,
43
+ cwd,
44
+ instruction: args.parsed.instruction,
45
+ instructionSource: args.parsed.instructionSource,
46
+ selector: args.parsed.selector,
47
+ target,
48
+ reviewDir: paths.reviewDir,
49
+ maxRounds,
50
+ outcome: nextOutcome,
51
+ });
52
+ await writeReviewFindingsMeta(paths, buildMeta('failed'));
53
+ await writeReviewFindingsEvent(paths, 'review.started', {
54
+ selectorKind: args.parsed.selector.kind,
55
+ externalBaseCommit: target.externalBaseCommit,
56
+ externalHeadCommit: target.externalHeadCommit,
57
+ externalCommitCount: target.externalCommits.length,
58
+ loopKind: REVIEW_FINDINGS_ADJUDICATED_LOOP_CONTRACT.loopKind,
59
+ sideEffectPolicy: REVIEW_FINDINGS_ADJUDICATED_LOOP_CONTRACT.sideEffectPolicy,
60
+ roundCapSource: REVIEW_FINDINGS_ADJUDICATED_LOOP_CONTRACT.roundCap.source,
61
+ maxRounds,
62
+ });
63
+ await writeReviewFindingsContext(paths, context);
64
+ try {
65
+ for (let roundNumber = 1; roundNumber <= maxRounds; roundNumber += 1) {
66
+ await reportActivity(args, {
67
+ activity: `coder drafting review findings round ${roundNumber}/${maxRounds}`,
68
+ detailContext: { runId: reviewId, phase: 'review', scopeNumber: roundNumber },
69
+ });
70
+ const draftPrompt = buildReviewFindingsDraftPrompt(context, {
71
+ round: roundNumber,
72
+ previousDraft,
73
+ reviewFindings,
74
+ maxRounds,
75
+ });
76
+ let draftSessionHandle = null;
77
+ const draftResponse = await provider.draftFindings({
78
+ context,
79
+ round: roundNumber,
80
+ previousDraft,
81
+ reviewFindings,
82
+ prompt: draftPrompt,
83
+ onSessionHandle: (handle) => {
84
+ draftSessionHandle = handle;
85
+ },
86
+ });
87
+ await assertReviewReadOnlyStateUnchanged(cwd, beforeState);
88
+ await assertReviewReadOnlyWorktreeUnchanged(cwd, reviewId, beforeWorktreeStatus);
89
+ const draft = validateReviewFindingsDraft(draftResponse);
90
+ await writeReviewFindingsEvent(paths, 'review.draft_completed', {
91
+ round: roundNumber,
92
+ findingCount: draft.findings.length,
93
+ });
94
+ await reportActivity(args, {
95
+ activity: `reviewer checking review findings round ${roundNumber}/${maxRounds}`,
96
+ detailContext: { runId: reviewId, phase: 'review', scopeNumber: roundNumber },
97
+ });
98
+ const reviewPrompt = buildReviewFindingsReviewPrompt(context, draft, roundNumber);
99
+ let reviewSessionHandle = null;
100
+ const reviewResponse = await provider.reviewDraft({
101
+ context,
102
+ round: roundNumber,
103
+ draft,
104
+ prompt: reviewPrompt,
105
+ onSessionHandle: (handle) => {
106
+ reviewSessionHandle = handle;
107
+ },
108
+ });
109
+ await assertReviewReadOnlyStateUnchanged(cwd, beforeState);
110
+ await assertReviewReadOnlyWorktreeUnchanged(cwd, reviewId, beforeWorktreeStatus);
111
+ const review = validateReviewFindingsReview(reviewResponse);
112
+ const loopRound = {
113
+ round: roundNumber,
114
+ draftPrompt,
115
+ reviewPrompt,
116
+ draft,
117
+ review,
118
+ // Only record handles when captured, so artifacts stay byte-identical
119
+ // for non-SDK providers.
120
+ ...(draftSessionHandle ? { draftSessionHandle } : {}),
121
+ ...(reviewSessionHandle ? { reviewSessionHandle } : {}),
122
+ };
123
+ rounds.push(loopRound);
124
+ await writeReviewFindingsEvent(paths, 'review.review_completed', {
125
+ round: roundNumber,
126
+ verdict: review.verdict,
127
+ findingCount: review.findings.length,
128
+ });
129
+ const loopStep = resolveReviewedDraftLoopStep({
130
+ ownerId: 'review',
131
+ contract: REVIEW_FINDINGS_ADJUDICATED_LOOP_CONTRACT,
132
+ verdict: review.verdict,
133
+ round: roundNumber,
134
+ maxRounds,
135
+ });
136
+ if (loopStep.terminalOutcome === 'accepted') {
137
+ outcome = loopStep.terminalOutcome;
138
+ finalMarkdown = review.finalMarkdown ?? renderReviewFindingsFinalMarkdown(context, draft, review);
139
+ await reportActivity(args, {
140
+ activity: 'review findings accepted',
141
+ status: 'done',
142
+ detailContext: { runId: reviewId, phase: 'review', scopeNumber: roundNumber },
143
+ });
144
+ break;
145
+ }
146
+ if (loopStep.terminalOutcome) {
147
+ outcome = loopStep.terminalOutcome;
148
+ await reportActivity(args, {
149
+ activity: `review findings ${loopStep.terminalOutcome}`,
150
+ status: loopStep.terminalOutcome === 'blocked' ? 'blocked' : 'failed',
151
+ detailContext: { runId: reviewId, phase: 'review', scopeNumber: roundNumber },
152
+ });
153
+ break;
154
+ }
155
+ previousDraft = draft;
156
+ reviewFindings = review.findings;
157
+ }
158
+ await assertReviewReadOnlyStateUnchanged(cwd, beforeState);
159
+ await assertReviewReadOnlyWorktreeUnchanged(cwd, reviewId, beforeWorktreeStatus);
160
+ }
161
+ catch (error) {
162
+ outcome = 'failed';
163
+ await reportActivity(args, {
164
+ activity: 'review failed',
165
+ status: 'failed',
166
+ detailContext: { runId: reviewId, phase: 'review' },
167
+ });
168
+ await persistReviewFindingsLoopArtifacts(paths, buildMeta(outcome), {
169
+ version: 1,
170
+ outcome,
171
+ maxRounds,
172
+ rounds,
173
+ });
174
+ await writeReviewFindingsEvent(paths, 'review.failed', {
175
+ message: error instanceof Error ? error.message : String(error),
176
+ });
177
+ throw error;
178
+ }
179
+ const meta = buildMeta(outcome);
180
+ const reviewArtifact = {
181
+ version: 1,
182
+ outcome,
183
+ maxRounds,
184
+ rounds,
185
+ };
186
+ await persistReviewFindingsLoopArtifacts(paths, meta, reviewArtifact);
187
+ if (outcome !== 'accepted' || !finalMarkdown) {
188
+ await writeReviewFindingsEvent(paths, outcome === 'cap_reached' ? 'review.cap_reached' : 'review.blocked', {
189
+ roundCount: rounds.length,
190
+ outcome,
191
+ });
192
+ throw new Error(formatUnacceptedReviewError(outcome, paths.reviewDir, rounds));
193
+ }
194
+ const acceptedRound = rounds[rounds.length - 1];
195
+ if (!acceptedRound) {
196
+ throw new Error(`Review ${reviewId} accepted without a recorded round`);
197
+ }
198
+ await writeReviewFindingsFinal(paths, finalMarkdown, acceptedRound, cwd);
199
+ await writeReviewFindingsEvent(paths, 'review.completed', {
200
+ finalBytes: Buffer.byteLength(finalMarkdown, 'utf8'),
201
+ findingCount: acceptedRound.draft.findings.length,
202
+ roundCount: rounds.length,
203
+ });
204
+ const resumeLine = formatReviewResumeStdoutLine(cwd, acceptedRound);
205
+ args.stdout.write(`${finalMarkdown.trimEnd()}\n\nReview findings accepted: ${paths.final}\n${resumeLine ? `${resumeLine}\n` : ''}`);
206
+ return {
207
+ reviewId,
208
+ paths,
209
+ meta,
210
+ context,
211
+ draft: acceptedRound.draft,
212
+ review: acceptedRound.review,
213
+ rounds,
214
+ outcome,
215
+ finalMarkdown,
216
+ };
217
+ }
218
+ async function reportActivity(args, update) {
219
+ await args.onActivity?.(update);
220
+ }
221
+ async function buildReviewFindingsContext(cwd, parsed, target) {
222
+ return {
223
+ version: 1,
224
+ instruction: parsed.instruction,
225
+ instructionSource: parsed.instructionSource,
226
+ selector: parsed.selector,
227
+ baseRef: target.baseRef,
228
+ headRef: target.headRef,
229
+ externalBaseCommit: target.externalBaseCommit,
230
+ externalHeadCommit: target.externalHeadCommit,
231
+ externalCommits: target.externalCommits,
232
+ externalCommitSubjects: target.externalCommitSubjects,
233
+ externalChangedFiles: target.externalChangedFiles,
234
+ diffStat: await getDiffStatForRange(cwd, target.externalBaseCommit, target.externalHeadCommit),
235
+ diff: await getDiffForRange(cwd, target.externalBaseCommit, target.externalHeadCommit),
236
+ };
237
+ }
238
+ async function persistReviewFindingsLoopArtifacts(paths, meta, reviewArtifact) {
239
+ await writeReviewFindingsMeta(paths, meta);
240
+ await writeReviewFindingsRequest(paths, meta, reviewArtifact.rounds);
241
+ await writeReviewFindingsDraft(paths, reviewArtifact.rounds);
242
+ await writeReviewFindingsReview(paths, reviewArtifact);
243
+ await writeReviewFindingsRounds(paths, reviewArtifact);
244
+ }
245
+ function formatUnacceptedReviewError(outcome, reviewDir, rounds) {
246
+ const latestRound = rounds[rounds.length - 1];
247
+ const latestReview = latestRound?.review;
248
+ const detail = latestReview?.blockedReason ??
249
+ latestReview?.findings.join('; ') ??
250
+ 'The review loop did not produce accepted findings.';
251
+ if (outcome === 'cap_reached') {
252
+ return `neal review reached the review round cap without accepted findings. Latest finding: ${detail}. Artifacts: ${reviewDir}`;
253
+ }
254
+ if (outcome === 'blocked') {
255
+ return `neal review was blocked before producing accepted findings. Latest finding: ${detail}. Artifacts: ${reviewDir}`;
256
+ }
257
+ return `neal review failed before producing accepted findings. Artifacts: ${reviewDir}`;
258
+ }
259
+ function shortCommit(commit) {
260
+ return commit.slice(0, 7);
261
+ }
262
+ function formatCommitCount(count) {
263
+ return `${count} ${count === 1 ? 'commit' : 'commits'}`;
264
+ }
265
+ function writeReviewStartupDiagnostics(stderr, commitCount, baseCommit, headCommit, parsed) {
266
+ stderr.write(`[neal] review: analyzing ${formatCommitCount(commitCount)}\n`);
267
+ stderr.write(`[neal] review range: ${shortCommit(baseCommit)}..${shortCommit(headCommit)}\n`);
268
+ if (parsed.instructionSource === 'default') {
269
+ stderr.write('[neal] review posture: built-in findings review\n');
270
+ }
271
+ else {
272
+ stderr.write(`[neal] user review context: ${parsed.instruction}\n`);
273
+ }
274
+ }
275
+ async function captureProtectedStateSnapshot(cwd) {
276
+ return {
277
+ currentRunPointer: await readOptionalText(getCurrentRunPointerPath(cwd)),
278
+ currentQueuePointer: await readOptionalText(getCurrentPlanAndExecuteQueuePointerPath(cwd)),
279
+ runStates: await readRunStates(cwd),
280
+ };
281
+ }
282
+ async function assertReviewReadOnlyStateUnchanged(cwd, before) {
283
+ const after = await captureProtectedStateSnapshot(cwd);
284
+ if (after.currentRunPointer !== before.currentRunPointer) {
285
+ throw new Error('neal review is read-only but .neal/current.json changed');
286
+ }
287
+ if (after.currentQueuePointer !== before.currentQueuePointer) {
288
+ throw new Error('neal review is read-only but .neal/current-queue.json changed');
289
+ }
290
+ assertSameStringMap(before.runStates, after.runStates, 'run-local RUN_STATE.json files');
291
+ }
292
+ async function captureReviewReadOnlyWorktreeStatus(cwd, reviewId) {
293
+ return filterReviewArtifactWorktreeStatus(await getWorktreeStatus(cwd, { untrackedFiles: 'all' }), reviewId);
294
+ }
295
+ async function assertReviewReadOnlyWorktreeUnchanged(cwd, reviewId, beforeStatus) {
296
+ const afterStatus = await captureReviewReadOnlyWorktreeStatus(cwd, reviewId);
297
+ if (afterStatus === beforeStatus) {
298
+ return;
299
+ }
300
+ const scratchDiagnostic = formatDirtyWorktreeDiagnostic({ statusOutput: afterStatus });
301
+ throw new Error([
302
+ 'neal review is read-only but the worktree changed outside its review artifacts:',
303
+ 'Before:',
304
+ beforeStatus || '(clean)',
305
+ 'After:',
306
+ afterStatus || '(clean)',
307
+ scratchDiagnostic,
308
+ ].filter(Boolean).join('\n'));
309
+ }
310
+ function filterReviewArtifactWorktreeStatus(statusOutput, reviewId) {
311
+ return statusOutput
312
+ .split('\n')
313
+ .map((line) => line.trimEnd())
314
+ .filter(Boolean)
315
+ .filter((line) => {
316
+ const paths = parseWorktreeStatusLine(line)?.paths ?? [];
317
+ return paths.length === 0 || paths.some((path) => !isReviewArtifactPath(path, reviewId));
318
+ })
319
+ .join('\n');
320
+ }
321
+ function isReviewArtifactPath(path, reviewId) {
322
+ const normalized = normalize(path).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
323
+ const reviewPrefix = `.neal/reviews/${reviewId}`;
324
+ return normalized === reviewPrefix || normalized.startsWith(`${reviewPrefix}/`);
325
+ }
326
+ async function readOptionalText(path) {
327
+ try {
328
+ return await readFile(path, 'utf8');
329
+ }
330
+ catch (error) {
331
+ if (isMissingFileError(error)) {
332
+ return null;
333
+ }
334
+ throw error;
335
+ }
336
+ }
337
+ async function readRunStates(cwd) {
338
+ const runsDir = getRunsDir(cwd);
339
+ let entries;
340
+ try {
341
+ entries = await readdir(runsDir);
342
+ }
343
+ catch (error) {
344
+ if (isMissingFileError(error)) {
345
+ return {};
346
+ }
347
+ throw error;
348
+ }
349
+ const states = {};
350
+ for (const entry of entries.sort()) {
351
+ const statePath = getRunStatePath(join(runsDir, entry));
352
+ const content = await readOptionalText(statePath);
353
+ if (content !== null) {
354
+ states[entry] = content;
355
+ }
356
+ }
357
+ return states;
358
+ }
359
+ function assertSameStringMap(before, after, label) {
360
+ const beforeKeys = Object.keys(before).sort();
361
+ const afterKeys = Object.keys(after).sort();
362
+ if (beforeKeys.join('\n') !== afterKeys.join('\n')) {
363
+ throw new Error(`neal review is read-only but ${label} changed`);
364
+ }
365
+ for (const key of beforeKeys) {
366
+ if (before[key] !== after[key]) {
367
+ throw new Error(`neal review is read-only but ${label} changed for ${key}`);
368
+ }
369
+ }
370
+ }
371
+ function isMissingFileError(error) {
372
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
373
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { getChangedFilesForRange, getCommitRange, getCommitSubjects, getHeadCommit, isAncestorCommit, resolveCommitRef, } from './git.js';
2
+ function shortCommit(commit) {
3
+ return commit.slice(0, 7);
4
+ }
5
+ function getSelectorRefs(selector) {
6
+ switch (selector.kind) {
7
+ case 'last':
8
+ return {
9
+ baseRef: `HEAD~${selector.count}`,
10
+ headRef: 'HEAD',
11
+ };
12
+ case 'since':
13
+ return {
14
+ baseRef: selector.baseRef,
15
+ headRef: 'HEAD',
16
+ };
17
+ }
18
+ }
19
+ async function resolveReviewCommitRef(cwd, label, ref) {
20
+ try {
21
+ return await resolveCommitRef(cwd, ref);
22
+ }
23
+ catch (error) {
24
+ const detail = error instanceof Error ? error.message : String(error);
25
+ throw new Error(`Unable to resolve review ${label} ref "${ref}" to a commit: ${detail}`);
26
+ }
27
+ }
28
+ export async function assertReviewExternalCommitsReproducible(args) {
29
+ const actualCommits = await getCommitRange(args.cwd, args.externalBaseCommit, args.externalHeadCommit);
30
+ if (actualCommits.length !== args.externalCommits.length ||
31
+ actualCommits.some((commit, index) => commit !== args.externalCommits[index])) {
32
+ throw new Error('neal review external commit list no longer matches the resolved base..head range');
33
+ }
34
+ }
35
+ export async function resolveReviewTarget(cwd, parsed) {
36
+ const { baseRef, headRef } = getSelectorRefs(parsed.selector);
37
+ const externalBaseCommit = await resolveReviewCommitRef(cwd, 'base', baseRef);
38
+ const externalHeadCommit = await resolveReviewCommitRef(cwd, 'head', headRef);
39
+ const currentHeadCommit = await getHeadCommit(cwd);
40
+ if (externalHeadCommit !== currentHeadCommit) {
41
+ throw new Error(`neal review requires the selected head to be the current checkout HEAD in v1: selected ${shortCommit(externalHeadCommit)}, current ${shortCommit(currentHeadCommit)}`);
42
+ }
43
+ const baseIsAncestor = await isAncestorCommit(cwd, externalBaseCommit, externalHeadCommit);
44
+ if (!baseIsAncestor) {
45
+ throw new Error(`neal review requires the selected base to be an ancestor of the selected head: ${shortCommit(externalBaseCommit)} is not an ancestor of ${shortCommit(externalHeadCommit)}`);
46
+ }
47
+ const externalCommits = await getCommitRange(cwd, externalBaseCommit, externalHeadCommit);
48
+ if (externalCommits.length === 0) {
49
+ throw new Error('neal review requires at least one commit in the selected range');
50
+ }
51
+ await assertReviewExternalCommitsReproducible({
52
+ cwd,
53
+ externalBaseCommit,
54
+ externalHeadCommit,
55
+ externalCommits,
56
+ });
57
+ return {
58
+ selector: parsed.selector,
59
+ baseRef,
60
+ headRef,
61
+ externalBaseCommit,
62
+ externalHeadCommit,
63
+ externalCommits,
64
+ externalCommitSubjects: await getCommitSubjects(cwd, externalCommits),
65
+ externalChangedFiles: await getChangedFilesForRange(cwd, externalBaseCommit, externalHeadCommit),
66
+ };
67
+ }
@@ -0,0 +1,137 @@
1
+ import { join } from 'node:path';
2
+ import { writeTextAtomic } from './atomic-write.js';
3
+ import { renderAdjudicationContractLines } from './adjudicator/artifacts.js';
4
+ import { formatPublicPhase } from './phase-display.js';
5
+ import { getCurrentScopeLabel, getExecutionPlanPath, getExecutionPlanScopeCountForShape, getParentScopeLabel, renderRecentAcceptedScopesSummary, renderScopeProgressSummary, } from './scopes.js';
6
+ import { getDerivedPlanView, getFinalCompletionView } from './state-views.js';
7
+ function getDiscardedDiffPath(state) {
8
+ if (!getDerivedPlanView(state)) {
9
+ return null;
10
+ }
11
+ return join(state.runDir, `SCOPE_${state.currentScopeNumber}_DISCARDED.diff`);
12
+ }
13
+ function getReviewTarget(state) {
14
+ const derivedPlan = getDerivedPlanView(state);
15
+ if (derivedPlan) {
16
+ return {
17
+ path: derivedPlan.path,
18
+ label: 'derived plan',
19
+ };
20
+ }
21
+ return {
22
+ path: getExecutionPlanPath(state),
23
+ label: 'plan document',
24
+ };
25
+ }
26
+ export function renderReviewMarkdown(state) {
27
+ const reviewTarget = getReviewTarget(state);
28
+ const derivedPlan = getDerivedPlanView(state);
29
+ const finalCompletion = getFinalCompletionView(state);
30
+ const lastReviewedPlanPath = state.rounds.at(-1)?.reviewedPlanPath ?? null;
31
+ const parentScopeLabel = state.topLevelMode === 'execute' ? getParentScopeLabel(state) : null;
32
+ const lines = [
33
+ '# Review Session',
34
+ '',
35
+ '## Metadata',
36
+ `- Plan: ${state.planDoc}`,
37
+ `- Review target: ${reviewTarget.path}`,
38
+ `- Review target kind: ${reviewTarget.label}`,
39
+ `- Last reviewed artifact: ${lastReviewedPlanPath ?? 'pending'}`,
40
+ `- Scope: ${getCurrentScopeLabel(state)}`,
41
+ `- Scope progress: ${renderScopeProgressSummary(state, getExecutionPlanScopeCountForShape(state.executionShape))}`,
42
+ `- Current step: ${formatPublicPhase(state.phase)}`,
43
+ `- Execution shape: ${state.executionShape ?? 'pending'}`,
44
+ `- Coder session: ${state.coderSessionHandle ?? 'pending'}`,
45
+ `- Base commit: ${state.baseCommit ?? 'unknown'}`,
46
+ `- Final commit: ${state.finalCommit ?? 'pending'}`,
47
+ `- Last marker: ${state.lastScopeMarker ?? 'pending'}`,
48
+ `- Derived plan: ${derivedPlan?.path ?? 'none'}`,
49
+ `- Derived plan status: ${derivedPlan?.status ?? 'none'}`,
50
+ `- Derived from scope: ${derivedPlan?.parentScopeNumber ?? 'none'}`,
51
+ `- Discarded WIP artifact: ${getDiscardedDiffPath(state) ?? 'none'}`,
52
+ ];
53
+ if (state.topLevelMode === 'execute') {
54
+ lines.push('', '## Meaningful Progress', `- Active parent objective: ${parentScopeLabel ?? 'none'}`);
55
+ if (state.currentScopeProgressJustification) {
56
+ lines.push(`- Coder milestone: ${state.currentScopeProgressJustification.milestoneTargeted}`, `- New evidence: ${state.currentScopeProgressJustification.newEvidence}`, `- Why not redundant: ${state.currentScopeProgressJustification.whyNotRedundant}`, `- Next step unlocked: ${state.currentScopeProgressJustification.nextStepUnlocked}`);
57
+ }
58
+ else {
59
+ lines.push('- Coder justification: pending');
60
+ }
61
+ if (state.currentScopeMeaningfulProgressVerdict) {
62
+ lines.push(`- Reviewer action: ${state.currentScopeMeaningfulProgressVerdict.action}`, `- Reviewer rationale: ${state.currentScopeMeaningfulProgressVerdict.rationale}`);
63
+ }
64
+ else {
65
+ lines.push('- Reviewer action: pending', '- Reviewer rationale: pending');
66
+ }
67
+ lines.push('', '### Recent Accepted Scope History');
68
+ for (const line of renderRecentAcceptedScopesSummary(state, parentScopeLabel ?? String(state.currentScopeNumber)).split('\n')) {
69
+ lines.push(line);
70
+ }
71
+ }
72
+ const latestCompletedScope = state.completedScopes.at(-1) ?? null;
73
+ if (latestCompletedScope) {
74
+ lines.push('', '## Latest Completed Scope', `- Scope: ${latestCompletedScope.number}`, `- Result: ${latestCompletedScope.result}`, `- Summary: ${latestCompletedScope.summary ?? 'none'}`, `- Commit subject: ${latestCompletedScope.commitSubject ?? 'pending'}`, `- Replaced by derived plan: ${latestCompletedScope.replacedByDerivedPlanPath ?? 'none'}`);
75
+ }
76
+ const contractLines = renderAdjudicationContractLines(state);
77
+ if (contractLines.length > 0) {
78
+ lines.push('', ...contractLines);
79
+ }
80
+ if (finalCompletion?.summary) {
81
+ lines.push('', '## Final Completion Summary', `- Plan goal satisfied: ${finalCompletion.summary.planGoalSatisfied ? 'yes' : 'no'}`, `- What changed overall: ${finalCompletion.summary.whatChangedOverall}`, `- Verification summary: ${finalCompletion.summary.verificationSummary}`);
82
+ if (finalCompletion.summary.remainingKnownGaps.length > 0) {
83
+ lines.push('- Remaining known gaps:');
84
+ for (const gap of finalCompletion.summary.remainingKnownGaps) {
85
+ lines.push(` - ${gap}`);
86
+ }
87
+ }
88
+ else {
89
+ lines.push('- Remaining known gaps: none');
90
+ }
91
+ }
92
+ if (finalCompletion?.reviewVerdict) {
93
+ lines.push('', '## Final Completion Review', `- Reviewer action: ${finalCompletion.reviewVerdict.action}`, `- Resulting action: ${finalCompletion.effectiveAction ?? finalCompletion.reviewVerdict.action}`, `- Reviewer summary: ${finalCompletion.reviewVerdict.summary}`, `- Reviewer rationale: ${finalCompletion.reviewVerdict.rationale}`, `- Continue-execution cycles used: ${finalCompletion.continueExecutionCount}`, `- Continue-execution cap reached: ${finalCompletion.continueExecutionCapReached ? 'yes' : 'no'}`);
94
+ if (finalCompletion.reviewVerdict.missingWork) {
95
+ lines.push(`- Missing work summary: ${finalCompletion.reviewVerdict.missingWork.summary}`, `- Missing work required outcome: ${finalCompletion.reviewVerdict.missingWork.requiredOutcome}`, `- Missing work verification: ${finalCompletion.reviewVerdict.missingWork.verification}`);
96
+ }
97
+ else {
98
+ lines.push('- Missing work: none');
99
+ }
100
+ }
101
+ lines.push('', '## Review Rounds');
102
+ if (state.rounds.length === 0) {
103
+ lines.push('', 'No review rounds yet.');
104
+ }
105
+ else {
106
+ for (const round of state.rounds) {
107
+ const normalizationStatus = round.normalizationApplied
108
+ ? round.normalizationOperations.length > 0
109
+ ? round.normalizationOperations.join(' | ')
110
+ : 'applied'
111
+ : 'none';
112
+ const scopeMappings = round.normalizationScopeLabelMappings.length > 0
113
+ ? round.normalizationScopeLabelMappings
114
+ .map((mapping) => `${mapping.originalScopeLabel} -> ${mapping.normalizedScopeNumber}`)
115
+ .join(', ')
116
+ : 'none';
117
+ lines.push('', `### Round ${round.round}`, `- Reviewed artifact: ${round.reviewedPlanPath ?? 'unknown'}`, `- Normalization: ${normalizationStatus}`, `- Scope label mappings: ${scopeMappings}`, `- Reviewer session: ${round.reviewerSessionHandle ?? 'pending'}`, `- Open blocking canonicals: ${round.openBlockingCanonicalCount}`, `- Findings: ${round.findings.join(', ') || 'none'}`);
118
+ }
119
+ }
120
+ lines.push('', '## Findings');
121
+ if (state.findings.length === 0) {
122
+ lines.push('', 'No findings yet.');
123
+ }
124
+ else {
125
+ const rounds = [...new Set(state.findings.map((finding) => finding.round))].sort((a, b) => a - b);
126
+ for (const round of rounds) {
127
+ lines.push('', `## Round ${round} Findings`);
128
+ for (const finding of state.findings.filter((item) => item.round === round)) {
129
+ lines.push('', `### ${finding.id}`, `- Canonical ID: ${finding.canonicalId}`, `- Source: ${finding.source}`, `- Severity: ${finding.severity}`, `- Status: ${finding.status}`, `- Files: ${finding.files.join(', ') || 'n/a'}`, `- Claim: ${finding.claim}`, `- Evidence: ${finding.evidence?.trim() || 'n/a'}`, `- Required action: ${finding.requiredAction}`, `- Round summary: ${finding.roundSummary}`, `- Coder disposition: ${finding.coderDisposition ?? 'pending'}`, `- Coder commit: ${finding.coderCommit ?? 'pending'}`);
130
+ }
131
+ }
132
+ }
133
+ return `${lines.join('\n')}\n`;
134
+ }
135
+ export async function writeReviewMarkdown(path, state) {
136
+ await writeTextAtomic(path, renderReviewMarkdown(state));
137
+ }