@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync, statSync } from 'node:fs';
4
4
  import { extname, join, relative, resolve } from 'node:path';
5
5
  import { getPersistence } from '../websocket/quality-handlers.js';
6
6
  import { QualityPersistence } from '../websocket/quality-persistence.js';
@@ -8,6 +8,7 @@ import { indexDismissals, isActiveFinding, runQualityScan } from '../websocket/q
8
8
  import { attributeToTrackedDirectories, detectEcosystem } from '../websocket/quality-tools.js';
9
9
  import { SOURCE_EXTENSIONS } from '../websocket/quality-types.js';
10
10
  import { resolveIsCodeTask } from './issue-classification.js';
11
+ import { allRecordFiles, writeStampedRecord } from './record-file.js';
11
12
  export const QUALITY_DELTA_DIRNAME = 'quality';
12
13
  export const QUALITY_DELTA_MAX_FINDINGS = 200;
13
14
  export const WORKTREE_NOT_CHECKED_REASON = 'worktree boards are not checked yet';
@@ -214,34 +215,15 @@ export function qualityDeltaDir(boardDir) {
214
215
  }
215
216
  export function persistQualityDelta(boardDir, record, epochMs = Date.now()) {
216
217
  const dir = qualityDeltaDir(boardDir);
217
- if (!existsSync(dir))
218
- mkdirSync(dir, { recursive: true });
219
- const path = join(dir, `${record.issueId}-${epochMs}.json`);
220
- writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
221
- return path;
218
+ return join(dir, writeStampedRecord(dir, record.issueId, `${JSON.stringify(record, null, 2)}\n`, epochMs));
222
219
  }
