@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,140 @@
1
+ import { open, readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export const DEFAULT_CONTEXT_ARTIFACT_BYTE_LIMIT = 16 * 1024;
4
+ export const DEFAULT_CONTEXT_TOTAL_BYTE_LIMIT = 96 * 1024;
5
+ export async function readBoundedArtifact(request, budget) {
6
+ if (budget.usedBytes >= budget.totalByteLimit) {
7
+ return omittedArtifact(request, 'total byte limit reached');
8
+ }
9
+ let fileStat;
10
+ try {
11
+ fileStat = await stat(request.path);
12
+ }
13
+ catch (error) {
14
+ if (isNotFoundError(error)) {
15
+ return omittedArtifact(request, 'missing');
16
+ }
17
+ throw error;
18
+ }
19
+ if (!fileStat.isFile()) {
20
+ return omittedArtifact(request, 'not a regular file');
21
+ }
22
+ const availableBytes = Math.max(0, budget.totalByteLimit - budget.usedBytes);
23
+ const byteLimit = Math.min(budget.perArtifactByteLimit, availableBytes);
24
+ if (byteLimit <= 0) {
25
+ return omittedArtifact(request, 'total byte limit reached');
26
+ }
27
+ const content = await readFileWindow(request.path, fileStat.size, byteLimit, request.readStrategy ?? 'head');
28
+ budget.usedBytes += content.byteLength;
29
+ return {
30
+ label: request.label,
31
+ kind: request.kind,
32
+ content: content.text,
33
+ byteLength: content.byteLength,
34
+ truncated: fileStat.size > content.bytesRead || content.droppedPartialLine,
35
+ omitted: false,
36
+ omissionReason: null,
37
+ };
38
+ }
39
+ export function makeBoundedInlineArtifact(citation, content, budget) {
40
+ if (budget.usedBytes >= budget.totalByteLimit) {
41
+ return omittedArtifact(citation, 'total byte limit reached');
42
+ }
43
+ const availableBytes = Math.max(0, budget.totalByteLimit - budget.usedBytes);
44
+ const byteLimit = Math.min(budget.perArtifactByteLimit, availableBytes);
45
+ const buffer = Buffer.from(content, 'utf8');
46
+ const truncated = buffer.byteLength > byteLimit;
47
+ const boundedBuffer = truncated ? buffer.subarray(0, byteLimit) : buffer;
48
+ const boundedContent = boundedBuffer.toString('utf8');
49
+ const byteLength = boundedBuffer.byteLength;
50
+ budget.usedBytes += byteLength;
51
+ return {
52
+ ...citation,
53
+ content: boundedContent,
54
+ byteLength,
55
+ truncated,
56
+ omitted: false,
57
+ omissionReason: null,
58
+ };
59
+ }
60
+ export async function findLatestRetrospectiveArtifact(runDir) {
61
+ const defaultPath = join(runDir, 'RETROSPECTIVE.md');
62
+ try {
63
+ const defaultStat = await stat(defaultPath);
64
+ if (defaultStat.isFile()) {
65
+ return {
66
+ label: 'RETROSPECTIVE.md',
67
+ kind: 'run_artifact',
68
+ path: defaultPath,
69
+ };
70
+ }
71
+ }
72
+ catch (error) {
73
+ if (!isNotFoundError(error)) {
74
+ throw error;
75
+ }
76
+ }
77
+ let entries;
78
+ try {
79
+ entries = await readdir(runDir, { withFileTypes: true });
80
+ }
81
+ catch (error) {
82
+ if (isNotFoundError(error)) {
83
+ return null;
84
+ }
85
+ throw error;
86
+ }
87
+ const names = entries
88
+ .filter((entry) => entry.isFile() && /^RETROSPECTIVE-.+\.md$/.test(entry.name))
89
+ .map((entry) => entry.name)
90
+ .sort();
91
+ const latestName = names.at(-1);
92
+ if (!latestName) {
93
+ return null;
94
+ }
95
+ return {
96
+ label: latestName,
97
+ kind: 'run_artifact',
98
+ path: join(runDir, latestName),
99
+ };
100
+ }
101
+ function omittedArtifact(citation, reason) {
102
+ return {
103
+ ...citation,
104
+ content: '',
105
+ byteLength: 0,
106
+ truncated: false,
107
+ omitted: true,
108
+ omissionReason: reason,
109
+ };
110
+ }
111
+ async function readFileWindow(path, fileSize, byteLimit, strategy) {
112
+ const file = await open(path, 'r');
113
+ try {
114
+ const length = Math.min(fileSize, byteLimit);
115
+ const start = strategy === 'tail' ? Math.max(0, fileSize - length) : 0;
116
+ const buffer = Buffer.alloc(length);
117
+ const { bytesRead } = await file.read(buffer, 0, length, start);
118
+ let boundedBuffer = buffer.subarray(0, bytesRead);
119
+ let droppedPartialLine = false;
120
+ if (strategy === 'tail' && start > 0) {
121
+ const newlineIndex = boundedBuffer.indexOf('\n');
122
+ if (newlineIndex >= 0) {
123
+ boundedBuffer = boundedBuffer.subarray(newlineIndex + 1);
124
+ droppedPartialLine = true;
125
+ }
126
+ }
127
+ return {
128
+ text: boundedBuffer.toString('utf8'),
129
+ byteLength: boundedBuffer.byteLength,
130
+ bytesRead,
131
+ droppedPartialLine,
132
+ };
133
+ }
134
+ finally {
135
+ await file.close();
136
+ }
137
+ }
138
+ function isNotFoundError(error) {
139
+ return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
140
+ }
@@ -0,0 +1,324 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access } from 'node:fs/promises';
3
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { getFinalCompletionReviewArtifactPath } from '../final-completion-review.js';
6
+ import { getCurrentRunPointerPath, resolveRunStatePath } from '../run-registry.js';
7
+ import { getRunDisplayStatus } from '../run-status.js';
8
+ import { getRunStatePath, loadState } from '../state.js';
9
+ import { getNealDir } from '../storage-paths.js';
10
+ import { formatStatusNextActionForState } from '../status.js';
11
+ import { DEFAULT_CONTEXT_ARTIFACT_BYTE_LIMIT, DEFAULT_CONTEXT_TOTAL_BYTE_LIMIT, findLatestRetrospectiveArtifact, makeBoundedInlineArtifact, readBoundedArtifact, } from './artifacts.js';
12
+ const execFileAsync = promisify(execFile);
13
+ const GIT_RECENT_COMMIT_LIMIT = 5;
14
+ export async function buildLocalNealContextPack(args) {
15
+ const cwd = resolve(args.cwd);
16
+ const createdAt = (args.now ?? new Date()).toISOString();
17
+ const perArtifactByteLimit = args.perArtifactByteLimit ?? DEFAULT_CONTEXT_ARTIFACT_BYTE_LIMIT;
18
+ const totalByteLimit = args.totalByteLimit ?? DEFAULT_CONTEXT_TOTAL_BYTE_LIMIT;
19
+ const budget = {
20
+ perArtifactByteLimit,
21
+ totalByteLimit,
22
+ usedBytes: 0,
23
+ };
24
+ const warnings = [];
25
+ const artifacts = [];
26
+ const stateResolution = await loadContextState(cwd, args.statePath ?? null, warnings);
27
+ if (stateResolution.state) {
28
+ for (const request of await buildRunArtifactRequests(stateResolution.state)) {
29
+ artifacts.push(await readBoundedArtifact(request, budget));
30
+ }
31
+ }
32
+ if (args.planPath) {
33
+ const planPath = resolvePath(cwd, args.planPath);
34
+ artifacts.push(await readBoundedArtifact({
35
+ label: args.planPath,
36
+ kind: 'plan',
37
+ path: planPath,
38
+ }, budget));
39
+ }
40
+ for (const request of buildGuidanceArtifactRequests(cwd)) {
41
+ artifacts.push(await readBoundedArtifact(request, budget));
42
+ }
43
+ const gitContext = await buildGitContext(cwd, warnings);
44
+ if (gitContext) {
45
+ artifacts.push(makeBoundedInlineArtifact({ label: 'git context', kind: 'git' }, gitContext, budget));
46
+ }
47
+ const suggestedActions = buildSuggestedActions(stateResolution, artifacts, cwd);
48
+ const citations = artifacts
49
+ .filter((artifact) => !artifact.omitted)
50
+ .map(({ label, kind }) => ({ label, kind }));
51
+ return {
52
+ version: 1,
53
+ createdAt,
54
+ cwd,
55
+ state: stateResolution.state ? buildContextState(cwd, stateResolution) : null,
56
+ artifacts,
57
+ citations,
58
+ suggestedActions,
59
+ limits: {
60
+ perArtifactByteLimit,
61
+ totalByteLimit,
62
+ totalArtifactBytes: budget.usedBytes,
63
+ truncatedArtifactCount: artifacts.filter((artifact) => artifact.truncated).length,
64
+ omittedArtifactCount: artifacts.filter((artifact) => artifact.omitted).length,
65
+ },
66
+ warnings,
67
+ };
68
+ }
69
+ function buildContextState(cwd, stateResolution) {
70
+ const state = stateResolution.state;
71
+ if (!state) {
72
+ throw new Error('Cannot build Neal context state without a loaded run state');
73
+ }
74
+ const displayStatus = getRunDisplayStatus(state);
75
+ const runId = basename(state.runDir);
76
+ const latestInteractiveBlockedRecoveryHistory = state.interactiveBlockedRecoveryHistory.at(-1) ?? null;
77
+ return {
78
+ statePath: toDisplayPath(cwd, stateResolution.statePath),
79
+ statePathSource: stateResolution.source,
80
+ runDir: toDisplayPath(cwd, state.runDir),
81
+ runDirName: runId,
82
+ planDoc: state.planDoc,
83
+ topLevelMode: state.topLevelMode,
84
+ executionShape: state.executionShape,
85
+ phase: state.phase,
86
+ status: state.status,
87
+ effectiveStatus: displayStatus.effectiveStatus,
88
+ waitingForOperatorGuidance: displayStatus.waitingForOperatorGuidance,
89
+ pendingOperatorGuidance: displayStatus.pendingOperatorGuidance,
90
+ currentScopeNumber: state.currentScopeNumber,
91
+ blockedFromPhase: state.blockedFromPhase,
92
+ lastBlockedReason: getLastBlockedReason(state),
93
+ interactiveBlockedRecovery: state.interactiveBlockedRecovery
94
+ ? {
95
+ sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
96
+ blockedReason: state.interactiveBlockedRecovery.blockedReason,
97
+ turns: state.interactiveBlockedRecovery.turns.length,
98
+ lastHandledTurn: state.interactiveBlockedRecovery.lastHandledTurn,
99
+ pendingDirective: state.interactiveBlockedRecovery.pendingDirective?.operatorGuidance ?? null,
100
+ acceptsFreeFormResumeMessage: displayStatus.waitingForOperatorGuidance,
101
+ }
102
+ : null,
103
+ acceptedResumeMessageShapes: getAcceptedResumeMessageShapes(state, runId, displayStatus.waitingForOperatorGuidance),
104
+ latestInteractiveBlockedRecoveryHistory: latestInteractiveBlockedRecoveryHistory
105
+ ? {
106
+ resolvedByAction: latestInteractiveBlockedRecoveryHistory.resolvedByAction,
107
+ resultPhase: latestInteractiveBlockedRecoveryHistory.resultPhase,
108
+ blockedReason: latestInteractiveBlockedRecoveryHistory.blockedReason,
109
+ turns: latestInteractiveBlockedRecoveryHistory.turns.length,
110
+ }
111
+ : null,
112
+ nextAction: formatStatusNextActionForState(state),
113
+ updatedAt: state.updatedAt,
114
+ };
115
+ }
116
+ function getAcceptedResumeMessageShapes(state, runId, waitingForOperatorGuidance) {
117
+ if (waitingForOperatorGuidance) {
118
+ return [`neal resume --run ${runId} --message "<free-form recovery guidance>"`];
119
+ }
120
+ return [];
121
+ }
122
+ function getLastBlockedReason(state) {
123
+ if (state.interactiveBlockedRecovery?.blockedReason) {
124
+ return state.interactiveBlockedRecovery.blockedReason;
125
+ }
126
+ const blockedScope = [...state.completedScopes].reverse().find((scope) => scope.result === 'blocked' && scope.blocker);
127
+ return blockedScope?.blocker ?? null;
128
+ }
129
+ async function loadContextState(cwd, inputStatePath, warnings) {
130
+ const source = inputStatePath ? 'explicit' : 'current_pointer';
131
+ let statePath;
132
+ try {
133
+ statePath = inputStatePath
134
+ ? resolvePath(cwd, inputStatePath)
135
+ : (await resolveRunStatePath({ cwd, runId: 'latest' })).statePath;
136
+ }
137
+ catch (error) {
138
+ statePath = inputStatePath ? resolvePath(cwd, inputStatePath) : getCurrentRunPointerPath(cwd);
139
+ warnings.push(inputStatePath
140
+ ? `State file could not be resolved: ${error instanceof Error ? error.message : String(error)}`
141
+ : `Current Neal run pointer could not be resolved: ${error instanceof Error ? error.message : String(error)}`);
142
+ return {
143
+ state: null,
144
+ statePath,
145
+ source,
146
+ };
147
+ }
148
+ try {
149
+ const state = await loadState(statePath);
150
+ return {
151
+ state,
152
+ statePath,
153
+ source,
154
+ };
155
+ }
156
+ catch (error) {
157
+ if (isNotFoundError(error)) {
158
+ warnings.push(inputStatePath ? `State file not found: ${inputStatePath}` : 'No .neal/current.json run pointer state file found.');
159
+ return {
160
+ state: null,
161
+ statePath,
162
+ source,
163
+ };
164
+ }
165
+ warnings.push(`State file could not be read: ${error instanceof Error ? error.message : String(error)}`);
166
+ return {
167
+ state: null,
168
+ statePath,
169
+ source,
170
+ };
171
+ }
172
+ }
173
+ async function buildRunArtifactRequests(state) {
174
+ const requests = [
175
+ {
176
+ label: 'RUN_STATE.json',
177
+ kind: 'state',
178
+ path: getRunStatePath(state.runDir),
179
+ },
180
+ ];
181
+ requests.push({
182
+ label: 'events.ndjson',
183
+ kind: 'run_artifact',
184
+ path: join(state.runDir, 'events.ndjson'),
185
+ readStrategy: 'tail',
186
+ }, {
187
+ label: 'plan-progress.json',
188
+ kind: 'run_artifact',
189
+ path: state.progressJsonPath,
190
+ }, {
191
+ label: 'PLAN_PROGRESS.md',
192
+ kind: 'run_artifact',
193
+ path: state.progressMarkdownPath,
194
+ }, {
195
+ label: 'REVIEW.md',
196
+ kind: 'run_artifact',
197
+ path: state.reviewMarkdownPath,
198
+ }, {
199
+ label: 'RECOVERY.md',
200
+ kind: 'run_artifact',
201
+ path: state.recoveryMarkdownPath,
202
+ });
203
+ const retrospective = await findLatestRetrospectiveArtifact(state.runDir);
204
+ if (retrospective) {
205
+ requests.push(retrospective);
206
+ }
207
+ requests.push({
208
+ label: 'FINAL_COMPLETION_REVIEW.md',
209
+ kind: 'run_artifact',
210
+ path: getFinalCompletionReviewArtifactPath(state.runDir),
211
+ });
212
+ return requests;
213
+ }
214
+ function buildGuidanceArtifactRequests(cwd) {
215
+ return [
216
+ {
217
+ label: 'AGENTS.md',
218
+ kind: 'guidance',
219
+ path: join(cwd, 'AGENTS.md'),
220
+ },
221
+ {
222
+ label: '.neal/NOTES.md',
223
+ kind: 'guidance',
224
+ path: join(getNealDir(cwd), 'NOTES.md'),
225
+ },
226
+ ];
227
+ }
228
+ async function buildGitContext(cwd, warnings) {
229
+ try {
230
+ await access(join(cwd, '.git'));
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ try {
236
+ const [statusResult, logResult] = await Promise.all([
237
+ execFileAsync('git', ['status', '--short'], { cwd }),
238
+ execFileAsync('git', ['log', `-${GIT_RECENT_COMMIT_LIMIT}`, '--pretty=format:%h %s'], { cwd }),
239
+ ]);
240
+ const status = statusResult.stdout.trim();
241
+ const log = logResult.stdout.trim();
242
+ return [
243
+ 'git status --short',
244
+ status || '(clean)',
245
+ '',
246
+ `git log -${GIT_RECENT_COMMIT_LIMIT} --pretty=format:%h %s`,
247
+ log || '(no commits)',
248
+ ].join('\n');
249
+ }
250
+ catch {
251
+ warnings.push('Git context unavailable.');
252
+ return null;
253
+ }
254
+ }
255
+ function buildSuggestedActions(stateResolution, artifacts, cwd) {
256
+ const state = stateResolution.state;
257
+ if (!state) {
258
+ return [];
259
+ }
260
+ const target = {
261
+ runDirName: basename(state.runDir),
262
+ statePath: toDisplayPath(cwd, getRunStatePath(state.runDir)),
263
+ };
264
+ const suggestions = [];
265
+ const firstInspectableArtifact = artifacts.find((artifact) => !artifact.omitted && artifact.kind !== 'git');
266
+ if (firstInspectableArtifact) {
267
+ suggestions.push({
268
+ type: 'inspect_artifact',
269
+ label: `Inspect ${firstInspectableArtifact.label}`,
270
+ target: {
271
+ ...target,
272
+ artifactLabel: firstInspectableArtifact.label,
273
+ },
274
+ rationale: 'Read a bounded Neal artifact excerpt before taking action.',
275
+ });
276
+ }
277
+ if (state.status === 'blocked') {
278
+ suggestions.push({
279
+ type: 'recover',
280
+ label: 'Recover selected run',
281
+ target,
282
+ rationale: 'The selected Neal run is blocked and can accept operator recovery guidance.',
283
+ });
284
+ }
285
+ else if (state.status === 'paused') {
286
+ suggestions.push({
287
+ type: 'resume',
288
+ label: 'Resume selected run',
289
+ target,
290
+ rationale: 'The selected Neal run is paused at a scope boundary.',
291
+ });
292
+ }
293
+ else if (state.status === 'running') {
294
+ suggestions.push({
295
+ type: 'pause_after_scope',
296
+ label: 'Pause after current scope',
297
+ target,
298
+ rationale: 'The selected Neal run is still running.',
299
+ });
300
+ }
301
+ else if (state.status === 'done' && state.finalCommit) {
302
+ suggestions.push({
303
+ type: 'squash',
304
+ label: 'Review squash opportunity',
305
+ target,
306
+ rationale: 'The selected Neal run has a final commit recorded.',
307
+ });
308
+ }
309
+ return suggestions;
310
+ }
311
+ function resolvePath(cwd, path) {
312
+ return isAbsolute(path) ? path : resolve(cwd, path);
313
+ }
314
+ function toDisplayPath(cwd, path) {
315
+ const absolutePath = resolvePath(cwd, path);
316
+ const relativePath = relative(cwd, absolutePath);
317
+ if (!relativePath.startsWith('..') && !isAbsolute(relativePath)) {
318
+ return relativePath || '.';
319
+ }
320
+ return absolutePath;
321
+ }
322
+ function isNotFoundError(error) {
323
+ return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
324
+ }
@@ -0,0 +1,131 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { getProviderDefinition, isRegisteredProviderId } from '../providers/registry.js';
3
+ // Per-section character cap for inlined reviewer context. Mirrors the
4
+ // truncation posture of truncateForPrompt in src/neal/agents/structured-json.ts:
5
+ // truncate with an explicit marker instead of silently dropping content.
6
+ export const INLINE_SECTION_MAX_CHARS = 200_000;
7
+ // Canonical forbidden-phrase list for no-read reviewer prompts. A no-read
8
+ // reviewer prompt must contain no instruction that requires repository, file,
9
+ // tool, or shell access of any kind. Both the prompt-builder implementations
10
+ // and the runtime capture tests assert against this shared list, so a newly
11
+ // added repo-access phrase fails the shared assertion rather than silently
12
+ // passing.
13
+ export const NO_READ_PROMPT_FORBIDDEN_MARKERS = [
14
+ 'repository tools',
15
+ 'repository inspection',
16
+ 'git diff',
17
+ 'git log',
18
+ 'git show',
19
+ 'scratch directory',
20
+ // "Read <path>"-style pointer phrasings ("Prior review history is available
21
+ // at <path>", "REVIEW.md is available at <path>", ...).
22
+ 'is available at',
23
+ 'Inspect cited artifacts',
24
+ ];
25
+ // True when the configured reviewer provider's structured-advisor role has no
26
+ // repository read access, so Neal must inline the review context (diff, plan,
27
+ // artifacts) into reviewer prompts.
28
+ export function reviewerNeedsInlineContext(reviewer) {
29
+ if (!isRegisteredProviderId(reviewer.provider)) {
30
+ return false;
31
+ }
32
+ return getProviderDefinition(reviewer.provider).capabilities['structured-advisor'].toolAccess.read === false;
33
+ }
34
+ // Three-way review-doctrine access mode for the configured reviewer provider's
35
+ // structured-advisor role. Unregistered provider ids default to 'tool-access',
36
+ // mirroring reviewerNeedsInlineContext's false. No repository read access
37
+ // selects 'no-read' (Neal inlines the review context); read access without
38
+ // shell access selects 'read-only' (the reviewer inspects the repository with
39
+ // read tools but must not be instructed to run commands); read plus shell
40
+ // access selects 'tool-access'. Write access does not affect review doctrine:
41
+ // reviews never instruct repository mutation.
42
+ export function getReviewerDoctrineAccessMode(reviewer) {
43
+ if (!isRegisteredProviderId(reviewer.provider)) {
44
+ return 'tool-access';
45
+ }
46
+ const toolAccess = getProviderDefinition(reviewer.provider).capabilities['structured-advisor'].toolAccess;
47
+ if (toolAccess.read === false) {
48
+ return 'no-read';
49
+ }
50
+ if (toolAccess.shell === false) {
51
+ return 'read-only';
52
+ }
53
+ return 'tool-access';
54
+ }
55
+ // True when the configured reviewer is a read-only reviewer (read tools, no
56
+ // shell) that has no commit-range diff tool of its own, so Neal must inline the
57
+ // commit-range diff into its reviewer prompt. Read-only reviewers that expose a
58
+ // commit-range diff tool (providesRangeDiffTool === true, e.g. generic-agentic)
59
+ // inspect the range with that tool and do not receive an inlined diff; no-read
60
+ // reviewers get the full inlineContext instead, and tool-access reviewers run
61
+ // shell git themselves. The inlined commit-range diff rides a dedicated prompt
62
+ // channel separate from the no-read inlineContext channel, so it never trips
63
+ // resolveReviewerPromptAccessMode's no-read-only inlineContext guard.
64
+ export function readOnlyReviewerNeedsInlinedDiff(reviewer) {
65
+ if (!isRegisteredProviderId(reviewer.provider)) {
66
+ return false;
67
+ }
68
+ if (getReviewerDoctrineAccessMode(reviewer) !== 'read-only') {
69
+ return false;
70
+ }
71
+ return getProviderDefinition(reviewer.provider).capabilities['structured-advisor'].providesRangeDiffTool !== true;
72
+ }
73
+ // Renders the Neal-inlined commit-range diff section for a read-only reviewer
74
+ // that lacks a commit-range diff tool. Unlike renderInlineReviewerContext (the
75
+ // no-read framing that denies all repository access), this framing affirms the
76
+ // reviewer's read tools and presents the inlined diff only as the source of
77
+ // truth for what the range changed.
78
+ export function renderInlinedRangeDiffSection(args) {
79
+ return [
80
+ `## Inlined commit-range diff from Neal (${args.rangeLabel})`,
81
+ '',
82
+ 'You have read-only repository tools (read and search, no shell) but no commit-range diff tool, so Neal has inlined the commit-range diff below.',
83
+ 'This inlined diff is the source of truth for exactly what this range changed, including deletions and renames that head-state file reads cannot reveal. Use your read tools to verify the surrounding code.',
84
+ '',
85
+ args.diff.trim() === '' ? '(empty diff)' : truncateInlineSectionBody(args.diff),
86
+ ].join('\n');
87
+ }
88
+ // Best-effort artifact read for inline reviewer sections. Missing or
89
+ // unreadable artifacts inline as empty content (callers substitute an explicit
90
+ // "(unavailable)" placeholder) instead of failing the reviewer round.
91
+ export async function readTextForInlineSection(path) {
92
+ try {
93
+ return await readFile(path, 'utf8');
94
+ }
95
+ catch {
96
+ return '';
97
+ }
98
+ }
99
+ export function truncateInlineSectionBody(body, maxChars = INLINE_SECTION_MAX_CHARS) {
100
+ if (body.length <= maxChars) {
101
+ return body;
102
+ }
103
+ return `${body.slice(0, maxChars)}\n[truncated ${body.length - maxChars} character(s)]`;
104
+ }
105
+ export function createInlineSection(title, body) {
106
+ return {
107
+ title,
108
+ body: truncateInlineSectionBody(body),
109
+ };
110
+ }
111
+ export function renderInlineReviewerContext(context) {
112
+ return [
113
+ '## Inlined review context from Neal',
114
+ '',
115
+ 'You do not have repository, file, shell, or tool access of any kind for this review.',
116
+ 'Neal has inlined every artifact you need below. The inlined sections are the source of truth for this review; judge entirely from this prompt.',
117
+ 'Do not report the lack of repository access as a finding; it is expected for this reviewer configuration.',
118
+ ...context.sections.flatMap((section) => ['', `### ${section.title}`, '', section.body]),
119
+ ].join('\n');
120
+ }
121
+ // Implementation-side guard for builder-owned static instruction text in
122
+ // no-read prompt variants. Call it only on Neal-authored instruction lines
123
+ // (never on dynamic content such as diffs, coder-authored justifications, or
124
+ // operator guidance, which may legitimately mention these phrases).
125
+ export function assertNoReadPromptInstructionText(text, label) {
126
+ const lowered = text.toLowerCase();
127
+ const violations = NO_READ_PROMPT_FORBIDDEN_MARKERS.filter((marker) => lowered.includes(marker.toLowerCase()));
128
+ if (violations.length > 0) {
129
+ throw new Error(`${label} produced no-read reviewer instructions containing forbidden repository-access phrasing: ${violations.join(', ')}`);
130
+ }
131
+ }