@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.
- package/bin/commands/minions.js +19 -0
- package/bin/minion.js +33 -4
- package/dist/server/cli/headless/claude-invoker-process.js +23 -7
- package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
- package/dist/server/cli/headless/haiku-assessments.js +2 -1
- package/dist/server/cli/headless/mcp-config.js +2 -1
- package/dist/server/engines/claude/claude-command.js +1 -0
- package/dist/server/engines/factory.js +2 -0
- package/dist/server/engines/hermes/HermesEngine.js +112 -219
- package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
- package/dist/server/index.js +10 -4
- package/dist/server/mcp/bouncer-integration.js +3 -2
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
- package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
- package/dist/server/mcp/classifier/telemetry.js +0 -0
- package/dist/server/mcp/permission-channels.js +176 -0
- package/dist/server/mcp/security-analysis.js +9 -1
- package/dist/server/mcp/security-audit.js +22 -2
- package/dist/server/mcp/security-patterns.js +73 -1
- package/dist/server/mcp/server.js +80 -50
- package/dist/server/routes/internal.js +6 -1
- package/dist/server/routes/notifications.js +6 -18
- package/dist/server/server-setup.js +24 -48
- package/dist/server/services/analytics.js +4 -2
- package/dist/server/services/browser/host-mock.js +6 -1
- package/dist/server/services/browser/host.js +27 -3
- package/dist/server/services/browser/reasoning-agent.js +3 -1
- package/dist/server/services/chain/chain-engine.js +494 -0
- package/dist/server/services/chain/chain-store.js +542 -0
- package/dist/server/services/git/haiku.js +2 -2
- package/dist/server/services/plan/agents/check-injection.md +43 -2
- package/dist/server/services/plan/agents/review-code.md +62 -3
- package/dist/server/services/plan/agents/review-quality.md +52 -2
- package/dist/server/services/plan/board-export.js +4 -0
- package/dist/server/services/plan/composer-prompt.js +27 -8
- package/dist/server/services/plan/composer.js +10 -4
- package/dist/server/services/plan/config-installer.js +4 -83
- package/dist/server/services/plan/executor.js +155 -73
- package/dist/server/services/plan/issue-effort.js +9 -21
- package/dist/server/services/plan/issue-prompt-builder.js +72 -9
- package/dist/server/services/plan/parser-core.js +6 -1
- package/dist/server/services/plan/quality-delta.js +6 -24
- package/dist/server/services/plan/readiness-planner.js +12 -4
- package/dist/server/services/plan/record-file.js +52 -0
- package/dist/server/services/plan/review-approval.js +64 -0
- package/dist/server/services/plan/review-gate.js +186 -77
- package/dist/server/services/plan/review-outcome.js +146 -0
- package/dist/server/services/plan/review-report.js +28 -1
- package/dist/server/services/plan/review-target.js +57 -0
- package/dist/server/services/plan/state-reconciler.js +3 -3
- package/dist/server/services/plan/template-diff.js +2 -0
- package/dist/server/services/plan/template-instantiator.js +4 -0
- package/dist/server/services/plan/watcher.js +11 -1
- package/dist/server/services/platform-reconnect.js +7 -0
- package/dist/server/services/platform-token-lifecycle.js +2 -1
- package/dist/server/services/platform.js +293 -50
- package/dist/server/services/relay-http-client.js +107 -0
- package/dist/server/services/schedule/agent-schedule-store.js +69 -8
- package/dist/server/services/schedule/agent-scheduler.js +48 -7
- package/dist/server/services/schedule/daily-timing.js +52 -0
- package/dist/server/services/schedule/prompt-file.js +55 -0
- package/dist/server/services/schedule/run-bootstrap.js +20 -5
- package/dist/server/services/schedule/schedule-store.js +54 -4
- package/dist/server/services/schedule/schedule-wire.js +17 -0
- package/dist/server/services/schedule/scheduler.js +43 -9
- package/dist/server/services/sdk/agent-schedule.js +294 -0
- package/dist/server/services/sdk/agents.js +95 -13
- package/dist/server/services/sdk/app-tools.js +44 -7
- package/dist/server/services/sdk/atomic-write.js +75 -0
- package/dist/server/services/sdk/browser.js +5 -1
- package/dist/server/services/sdk/composer.js +2 -0
- package/dist/server/services/sdk/file-change-notify.js +60 -13
- package/dist/server/services/sdk/files-app.js +12 -7
- package/dist/server/services/sdk/files-search.js +14 -0
- package/dist/server/services/sdk/files-transfer.js +1 -1
- package/dist/server/services/sdk/files-watch.js +721 -0
- package/dist/server/services/sdk/files-workspace.js +37 -11
- package/dist/server/services/sdk/inference-claude.js +4 -2
- package/dist/server/services/sdk/inference.js +16 -2
- package/dist/server/services/sdk/pm-chain.js +0 -0
- package/dist/server/services/sdk/pm-schedule.js +72 -15
- package/dist/server/services/sdk/pm.js +22 -0
- package/dist/server/services/sdk/rate-limits.js +32 -0
- package/dist/server/services/sdk/registry.js +40 -2
- package/dist/server/services/sdk/terminal.js +1 -1
- package/dist/server/services/sentry.js +5 -1
- package/dist/server/services/terminal/pty-manager.js +26 -11
- package/dist/server/services/terminal/pty-utils.js +12 -1
- package/dist/server/services/terminal/terminal-modes.js +87 -0
- package/dist/server/services/timeline/assembler.js +2 -2
- package/dist/server/services/timeline/index.js +1 -1
- package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
- package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
- package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
- package/dist/server/services/websocket/browser-handlers.js +5 -2
- package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
- package/dist/server/services/websocket/file-transfer-http.js +12 -6
- package/dist/server/services/websocket/handler.js +185 -33
- package/dist/server/services/websocket/msg-id-tracker.js +94 -19
- package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
- package/dist/server/services/websocket/plan-handlers.js +10 -5
- package/dist/server/services/websocket/plan-helpers.js +23 -0
- package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
- package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
- package/dist/server/services/websocket/schedule-handlers.js +8 -2
- package/dist/server/services/websocket/sdk-agent-host.js +5 -1
- package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
- package/dist/server/services/websocket/sdk-browser-host.js +11 -4
- package/dist/server/services/websocket/sdk-handlers.js +6 -1
- package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
- package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
- package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
- package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
- package/dist/server/services/websocket/settings-handlers.js +6 -18
- package/dist/server/services/websocket/tab-broadcast.js +5 -1
- package/dist/server/services/websocket/terminal-handlers.js +51 -6
- package/dist/server/services/websocket/types.js +6 -5
- package/dist/server/services/websocket/viewer-interest.js +30 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
47
|
-
const blocked =
|
|
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
|
|
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
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
137
|
+
return null;
|
|
81
138
|
}
|
|
82
139
|
}
|
|
83
140
|
export function persistReviewResult(boardOrSandboxDir, issue, result) {
|
|
84
141
|
if (!boardOrSandboxDir)
|
|
85
142
|
return;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
mkdirSync(reviewsDir, { recursive: true });
|
|
143
|
+
const dir = reviewsDirOf(boardOrSandboxDir);
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
89
145
|
try {
|
|
90
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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:
|
|
190
|
-
model:
|
|
191
|
-
effortLevel:
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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:
|
|
372
|
-
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,
|
|
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
|
+
}
|