223
- const DELTA_FILE_RE = /^(.+)-(\d+)\.json$/;
224
220
  export function readLatestQualityDeltas(boardDir) {
225
221
  const dir = qualityDeltaDir(boardDir);
226
222
  const newest = new Map();
227
- let names;
228
- try {
229
- names = readdirSync(dir);
230
- }
231
- catch {
232
- return new Map();
233
- }
234
- for (const name of names) {
235
- const match = name.match(DELTA_FILE_RE);
236
- if (!match)
237
- continue;
238
- const epochMs = Number(match[2]);
239
- const current = newest.get(match[1]);
240
- if (!current || epochMs > current.epochMs)
241
- newest.set(match[1], { epochMs, name });
242
- }
223
+ for (const { id, fileName } of allRecordFiles(dir))
224
+ newest.set(id, fileName);
243
225
  const records = new Map();
244
- for (const [issueId, { name }] of newest) {
226
+ for (const [issueId, name] of newest) {
245
227
  try {
246
228
  const record = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
247
229
  if (record && typeof record === 'object' && record.issueId === issueId)
@@ -1,19 +1,27 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  const TERMINAL_STATUSES = new Set(['done', 'cancelled']);
4
+ const WAITING_STATUSES = new Set(['awaiting_approval']);
4
5
  export function buildCompletionReason(issues, epicScope) {
5
6
  const nonEpic = issues.filter(i => i.type !== 'epic');
6
7
  const done = nonEpic.filter(i => TERMINAL_STATUSES.has(i.status)).length;
8
+ const awaitingApproval = nonEpic.filter(i => WAITING_STATUSES.has(i.status)).length;
7
9
  const blocked = nonEpic.filter(i => i.status === 'todo').length;
8
10
  if (done === nonEpic.length)
9
11
  return epicScope ? 'All epic issues are done' : 'All issues are done';
10
- if (blocked > 0)
11
- return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
12
+ if (awaitingApproval > 0 || blocked > 0) {
13
+ const parts = [`${done}/${nonEpic.length} issues done`];
14
+ if (awaitingApproval > 0)
15
+ parts.push(`${awaitingApproval} awaiting approval`);
16
+ if (blocked > 0)
17
+ parts.push(`${blocked} blocked by incomplete dependencies`);
18
+ return parts.join(', ');
19
+ }
12
20
  return epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
13
21
  }
14
22
  export function detectDeadState(issues) {
15
23
  const nonEpic = issues.filter(i => i.type !== 'epic');
16
- const stuck = nonEpic.filter(i => !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo');
24
+ const stuck = nonEpic.filter(i => !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo' && !WAITING_STATUSES.has(i.status));
17
25
  if (stuck.length === 0)
18
26
  return null;
19
27
  const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
@@ -30,5 +38,5 @@ export function detectDeadState(issues) {
30
38
  return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
31
39
  }
32
40
  export function hasBlockedIssues(issues) {
33
- return issues.some(i => i.type !== 'epic' && !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo');
41
+ return issues.some(i => i.type !== 'epic' && !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo' && !WAITING_STATUSES.has(i.status));
34
42
  }
@@ -0,0 +1,52 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ const RECORD_FILE_RE = /^(.+)-(\d+)\.json$/;
6
+ export function matchRecordFile(fileName) {
7
+ const match = fileName.match(RECORD_FILE_RE);
8
+ return match ? { fileName, id: match[1], epochMs: Number(match[2]) } : null;
9
+ }
10
+ export function allRecordFiles(dir, keep) {
11
+ let names;
12
+ try {
13
+ names = readdirSync(dir);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ const files = [];
19
+ for (const name of names) {
20
+ const match = matchRecordFile(name);
21
+ if (match && (!keep || keep(match)))
22
+ files.push(match);
23
+ }
24
+ return files.sort((a, b) => a.epochMs - b.epochMs);
25
+ }
26
+ export function recordFilesFor(dir, id) {
27
+ return allRecordFiles(dir, (file) => file.id === id);
28
+ }
29
+ export function newestRecordFile(dir, id) {
30
+ const files = recordFilesFor(dir, id);
31
+ return files[files.length - 1] ?? null;
32
+ }
33
+ const MAX_STAMP_ATTEMPTS = 1000;
34
+ export function writeStampedRecord(dir, id, contents, preferredEpochMs) {
35
+ if (/[/\\\0]/.test(id))
36
+ throw new Error(`Unsafe issue id for a record file: ${id}`);
37
+ mkdirSync(dir, { recursive: true });
38
+ let epochMs = Math.max(preferredEpochMs ?? Date.now(), (newestRecordFile(dir, id)?.epochMs ?? 0) + 1);
39
+ for (let attempt = 0; attempt < MAX_STAMP_ATTEMPTS; attempt++) {
40
+ const fileName = `${id}-${epochMs}.json`;
41
+ try {
42
+ writeFileSync(join(dir, fileName), contents, { encoding: 'utf-8', flag: 'wx' });
43
+ return fileName;
44
+ }
45
+ catch (error) {
46
+ if (error.code !== 'EEXIST')
47
+ throw error;
48
+ epochMs += 1;
49
+ }
50
+ }
51
+ throw new Error(`No free record name for ${id} in ${dir} after ${MAX_STAMP_ATTEMPTS} attempts`);
52
+ }
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { newestRecordFile, recordFilesFor, writeStampedRecord } from './record-file.js';
6
+ const SAFE_RECORD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
7
+ export function approvalsDir(boardOrSandboxDir) {
8
+ return join(boardOrSandboxDir, 'approvals');
9
+ }
10
+ export function latestReviewVerdict(boardOrSandboxDir, issueId) {
11
+ return boardOrSandboxDir ? newestRecordFile(join(boardOrSandboxDir, 'reviews'), issueId) : null;
12
+ }
13
+ export function writeReviewApproval(boardOrSandboxDir, record, epochMs = Date.now()) {
14
+ if (!SAFE_RECORD_ID_RE.test(record.issueId)) {
15
+ throw new Error(`Unsafe issue id for a decision record: ${record.issueId}`);
16
+ }
17
+ return writeStampedRecord(approvalsDir(boardOrSandboxDir), record.issueId, JSON.stringify(record, null, 2), epochMs);
18
+ }
19
+ export function readReviewApprovals(boardOrSandboxDir, issueId) {
20
+ if (!boardOrSandboxDir)
21
+ return [];
22
+ const dir = approvalsDir(boardOrSandboxDir);
23
+ return recordFilesFor(dir, issueId)
24
+ .map((file) => readApprovalFile(dir, file.fileName, file.epochMs, issueId))
25
+ .filter((stored) => stored !== null);
26
+ }
27
+ export function readLatestReviewApproval(boardOrSandboxDir, issueId) {
28
+ if (!boardOrSandboxDir)
29
+ return null;
30
+ const dir = approvalsDir(boardOrSandboxDir);
31
+ const newest = newestRecordFile(dir, issueId);
32
+ return newest ? readApprovalFile(dir, newest.fileName, newest.epochMs, issueId) : null;
33
+ }
34
+ function readApprovalFile(dir, fileName, epochMs, issueId) {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(join(dir, fileName), 'utf-8'));
37
+ return isReviewApprovalRecord(parsed, issueId) ? { record: parsed, fileName, epochMs } : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function isReviewApprovalRecord(value, issueId) {
44
+ if (!value || typeof value !== 'object')
45
+ return false;
46
+ const record = value;
47
+ return record.issueId === issueId && (record.decision === 'approve' || record.decision === 'reject');
48
+ }
49
+ export const REVIEW_REJECTION_CHECK = 'changes_requested';
50
+ export function rejectionFeedbackFor(boardOrSandboxDir, issueId) {
51
+ const latest = readLatestReviewApproval(boardOrSandboxDir, issueId);
52
+ if (!latest || latest.record.decision !== 'reject')
53
+ return [];
54
+ const comment = latest.record.comment?.trim();
55
+ if (!comment)
56
+ return [];
57
+ const verdict = latestReviewVerdict(boardOrSandboxDir, issueId);
58
+ if (verdict && verdict.epochMs > latest.epochMs)
59
+ return [];
60
+ return [{
61
+ name: REVIEW_REJECTION_CHECK,
62
+ details: `A person reviewed this issue on ${latest.record.decidedAt.slice(0, 10)} and requested changes: ${comment}`,
63
+ }];
64
+ }
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { persistTranscriptSession } from '../websocket/session-history.js';
6
6
  import { loadAgentPrompt } from './agent-loader.js';
@@ -9,45 +9,29 @@ import { appendCancellationNote, appendOutputHistoryEntry } from './issue-writer
9
9
  import { resolveOutputPath } from './output-manager.js';
10
10
  import { createPlanRunner } from './plan-runner-factory.js';
11
11
  import { QUALITY_DELTA_MAX_FINDINGS, qualityDeltaDir } from './quality-delta.js';
12
+ import { newestRecordFile, recordFilesFor, writeStampedRecord } from './record-file.js';
13
+ import { decideReviewRecovery, parseReviewBlocker, parseReviewConfidence, reviewConfidence, reviewDisposition, shouldEscalate, } from './review-outcome.js';
12
14
  import { REVIEW_INCOMPLETE_CHECK } from './review-report.js';
15
+ import { describeReviewTarget, escalatedReviewTarget, REVIEW_ESCALATION_EFFORT_LEVEL, REVIEW_GATE_EFFORT_LEVEL, REVIEW_GATE_ENGINE, REVIEW_GATE_MODEL, resolveReviewTarget, toReviewRunner, } from './review-target.js';
13
16
  import { balancedObjectEnd, createVerdictStream, parseVerdictObject } from './review-verdict-stream.js';
14
17
  import { MAX_REVIEW_ATTEMPTS } from './types.js';
15
- export { MAX_REVIEW_ATTEMPTS };
16
- export const REVIEW_GATE_ENGINE = 'claude-code';
17
- export const REVIEW_GATE_MODEL = 'fable';
18
- export const REVIEW_GATE_EFFORT_LEVEL = 'max';
18
+ export { MAX_REVIEW_ATTEMPTS, REVIEW_ESCALATION_EFFORT_LEVEL, REVIEW_GATE_EFFORT_LEVEL, REVIEW_GATE_ENGINE, REVIEW_GATE_MODEL };
19
19
  export async function reviewIssue(options) {
20
- const { workingDir, issue, pmDir, outputPath, onOutput, onPrompt, onComplete, logDir, boardDir, qualityDelta } = options;
20
+ const { workingDir, issue, pmDir, outputPath, onOutput, onPrompt, onComplete, boardDir, qualityDelta } = options;
21
21
  const isCodeTask = resolveIsCodeTask(issue);
22
22
  const issueType = isCodeTask ? 'code' : 'non-code';
23
23
  try {
24
24
  const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, boardDir, workingDir, qualityDelta);
25
25
  onPrompt?.(prompt);
26
- if (issue.reviewModel && issue.reviewModel !== REVIEW_GATE_MODEL) {
27
- onOutput?.(`Review model '${issue.reviewModel}' overridden the review gate is pinned to ${REVIEW_GATE_MODEL} at ${REVIEW_GATE_EFFORT_LEVEL} effort.\n`);
28
- }
29
- const stream = createVerdictStream(onOutput);
30
- const runner = createPlanRunner('review', {
31
- workingDir,
32
- prompt,
33
- engineId: REVIEW_GATE_ENGINE,
34
- model: REVIEW_GATE_MODEL,
35
- effortLevel: REVIEW_GATE_EFFORT_LEVEL,
36
- verbose: true,
37
- outputCallback: (text) => stream.write(text),
38
- extraEnv: options.extraEnv,
39
- abortSignal: options.abortSignal,
40
- logLabel: 'pm-review',
41
- logDir,
42
- });
43
- const result = await runner.run().finally(() => stream.flush());
44
- await onComplete?.(result, prompt);
26
+ const target = options.reviewTarget ?? resolveReviewTarget(issue);
27
+ onOutput?.(`Reviewing on ${describeReviewTarget(target)}${target.source === 'issue' ? " (the issue's `review_model`)" : ''}.\n`);
28
+ const result = await runReviewer(options, target, prompt, 'pm-review', onComplete);
45
29
  if (result.completed && result.assistantResponse) {
46
- const verdict = applyQualityGate(await resolveVerdict(options, issueType, result.assistantResponse), qualityDelta);
47
- const blocked = verdict.checks.find((c) => c.name === QUALITY_DELTA_CHECK && !c.passed);
30
+ const primary = stampRunner(applyQualityGate(await resolveVerdict(options, issueType, result.assistantResponse, target), qualityDelta), target);
31
+ const blocked = primary.checks.find((c) => c.name === QUALITY_DELTA_CHECK && !c.passed);
48
32
  if (blocked)
49
33
  onOutput?.(`\nQuality gate (block): ${blocked.details}\n`);
50
- return verdict;
34
+ return await maybeEscalate(options, issueType, primary, target, qualityDelta);
51
35
  }
52
36
  return incompleteReviewResult(issue.id, issueType, options.abortSignal?.aborted ? 'Review aborted by user stop' : 'Review runner did not complete');
53
37
  }
@@ -57,37 +41,109 @@ export async function reviewIssue(options) {
57
41
  : `Review threw an error: ${err instanceof Error ? err.message : String(err)}`);
58
42
  }
59
43
  }
60
- async function resolveVerdict(options, issueType, reviewerOutput) {
44
+ async function runReviewer(options, target, prompt, logLabel, onComplete) {
45
+ const stream = createVerdictStream(options.onOutput);
46
+ const runner = createPlanRunner('review', {
47
+ workingDir: options.workingDir,
48
+ prompt,
49
+ engineId: target.engine,
50
+ model: target.model,
51
+ effortLevel: target.effortLevel,
52
+ verbose: true,
53
+ outputCallback: (text) => stream.write(text),
54
+ extraEnv: options.extraEnv,
55
+ abortSignal: options.abortSignal,
56
+ logLabel,
57
+ logDir: options.logDir,
58
+ });
59
+ const result = await runner.run().finally(() => stream.flush());
60
+ await onComplete?.(result, prompt);
61
+ return result;
62
+ }
63
+ async function maybeEscalate(options, issueType, primary, primaryTarget, qualityDelta) {
64
+ if (!shouldEscalate(primary, isIncompleteReview(primary)))
65
+ return primary;
66
+ const next = escalatedReviewTarget(primaryTarget);
67
+ const why = reviewDisposition(primary) === 'blocked'
68
+ ? `claims the issue cannot pass review (${primary.blocker?.kind})`
69
+ : `is ${reviewConfidence(primary)}-confidence`;
70
+ if (!next) {
71
+ options.onOutput?.(`\nVerdict ${why}, but ${primaryTarget.model} is already the top review tier — the verdict stands.\n`);
72
+ return primary;
73
+ }
74
+ options.onOutput?.(`\nVerdict ${why} — escalating to ${describeReviewTarget(next)} for a second opinion.\n`);
75
+ const prompt = buildReviewPrompt(options.issue, options.pmDir, options.outputPath, issueType === 'code', options.boardDir, options.workingDir, qualityDelta);
76
+ const run = await runReviewer(options, next, prompt, 'pm-review-escalation');
77
+ if (!run.completed || !run.assistantResponse) {
78
+ options.onOutput?.('\nEscalated review did not complete — keeping the first verdict.\n');
79
+ return demoteUnconfirmedBlocker(primary);
80
+ }
81
+ const escalated = stampRunner(applyQualityGate(await resolveVerdict(options, issueType, run.assistantResponse, next), qualityDelta), next);
82
+ if (isIncompleteReview(escalated)) {
83
+ options.onOutput?.('\nEscalated review reached no verdict — keeping the first verdict.\n');
84
+ return demoteUnconfirmedBlocker(primary);
85
+ }
86
+ return { ...escalated, escalatedFrom: toReviewRunner(primaryTarget) };
87
+ }
88
+ function demoteUnconfirmedBlocker(result) {
89
+ if (reviewDisposition(result) !== 'blocked')
90
+ return result;
91
+ const { blocker, ...rest } = result;
92
+ return {
93
+ ...rest,
94
+ disposition: 'revise',
95
+ checks: [
96
+ ...result.checks,
97
+ {
98
+ name: 'blocked_claim_unverified',
99
+ passed: false,
100
+ details: `The reviewer reported this issue as blocked (${blocker?.kind}: ${blocker?.detail}) but the escalated second opinion could not confirm it, so the issue is being retried rather than cancelled.`,
101
+ remediation: blocker?.humanAction ?? 'Attempt the work; if it is genuinely impossible here, say so explicitly in the output file.',
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ function stampRunner(result, target) {
107
+ return { ...result, reviewedBy: toReviewRunner(target) };
108
+ }
109
+ async function resolveVerdict(options, issueType, reviewerOutput, target) {
61
110
  const parsed = parseReviewOutput(options.issue.id, issueType, reviewerOutput);
62
111
  if (!hasFailedChecksWithoutDetails(parsed))
63
112
  return parsed;
64
113
  options.onOutput?.('\nReview verdict listed failed checks without details — asking the reviewer for reasons.\n');
65
- const followUp = await runDetailsFollowUp(options, issueType, parsed, reviewerOutput);
114
+ const followUp = await runDetailsFollowUp(options, issueType, parsed, reviewerOutput, target);
66
115
  if (followUp && !hasFailedChecksWithoutDetails(followUp))
67
116
  return followUp;
68
117
  return incompleteReviewResult(options.issue.id, issueType, 'Reviewer reported failed checks without details');
69
118
  }
119
+ function reviewsDirOf(boardOrSandboxDir) {
120
+ return join(boardOrSandboxDir, 'reviews');
121
+ }
70
122
  export function getReviewAttemptCount(boardOrSandboxDir, issue) {
123
+ return boardOrSandboxDir ? recordFilesFor(reviewsDirOf(boardOrSandboxDir), issue.id).length : 0;
124
+ }
125
+ export function readLatestReviewResult(boardOrSandboxDir, issue) {
71
126
  if (!boardOrSandboxDir)
72
- return 0;
73
- const reviewsDir = join(boardOrSandboxDir, 'reviews');
74
- if (!existsSync(reviewsDir))
75
- return 0;
127
+ return null;
128
+ const dir = reviewsDirOf(boardOrSandboxDir);
129
+ const newest = newestRecordFile(dir, issue.id);
130
+ if (!newest)
131
+ return null;
76
132
  try {
77
- return readdirSync(reviewsDir).filter(f => f.startsWith(issue.id) && f.endsWith('.json')).length;
133
+ const record = JSON.parse(readFileSync(join(dir, newest.fileName), 'utf-8'));
134
+ return record && typeof record === 'object' && record.issueId === issue.id ? record : null;
78
135
  }
79
136
  catch {
80
- return 0;
137
+ return null;
81
138
  }
82
139
  }
83
140
  export function persistReviewResult(boardOrSandboxDir, issue, result) {
84
141
  if (!boardOrSandboxDir)
85
142
  return;
86
- const reviewsDir = join(boardOrSandboxDir, 'reviews');
87
- if (!existsSync(reviewsDir))
88
- mkdirSync(reviewsDir, { recursive: true });
143
+ const dir = reviewsDirOf(boardOrSandboxDir);
144
+ mkdirSync(dir, { recursive: true });
89
145
  try {
90
- writeFileSync(join(reviewsDir, `${issue.id}-${Date.now()}.json`), JSON.stringify(result, null, 2), 'utf-8');
146
+ writeStampedRecord(dir, issue.id, JSON.stringify(result, null, 2));
91
147
  }
92
148
  catch { }
93
149
  }
@@ -96,8 +152,10 @@ export function appendReviewFeedback(pmDir, issue, result) {
96
152
  try {
97
153
  let content = readFileSync(fullPath, 'utf-8');
98
154
  const failedChecks = result.checks.filter(c => !c.passed);
99
- const feedback = failedChecks.map(c => ` - ${c.name}: ${c.details}`).join('\n');
100
- const entry = `- Review failed (${new Date().toISOString().split('T')[0]}): ${failedChecks.length} check(s) failed\n${feedback}`;
155
+ const entry = [
156
+ `- Review failed (${new Date().toISOString().split('T')[0]}): ${failedChecks.length} check(s) failed`,
157
+ ...failedChecks.flatMap(renderFeedbackCheck),
158
+ ].join('\n');
101
159
  if (content.includes('## Activity')) {
102
160
  content = content.replace(/## Activity/, `## Activity\n${entry}`);
103
161
  }
@@ -108,6 +166,14 @@ export function appendReviewFeedback(pmDir, issue, result) {
108
166
  }
109
167
  catch { }
110
168
  }
169
+ function renderFeedbackCheck(check) {
170
+ const lines = [` - ${check.name}: ${check.details}`];
171
+ if (check.evidence?.trim())
172
+ lines.push(` - evidence: ${check.evidence.trim()}`);
173
+ if (check.remediation?.trim())
174
+ lines.push(` - fix: ${check.remediation.trim()}`);
175
+ return lines;
176
+ }
111
177
  function balancedObjectAt(text, start) {
112
178
  const end = balancedObjectEnd(text, start);
113
179
  return end === -1 ? null : text.slice(start, end);
@@ -148,27 +214,38 @@ function lastTopLevelVerdict(output) {
148
214
  }
149
215
  export function parseReviewOutput(issueId, issueType, output) {
150
216
  const parsed = extractReviewVerdict(output);
151
- if (parsed) {
152
- return {
153
- issueId,
154
- issueType,
155
- passed: !!parsed.passed,
156
- checks: Array.isArray(parsed.checks) ? parsed.checks.map((c) => ({
157
- name: String(c.name ?? 'unknown'),
158
- passed: !!c.passed,
159
- details: String(c.details ?? ''),
160
- })) : [],
161
- reviewedAt: new Date().toISOString(),
162
- };
163
- }
164
- return incompleteReviewResult(issueId, issueType, 'Could not parse review output');
217
+ if (!parsed)
218
+ return incompleteReviewResult(issueId, issueType, 'Could not parse review output');
219
+ const passed = !!parsed.passed;
220
+ const blocker = passed ? null : parseReviewBlocker(parsed.blocker);
221
+ return {
222
+ issueId,
223
+ issueType,
224
+ passed,
225
+ checks: Array.isArray(parsed.checks) ? parsed.checks.map(parseReviewCheck) : [],
226
+ reviewedAt: new Date().toISOString(),
227
+ confidence: parseReviewConfidence(parsed.confidence),
228
+ disposition: passed ? 'pass' : blocker ? 'blocked' : 'revise',
229
+ ...(blocker ? { blocker } : {}),
230
+ };
231
+ }
232
+ function parseReviewCheck(raw) {
233
+ const evidence = String(raw.evidence ?? '').trim();
234
+ const remediation = String(raw.remediation ?? '').trim();
235
+ return {
236
+ name: String(raw.name ?? 'unknown'),
237
+ passed: !!raw.passed,
238
+ details: String(raw.details ?? ''),
239
+ ...(evidence ? { evidence } : {}),
240
+ ...(remediation ? { remediation } : {}),
241
+ };
165
242
  }
166
243
  export function hasFailedChecksWithoutDetails(result) {
167
244
  if (result.passed || isIncompleteReview(result))
168
245
  return false;
169
246
  return result.checks.some((c) => !c.passed && c.details.trim().length === 0);
170
247
  }
171
- async function runDetailsFollowUp(options, issueType, verdict, previousOutput) {
248
+ async function runDetailsFollowUp(options, issueType, verdict, previousOutput, target) {
172
249
  const failedNames = verdict.checks.filter((c) => !c.passed).map((c) => c.name);
173
250
  const prompt = [
174
251
  `You reviewed issue ${options.issue.id} (${options.issue.title}) and returned this verdict:`,
@@ -177,18 +254,18 @@ async function runDetailsFollowUp(options, issueType, verdict, previousOutput) {
177
254
  '```',
178
255
  '',
179
256
  `The failed checks ${failedNames.join(', ')} carry no details. The implementing agent cannot act on a bare label.`,
180
- 'Restate the SAME verdict as one JSON object with, for every failed check, a specific `details` string naming what is wrong and what would satisfy the check (file, criterion, behaviour). Do not change which checks passed or failed.',
257
+ 'Restate the SAME verdict as one JSON object with, for every failed check, a specific `details` string naming what is wrong, an `evidence` string citing what you read that shows it (file:line, a quoted criterion, a command and its output), and a `remediation` string naming the concrete change that would satisfy the check. Do not change which checks passed or failed.',
181
258
  'Output EXACTLY one JSON object on its own line (no markdown fencing):',
182
- '{"passed": false, "checks": [{"name": "criteria_met", "passed": false, "details": "..."}]}',
259
+ '{"passed": false, "confidence": "high", "checks": [{"name": "criteria_met", "passed": false, "details": "...", "evidence": "...", "remediation": "..."}]}',
183
260
  ].join('\n');
184
261
  try {
185
262
  const stream = createVerdictStream(options.onOutput);
186
263
  const runner = createPlanRunner('review', {
187
264
  workingDir: options.workingDir,
188
265
  prompt,
189
- engineId: REVIEW_GATE_ENGINE,
190
- model: REVIEW_GATE_MODEL,
191
- effortLevel: REVIEW_GATE_EFFORT_LEVEL,
266
+ engineId: target.engine,
267
+ model: target.model,
268
+ effortLevel: target.effortLevel,
192
269
  verbose: true,
193
270
  inferenceOnly: true,
194
271
  policy: 'NONE',
@@ -342,17 +419,19 @@ export async function runReviewPipeline(options, callbacks) {
342
419
  const { issue, pmDir, workingDir, executionDir, boardDir, extraEnv, abortSignal, qualityDelta } = options;
343
420
  const reviewDir = boardDir ?? pmDir;
344
421
  const attempts = getReviewAttemptCount(reviewDir, issue);
422
+ const previous = readLatestReviewResult(reviewDir, issue);
345
423
  if (attempts >= MAX_REVIEW_ATTEMPTS) {
346
- await callbacks.setStatus(issue.path, 'cancelled');
347
- await appendCancellationNote(pmDir, issue, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`, callbacks.warn);
348
- callbacks.onReviewProgress(issue.id, 'max_attempts');
349
- callbacks.onIssueAbandoned(issue.id, `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`, attempts);
350
- callbacks.onOutput(issue.id, 'Review: max attempts reached, cancelling issue to unblock dependents');
424
+ await abandonIssue(options, callbacks, {
425
+ action: 'abandon',
426
+ reason: `Review failed ${attempts} times — cancelled to unblock dependents; the issue likely needs restructuring`,
427
+ attempts,
428
+ });
351
429
  return false;
352
430
  }
353
431
  await callbacks.setStatus(issue.path, 'in_review');
354
432
  callbacks.onReviewProgress(issue.id, 'reviewing');
355
433
  const outputPath = resolveOutputPath(issue, workingDir, boardDir);
434
+ const reviewTarget = resolveReviewTarget(issue);
356
435
  const reviewStart = Date.now();
357
436
  const reviewSessionId = `improv-${reviewStart}-pm-${issue.id}-review`;
358
437
  const result = await reviewIssue({
@@ -361,15 +440,16 @@ export async function runReviewPipeline(options, callbacks) {
361
440
  pmDir,
362
441
  outputPath,
363
442
  onOutput: (text) => callbacks.onOutput(issue.id, text),
364
- onPrompt: (prompt) => callbacks.onReviewPrompt(issue.id, prompt),
443
+ onPrompt: (prompt) => callbacks.onReviewPrompt(issue.id, prompt, reviewTarget),
444
+ reviewTarget,
365
445
  onComplete: async (sessionResult, prompt) => {
366
446
  persistTranscriptSession(workingDir, {
367
447
  sessionId: reviewSessionId,
368
448
  userPrompt: prompt,
369
449
  result: sessionResult,
370
450
  execStart: reviewStart,
371
- engine: REVIEW_GATE_ENGINE,
372
- model: REVIEW_GATE_MODEL,
451
+ engine: reviewTarget.engine,
452
+ model: reviewTarget.model,
373
453
  });
374
454
  await appendOutputHistoryEntry(pmDir, issue.path, {
375
455
  sessionId: reviewSessionId,
@@ -390,20 +470,49 @@ export async function runReviewPipeline(options, callbacks) {
390
470
  persistReviewResult(reviewDir, issue, stamped);
391
471
  callbacks.onReviewResult?.(issue.id, stamped);
392
472
  }
393
- if (result.passed) {
394
- await callbacks.setStatus(issue.path, 'done');
395
- callbacks.onReviewProgress(issue.id, 'passed');
396
- callbacks.onIssueCompleted(issue);
397
- return true;
398
- }
399
473
  if (isIncompleteReview(result)) {
400
474
  callbacks.onOutput(issue.id, `Review: ${result.checks[0]?.details ?? 'incomplete'} — leaving for retry.`);
401
475
  callbacks.onIssueError(issue.id, `Review incomplete: ${result.checks[0]?.details ?? 'no verdict'}`);
402
476
  return false;
403
477
  }
478
+ const recovery = decideReviewRecovery(result, previous, attempts);
479
+ if (recovery.action === 'complete') {
480
+ switch (issue.reviewGate) {
481
+ case 'none':
482
+ case 'auto':
483
+ await callbacks.setStatus(issue.path, 'done');
484
+ callbacks.onReviewProgress(issue.id, 'passed');
485
+ callbacks.onIssueCompleted(issue);
486
+ return true;
487
+ case 'required':
488
+ await callbacks.setStatus(issue.path, 'awaiting_approval');
489
+ callbacks.onReviewProgress(issue.id, 'awaiting_approval');
490
+ return false;
491
+ default: {
492
+ const exhaustive = issue.reviewGate;
493
+ throw new Error(`Unknown review gate: ${exhaustive}`);
494
+ }
495
+ }
496
+ }
497
+ if (recovery.action === 'abandon') {
498
+ appendReviewFeedback(pmDir, issue, result);
499
+ await abandonIssue(options, callbacks, recovery);
500
+ return false;
501
+ }
404
502
  await callbacks.setStatus(issue.path, 'todo');
405
503
  appendReviewFeedback(pmDir, issue, result);
406
504
  callbacks.onReviewProgress(issue.id, 'failed');
407
- callbacks.onIssueError(issue.id, `Review failed: ${result.checks.filter(c => !c.passed).map(c => c.name).join(', ')}`);
505
+ callbacks.onIssueError(issue.id, recovery.reason);
408
506
  return false;
409
507
  }
508
+ async function abandonIssue(options, callbacks, recovery) {
509
+ const { issue, pmDir } = options;
510
+ const note = recovery.blocker?.humanAction
511
+ ? `${recovery.reason} To unblock: ${recovery.blocker.humanAction}`
512
+ : recovery.reason;
513
+ await callbacks.setStatus(issue.path, 'cancelled');
514
+ await appendCancellationNote(pmDir, issue, note, callbacks.warn);
515
+ callbacks.onReviewProgress(issue.id, 'max_attempts');
516
+ callbacks.onIssueAbandoned(issue.id, note, recovery.attempts);
517
+ callbacks.onOutput(issue.id, `Review: ${note}\n`);
518
+ }