@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
package/config.yml ADDED
@@ -0,0 +1,104 @@
1
+ # neal runtime configuration template.
2
+ # This file is intentionally fully commented out:
3
+ # - uncomment any setting you want to override for this repository; repo config
4
+ # takes precedence over ~/.neal/config.yml
5
+ # - put machine-local overrides such as `neal.notify_bin` or role-specific model
6
+ # choices in ~/.neal/config.yml
7
+ # - run `neal setup` for first-time writer-run provider configuration
8
+
9
+ # neal:
10
+ # # Milliseconds between wrapper heartbeat messages on stderr.
11
+ # # Set to 0 to disable heartbeats entirely.
12
+ # phase_heartbeat_ms: 60000
13
+ #
14
+ # # Maximum reviewer rounds before neal blocks on non-converging review.
15
+ # max_review_rounds: 20
16
+ #
17
+ # # Consecutive rounds with no reduction in open blocking findings before
18
+ # # neal treats review as stuck and blocks early.
19
+ # review_stuck_window: 5
20
+ #
21
+ # # Milliseconds of inactivity before a coder or reviewer turn times out.
22
+ # inactivity_timeout_ms: 600000
23
+ #
24
+ # # Maximum retry attempts for transient API/internal provider failures.
25
+ # api_retry_limit: 10
26
+ #
27
+ # # Milliseconds an agent turn may stay silent after starting, with no
28
+ # # observable progress (no tool use, commands, file changes, or assistant
29
+ # # text), before neal aborts and retries the turn; once any progress is
30
+ # # observed, inactivity_timeout_ms governs instead.
31
+ # agent_turn_startup_timeout_ms: 300000
32
+ #
33
+ # # Maximum number of times neal retries an agent turn that was aborted for
34
+ # # startup silence before failing the turn.
35
+ # agent_turn_retry_limit: 1
36
+ #
37
+ # # Maximum operator guidance turns allowed during interactive blocked recovery
38
+ # # before neal requires a terminal disposition.
39
+ # interactive_blocked_recovery_max_turns: 3
40
+ #
41
+ # # Maximum number of times final completion review may send execution back
42
+ # # for more work before neal stops reopening the plan.
43
+ # final_completion_continue_execution_max: 2
44
+ #
45
+ # # Optional local notification command. Leave commented to keep
46
+ # # notifications disabled.
47
+ # # notify_bin: /absolute/path/to/notify
48
+ #
49
+ # agent:
50
+ # # Fresh writer-run commands require explicit coder and reviewer providers.
51
+ # # These commented examples are templates only; run `neal setup` to write
52
+ # # active provider defaults to ~/.neal/config.yml, or uncomment and edit the
53
+ # # role settings here for repository-level defaults.
54
+ # coder:
55
+ # # Provider for new plan/execute/run workflows.
56
+ # provider: openai-codex
57
+ #
58
+ # # Or run the coder against any OpenAI-compatible tool-calling endpoint
59
+ # # via the providers.openai_compatible block (see docs/providers.md):
60
+ # # provider: generic-agentic
61
+ #
62
+ # # Optional default model override for the coder role.
63
+ # # Leave null to let the provider choose its default model.
64
+ # model: null
65
+ #
66
+ # # Optional reasoning-effort override for the coder role.
67
+ # # Leave null or omit to use the provider default.
68
+ # # Supported values per provider:
69
+ # # openai-codex: minimal, low, medium, high, xhigh
70
+ # # anthropic-claude: low, medium, high, xhigh, max
71
+ # # An effort value not supported by the configured provider is rejected
72
+ # # before a run starts.
73
+ # effort: null
74
+ #
75
+ # reviewer:
76
+ # # Provider for the reviewer role.
77
+ # provider: anthropic-claude
78
+ #
79
+ # # Optional default model override for the reviewer role.
80
+ # # Leave null to let the provider choose its default model.
81
+ # model: null
82
+ #
83
+ # # Optional reasoning-effort override for the reviewer role.
84
+ # # Leave null or omit to use the provider default.
85
+ # # Supported values per provider:
86
+ # # openai-codex: minimal, low, medium, high, xhigh
87
+ # # anthropic-claude: low, medium, high, xhigh, max
88
+ # effort: null
89
+ #
90
+ # # The planner inherits the coder provider/model/effort unless configured
91
+ # # explicitly. Uncomment to override the planner independently.
92
+ # # planner:
93
+ # # provider: openai-codex
94
+ # # model: null
95
+ # # effort: null
96
+ #
97
+ # # Run headlessly when no operator is available to answer an operator block
98
+ # # (CI, cron, or a benchmark harness). When true, the execute-mode
99
+ # # interactive-recovery loop auto-resumes with conservative guidance for a
100
+ # # bounded number of turns and then fails cleanly, while the final-completion
101
+ # # and top-level plan-review gates fail cleanly and terminally instead of
102
+ # # waiting. Verification, authorization, and squash/grading are unchanged.
103
+ # # The `--unattended` CLI flag overrides this key. Defaults to false.
104
+ # unattended: false
@@ -0,0 +1,177 @@
1
+ const DEFAULT_REFRESH_INTERVAL_MS = 1_000;
2
+ const DEFAULT_MIN_REDRAW_INTERVAL_MS = 100;
3
+ const DEFAULT_MIN_COLUMNS = 60;
4
+ function formatElapsed(elapsedMs) {
5
+ const totalSeconds = Math.max(0, Math.round(elapsedMs / 1_000));
6
+ const minutes = Math.floor(totalSeconds / 60);
7
+ const seconds = totalSeconds % 60;
8
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
9
+ }
10
+ function truncateForColumns(text, columns) {
11
+ if (columns <= 0 || text.length <= columns) {
12
+ return text;
13
+ }
14
+ if (columns <= 3) {
15
+ return text.slice(0, columns);
16
+ }
17
+ return `${text.slice(0, columns - 3)}...`;
18
+ }
19
+ export function renderActivityFooterLine(state) {
20
+ const subject = state.subject ? ` ${state.subject}` : '';
21
+ const now = state.now ?? Date.now();
22
+ return [
23
+ `[neal] ${state.mode}${subject}`,
24
+ `activity: ${state.activity}`,
25
+ `elapsed: ${formatElapsed(now - state.startedAt)}`,
26
+ `status: ${state.status}`,
27
+ ].join(' | ');
28
+ }
29
+ export class ActivityFooter {
30
+ stream;
31
+ now;
32
+ refreshIntervalMs;
33
+ minRedrawIntervalMs;
34
+ minColumns;
35
+ refreshTimer = null;
36
+ redrawTimer = null;
37
+ resizeListener = null;
38
+ currentLine = '';
39
+ footerVisible = false;
40
+ lastRedrawAt = 0;
41
+ state = null;
42
+ disposed = false;
43
+ constructor(options = {}) {
44
+ this.stream = options.stream ?? process.stderr;
45
+ this.now = options.now ?? (() => Date.now());
46
+ this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
47
+ this.minRedrawIntervalMs = options.minRedrawIntervalMs ?? DEFAULT_MIN_REDRAW_INTERVAL_MS;
48
+ this.minColumns = options.minColumns ?? DEFAULT_MIN_COLUMNS;
49
+ if (this.isEnabled() && this.stream === process.stderr) {
50
+ this.resizeListener = () => {
51
+ this.handleResize();
52
+ };
53
+ process.on('SIGWINCH', this.resizeListener);
54
+ }
55
+ }
56
+ isEnabled() {
57
+ return this.stream.isTTY === true;
58
+ }
59
+ setState(state) {
60
+ if (this.disposed) {
61
+ return;
62
+ }
63
+ this.state = state;
64
+ this.currentLine = renderActivityFooterLine({
65
+ ...state,
66
+ now: this.now(),
67
+ });
68
+ this.ensureRefreshTimer();
69
+ this.renderFooter(true);
70
+ }
71
+ write(message) {
72
+ if (!this.isEnabled() || this.disposed) {
73
+ this.stream.write(message);
74
+ return;
75
+ }
76
+ this.clearFooter();
77
+ this.stream.write(message);
78
+ if (message.endsWith('\n') || message.endsWith('\r\n')) {
79
+ this.renderFooter(true);
80
+ }
81
+ }
82
+ replaceView(message) {
83
+ if (!this.isEnabled() || this.disposed) {
84
+ this.stream.write(message);
85
+ return;
86
+ }
87
+ this.clearFooter();
88
+ this.stream.write('\x1b[H\x1b[2J');
89
+ if (message !== '') {
90
+ this.stream.write(message);
91
+ }
92
+ this.renderFooter(true);
93
+ }
94
+ dispose() {
95
+ if (this.disposed) {
96
+ return;
97
+ }
98
+ this.disposed = true;
99
+ if (this.refreshTimer) {
100
+ clearInterval(this.refreshTimer);
101
+ this.refreshTimer = null;
102
+ }
103
+ if (this.redrawTimer) {
104
+ clearTimeout(this.redrawTimer);
105
+ this.redrawTimer = null;
106
+ }
107
+ if (this.resizeListener) {
108
+ process.off('SIGWINCH', this.resizeListener);
109
+ this.resizeListener = null;
110
+ }
111
+ this.clearFooter();
112
+ }
113
+ handleResize() {
114
+ if (this.disposed) {
115
+ return;
116
+ }
117
+ if (!this.canRender()) {
118
+ this.clearFooter();
119
+ return;
120
+ }
121
+ this.refresh(true);
122
+ }
123
+ refresh(force = false) {
124
+ if (!this.state || this.disposed) {
125
+ return;
126
+ }
127
+ this.currentLine = renderActivityFooterLine({
128
+ ...this.state,
129
+ now: this.now(),
130
+ });
131
+ this.renderFooter(force);
132
+ }
133
+ ensureRefreshTimer() {
134
+ if (this.refreshTimer || this.disposed || this.refreshIntervalMs <= 0 || !this.isEnabled()) {
135
+ return;
136
+ }
137
+ this.refreshTimer = setInterval(() => {
138
+ this.refresh();
139
+ }, this.refreshIntervalMs);
140
+ this.refreshTimer.unref?.();
141
+ }
142
+ canRender() {
143
+ return (this.isEnabled() &&
144
+ !this.disposed &&
145
+ this.currentLine !== '' &&
146
+ (this.stream.columns ?? 0) >= this.minColumns);
147
+ }
148
+ renderFooter(force = false) {
149
+ if (!this.canRender()) {
150
+ this.clearFooter();
151
+ return;
152
+ }
153
+ const elapsed = this.now() - this.lastRedrawAt;
154
+ if (!force && elapsed < this.minRedrawIntervalMs) {
155
+ if (!this.redrawTimer) {
156
+ this.redrawTimer = setTimeout(() => {
157
+ this.redrawTimer = null;
158
+ this.renderFooter(true);
159
+ }, this.minRedrawIntervalMs - elapsed);
160
+ this.redrawTimer.unref?.();
161
+ }
162
+ return;
163
+ }
164
+ this.lastRedrawAt = this.now();
165
+ const columns = this.stream.columns ?? this.currentLine.length;
166
+ const line = truncateForColumns(this.currentLine, Math.max(0, columns - 1));
167
+ this.stream.write(`\r\x1b[2K${line}`);
168
+ this.footerVisible = true;
169
+ }
170
+ clearFooter() {
171
+ if (!this.footerVisible || !this.isEnabled()) {
172
+ return;
173
+ }
174
+ this.stream.write('\r\x1b[2K');
175
+ this.footerVisible = false;
176
+ }
177
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { isExecuteFinalizationPhase } from '../execute-finalization.js';
2
+ import { renderAdjudicatedLoopContractLines } from './contracts.js';
3
+ import { resolveExecuteAdjudicationContext } from './execute.js';
4
+ import { resolvePlanningAdjudicationContext } from './planning.js';
5
+ import { getAdjudicationSpec } from './specs.js';
6
+ import { hasFinalCompletionReviewState } from '../state-views.js';
7
+ function resolveArtifactPhase(state) {
8
+ if (hasFinalCompletionReviewState(state)) {
9
+ return 'final_completion_review';
10
+ }
11
+ if (state.phase === 'blocked' && state.blockedFromPhase) {
12
+ return state.blockedFromPhase;
13
+ }
14
+ return state.phase;
15
+ }
16
+ export function resolveArtifactAdjudicationContract(state) {
17
+ const sourcePhase = resolveArtifactPhase(state);
18
+ if (sourcePhase === 'coder_plan' ||
19
+ sourcePhase === 'reviewer_plan' ||
20
+ sourcePhase === 'coder_plan_response' ||
21
+ sourcePhase === 'coder_plan_optional_response') {
22
+ return {
23
+ spec: resolvePlanningAdjudicationContext(state).spec,
24
+ sourcePhase,
25
+ };
26
+ }
27
+ if (sourcePhase === 'coder_scope' ||
28
+ sourcePhase === 'reviewer_scope' ||
29
+ sourcePhase === 'coder_response' ||
30
+ sourcePhase === 'coder_optional_response' ||
31
+ isExecuteFinalizationPhase(sourcePhase)) {
32
+ return {
33
+ spec: resolveExecuteAdjudicationContext(state).spec,
34
+ sourcePhase,
35
+ };
36
+ }
37
+ if (sourcePhase === 'final_completion_review') {
38
+ return {
39
+ spec: getAdjudicationSpec('final_completion_review'),
40
+ sourcePhase,
41
+ };
42
+ }
43
+ return null;
44
+ }
45
+ export function renderAdjudicationContractLines(state) {
46
+ const contract = resolveArtifactAdjudicationContract(state);
47
+ if (!contract) {
48
+ return [];
49
+ }
50
+ return [
51
+ '## Adjudication Contract',
52
+ `- Adjudication spec id: ${contract.spec.id}`,
53
+ `- Adjudication family: ${contract.spec.family}`,
54
+ ...renderAdjudicatedLoopContractLines(contract.spec.loopContract),
55
+ `- Allowed transition outcomes: ${contract.spec.transitionSignals.join(', ')}`,
56
+ '- Contract role: validated allowed outcomes for debugging; runtime routing remains explicit elsewhere.',
57
+ ];
58
+ }
@@ -0,0 +1,223 @@
1
+ import { resolve } from 'node:path';
2
+ import { runBlockedAdjudicatorRound } from '../agents/rounds.js';
3
+ import { createInlineSection, readTextForInlineSection, } from '../context/inline-review-context.js';
4
+ import { getChangedFilesForRange, getHeadCommit } from '../git.js';
5
+ import { getExecutionPlanPath } from '../scopes.js';
6
+ // Maximum number of most-recent reviewer rounds inlined as snapshots for the
7
+ // adjudicator. The deadlock signal is dominated by the latest rounds, so a small
8
+ // window keeps the prompt bounded while still showing how the disagreement
9
+ // evolved.
10
+ const RECENT_ROUND_SNAPSHOT_LIMIT = 5;
11
+ // The source phases the generalized adjudicator triages: a reviewer `review_stuck`
12
+ // deadlock (`reviewer_scope`/`reviewer_plan`) and a coder-blocked signal
13
+ // (`coder_scope`/`coder_response`/`coder_optional_response`, which after the
14
+ // split-plan reroute also carries the invalid-payload block). Every other accepted
15
+ // recovery source phase is ineligible and keeps today's generic recovery behavior;
16
+ // the recovery chokepoint enforces this gate.
17
+ export const ADJUDICATOR_ELIGIBLE_SOURCE_PHASES = new Set([
18
+ 'reviewer_scope',
19
+ 'reviewer_plan',
20
+ 'coder_scope',
21
+ 'coder_response',
22
+ 'coder_optional_response',
23
+ ]);
24
+ export function isReviewerAdjudicatorPhase(sourcePhase) {
25
+ return sourcePhase === 'reviewer_scope' || sourcePhase === 'reviewer_plan';
26
+ }
27
+ function isCoderAdjudicatorPhase(sourcePhase) {
28
+ return (sourcePhase === 'coder_scope' ||
29
+ sourcePhase === 'coder_response' ||
30
+ sourcePhase === 'coder_optional_response');
31
+ }
32
+ // --- Anti-thrash guard ------------------------------------------------------------
33
+ // These helpers are PURE and read-only. They never write or persist state; the
34
+ // recovery chokepoint is the sole writer of `state.recentBlocks`.
35
+ function normalizeBlockerKey(input, cwd) {
36
+ const escapedCwd = cwd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ return input
38
+ .replace(new RegExp(escapedCwd, 'gi'), '')
39
+ .replace(/(?:[A-Za-z]:)?(?:\/[^/\s:]+){2,}\/((?:src|test|tests|benchmark|docs|tmp|packages|lib|app)\/[^\s:]+)/g, '$1')
40
+ .replace(/[^\S\r\n]+/g, ' ')
41
+ .replace(/[!?.,;:]{2,}/g, (match) => match[0] ?? '')
42
+ .replace(/[!?.,;:]+(?=\s|$)/g, '')
43
+ .trim()
44
+ .toLowerCase();
45
+ }
46
+ // The "new evidence" escape needs a signal that deterministically CHANGES when
47
+ // the underlying situation changes, even when the blocker wording does not. The
48
+ // blocker text itself cannot be that signal (subtracting the blocker from itself
49
+ // is always empty), so the fingerprint is derived from the one persisted artifact
50
+ // that moves exactly when the coder actually did new work: the scope's commit
51
+ // trail. `state.createdCommits` is append-only for the lifetime of the anti-thrash
52
+ // window (every transition that resets it also resets `recentBlocks`, and a scope
53
+ // boundary changes the scope identity anyway), and the coder phases append the
54
+ // blocked round's commits BEFORE the recovery chokepoint runs, so the tail hash
55
+ // alone captures "the coder committed new work since the recorded block".
56
+ // Deliberately NOT evidence: recovery turns/history (they accrue mechanically on
57
+ // every adjudicated block, so every repeat would escape and the guard would never
58
+ // fire) and LLM free-text summaries/rationales (never byte-stable across rounds,
59
+ // which would defeat the guard the same way).
60
+ function commitTrailEvidenceFingerprint(state) {
61
+ return state.createdCommits.at(-1) ?? '';
62
+ }
63
+ // Build the anti-thrash candidate identity for a block from its `reason` + the
64
+ // current scope identity. Identically worded blockers can hide genuinely
65
+ // different underlying causes, so the candidate also carries the commit-trail
66
+ // evidence fingerprint: when the coder has committed new work since a prior
67
+ // identical block, `findRepeatedRecentBlock` treats the block as carrying new
68
+ // evidence and the adjudicator runs instead of short-circuiting.
69
+ export function buildRecentBlockCandidate(state, reason, sourcePhase) {
70
+ const blocker = reason.trim();
71
+ return {
72
+ scopeNumber: state.currentScopeNumber,
73
+ derivedScopeIndex: state.derivedScopeIndex,
74
+ sourcePhase,
75
+ normalizedKey: normalizeBlockerKey(blocker, state.cwd),
76
+ evidenceFingerprint: commitTrailEvidenceFingerprint(state),
77
+ };
78
+ }
79
+ // Return the most-recent window record that the candidate repeats, or `null` when
80
+ // there is no repeat. A match requires the SAME scope identity (`scopeNumber` +
81
+ // `derivedScopeIndex`), the same `sourcePhase`, and the same normalized blocker
82
+ // key. When that record exists but the candidate carries materially new evidence
83
+ // (a non-empty `evidenceFingerprint` that differs from the prior one — i.e. the
84
+ // coder has committed new work since the recorded block), it is NOT a repeat —
85
+ // the underlying situation has changed even though the blocker text has not — so
86
+ // `null` is returned and the adjudicator gets to look again.
87
+ export function findRepeatedRecentBlock(recentBlocks, candidate) {
88
+ for (const prior of [...recentBlocks].reverse()) {
89
+ if (prior.scopeNumber !== candidate.scopeNumber) {
90
+ continue;
91
+ }
92
+ if (prior.derivedScopeIndex !== candidate.derivedScopeIndex) {
93
+ continue;
94
+ }
95
+ if (prior.sourcePhase !== candidate.sourcePhase) {
96
+ continue;
97
+ }
98
+ if (prior.normalizedKey !== candidate.normalizedKey) {
99
+ continue;
100
+ }
101
+ if (candidate.evidenceFingerprint && candidate.evidenceFingerprint !== prior.evidenceFingerprint) {
102
+ return null;
103
+ }
104
+ return prior;
105
+ }
106
+ return null;
107
+ }
108
+ // Return a NEW window array that either appends the candidate as a fresh record
109
+ // (`count:1`) or, on an exact match (per `findRepeatedRecentBlock`), increments the
110
+ // matched record's `count` and refreshes its `recordedAt`. Pure: the input array
111
+ // is never mutated.
112
+ export function upsertRecentBlock(recentBlocks, candidate, now = new Date().toISOString()) {
113
+ const match = findRepeatedRecentBlock(recentBlocks, candidate);
114
+ if (match) {
115
+ return recentBlocks.map((record) => record === match ? { ...record, count: record.count + 1, recordedAt: now } : record);
116
+ }
117
+ return [...recentBlocks, { ...candidate, count: 1, recordedAt: now }];
118
+ }
119
+ // --- Inline context ---------------------------------------------------------------
120
+ function renderOpenBlockingFindings(findings) {
121
+ if (findings.length === 0) {
122
+ return '(no open blocking findings recorded)';
123
+ }
124
+ return findings
125
+ .map((finding) => [
126
+ `- canonicalId: ${finding.canonicalId}`,
127
+ ` claim: ${finding.claim}`,
128
+ ` requiredAction: ${finding.requiredAction}`,
129
+ ` coderDisposition: ${finding.coderDisposition ?? '(none recorded)'}`,
130
+ ].join('\n'))
131
+ .join('\n');
132
+ }
133
+ function renderRecentRoundSnapshots(rounds) {
134
+ if (rounds.length === 0) {
135
+ return '(no reviewer rounds recorded)';
136
+ }
137
+ return rounds
138
+ .slice(-RECENT_ROUND_SNAPSHOT_LIMIT)
139
+ .map((round) => {
140
+ const openBlocking = round.openBlockingCanonicalIds ?? [];
141
+ const openBlockingText = openBlocking.length > 0 ? openBlocking.join(', ') : '(none)';
142
+ return `- round ${round.round}: openBlockingCanonicalIds: ${openBlockingText}`;
143
+ })
144
+ .join('\n');
145
+ }
146
+ async function buildReviewerInlineContext(state) {
147
+ const planContent = await readTextForInlineSection(resolve(state.cwd, getExecutionPlanPath(state)));
148
+ const openBlockingFindings = state.findings.filter((finding) => finding.severity === 'blocking' && finding.status === 'open');
149
+ return {
150
+ sections: [
151
+ createInlineSection('Execution plan content', planContent || '(plan document content unavailable)'),
152
+ createInlineSection('Open blocking findings', renderOpenBlockingFindings(openBlockingFindings)),
153
+ createInlineSection('Recent reviewer-round snapshots', renderRecentRoundSnapshots(state.rounds)),
154
+ ],
155
+ };
156
+ }
157
+ async function readChangedFilesBestEffort(state) {
158
+ const base = state.baseCommit;
159
+ if (!base) {
160
+ return '(no scope base commit recorded)';
161
+ }
162
+ try {
163
+ const head = await getHeadCommit(state.cwd);
164
+ const files = await getChangedFilesForRange(state.cwd, base, head);
165
+ return files.length > 0 ? files.map((file) => `- ${file}`).join('\n') : '(no changed files in the current scope)';
166
+ }
167
+ catch {
168
+ return '(changed-file context unavailable)';
169
+ }
170
+ }
171
+ async function buildCoderInlineContext(state, reason) {
172
+ const planContent = await readTextForInlineSection(resolve(state.cwd, getExecutionPlanPath(state)));
173
+ const changedFiles = await readChangedFilesBestEffort(state);
174
+ return {
175
+ sections: [
176
+ createInlineSection('Execution plan content', planContent || '(plan document content unavailable)'),
177
+ createInlineSection('Coder blocker summary', reason.trim() || '(no blocker summary provided)'),
178
+ createInlineSection('Changed files since scope base', changedFiles),
179
+ ],
180
+ };
181
+ }
182
+ // Always supplies a non-null InlineReviewerContext built entirely from in-memory
183
+ // OrchestrationState artifacts, so the adjudicator works for every reviewer
184
+ // provider including no-read providers. Throws for any source phase outside
185
+ // `ADJUDICATOR_ELIGIBLE_SOURCE_PHASES`, so an ineligible phase can never silently
186
+ // reach an LLM round.
187
+ async function buildAdjudicatorInlineContext(state, reason, sourcePhase) {
188
+ if (isReviewerAdjudicatorPhase(sourcePhase)) {
189
+ return buildReviewerInlineContext(state);
190
+ }
191
+ if (isCoderAdjudicatorPhase(sourcePhase)) {
192
+ return buildCoderInlineContext(state, reason);
193
+ }
194
+ throw new Error(`Blocked adjudicator cannot build context for ineligible source phase: ${String(sourcePhase)}`);
195
+ }
196
+ // Thin, read-only adjudicator for every triaged block class. It first applies the
197
+ // pure anti-thrash guard against `state.recentBlocks`: if this block repeats a
198
+ // recent block for the same scope identity + sourcePhase + normalized key with no
199
+ // new evidence, it returns a non-recoverable verdict WITHOUT running an LLM round.
200
+ // Otherwise it assembles the InlineReviewerContext for the source phase and runs
201
+ // the reviewer round. The module performs NO writes and NO commits — it only
202
+ // returns a `BlockedAdjudicatorVerdict`; `state.recentBlocks` is written elsewhere
203
+ // (the recovery chokepoint), never here.
204
+ export async function runBlockedAdjudicator(state, reason, sourcePhase, logger) {
205
+ const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
206
+ if (findRepeatedRecentBlock(state.recentBlocks, candidate)) {
207
+ return {
208
+ recoverable: false,
209
+ triageCategory: 'impossible_task',
210
+ resolutionDirective: '',
211
+ rationale: 'This blocker repeats a recent block for the same scope with no new evidence; the adjudicator short-circuited to avoid thrashing without re-running a reviewer round.',
212
+ };
213
+ }
214
+ const inlineContext = await buildAdjudicatorInlineContext(state, reason, sourcePhase);
215
+ const { verdict } = await runBlockedAdjudicatorRound({
216
+ reviewer: state.agentConfig.reviewer,
217
+ cwd: state.cwd,
218
+ blockedReason: reason,
219
+ inlineContext,
220
+ logger,
221
+ });
222
+ return verdict;
223
+ }