@zigrivers/mmr 1.3.0 → 1.4.1
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/README.md +444 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/ack.d.ts +11 -0
- package/dist/commands/ack.d.ts.map +1 -0
- package/dist/commands/ack.js +123 -0
- package/dist/commands/ack.js.map +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +248 -14
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/jobs.d.ts.map +1 -1
- package/dist/commands/jobs.js +3 -4
- package/dist/commands/jobs.js.map +1 -1
- package/dist/commands/reconcile.d.ts.map +1 -1
- package/dist/commands/reconcile.js +12 -5
- package/dist/commands/reconcile.js.map +1 -1
- package/dist/commands/results.d.ts.map +1 -1
- package/dist/commands/results.js +13 -5
- package/dist/commands/results.js.map +1 -1
- package/dist/commands/review.d.ts +25 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +459 -44
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/sessions.d.ts +58 -0
- package/dist/commands/sessions.d.ts.map +1 -0
- package/dist/commands/sessions.js +266 -0
- package/dist/commands/sessions.js.map +1 -0
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +2 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/config/defaults.d.ts +2 -2
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +76 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts +22 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +279 -36
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +897 -53
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +155 -4
- package/dist/config/schema.js.map +1 -1
- package/dist/core/ack-store.d.ts +109 -0
- package/dist/core/ack-store.d.ts.map +1 -0
- package/dist/core/ack-store.js +363 -0
- package/dist/core/ack-store.js.map +1 -0
- package/dist/core/auth.d.ts +10 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +106 -35
- package/dist/core/auth.js.map +1 -1
- package/dist/core/compensator.d.ts +33 -4
- package/dist/core/compensator.d.ts.map +1 -1
- package/dist/core/compensator.js +120 -15
- package/dist/core/compensator.js.map +1 -1
- package/dist/core/diff-introspect.d.ts +21 -0
- package/dist/core/diff-introspect.d.ts.map +1 -0
- package/dist/core/diff-introspect.js +42 -0
- package/dist/core/diff-introspect.js.map +1 -0
- package/dist/core/dispatcher.d.ts +10 -0
- package/dist/core/dispatcher.d.ts.map +1 -1
- package/dist/core/dispatcher.js +91 -20
- package/dist/core/dispatcher.js.map +1 -1
- package/dist/core/git-show.d.ts +31 -0
- package/dist/core/git-show.d.ts.map +1 -0
- package/dist/core/git-show.js +72 -0
- package/dist/core/git-show.js.map +1 -0
- package/dist/core/host-isolation.d.ts +24 -0
- package/dist/core/host-isolation.d.ts.map +1 -0
- package/dist/core/host-isolation.js +107 -0
- package/dist/core/host-isolation.js.map +1 -0
- package/dist/core/http-dispatcher.d.ts +20 -0
- package/dist/core/http-dispatcher.d.ts.map +1 -0
- package/dist/core/http-dispatcher.js +125 -0
- package/dist/core/http-dispatcher.js.map +1 -0
- package/dist/core/job-store.d.ts +7 -1
- package/dist/core/job-store.d.ts.map +1 -1
- package/dist/core/job-store.js +21 -1
- package/dist/core/job-store.js.map +1 -1
- package/dist/core/jsonpath.d.ts +15 -0
- package/dist/core/jsonpath.d.ts.map +1 -0
- package/dist/core/jsonpath.js +63 -0
- package/dist/core/jsonpath.js.map +1 -0
- package/dist/core/oss-examples.d.ts +18 -0
- package/dist/core/oss-examples.d.ts.map +1 -0
- package/dist/core/oss-examples.js +66 -0
- package/dist/core/oss-examples.js.map +1 -0
- package/dist/core/parser.d.ts +8 -3
- package/dist/core/parser.d.ts.map +1 -1
- package/dist/core/parser.js +157 -6
- package/dist/core/parser.js.map +1 -1
- package/dist/core/project-root.d.ts +10 -0
- package/dist/core/project-root.d.ts.map +1 -0
- package/dist/core/project-root.js +23 -0
- package/dist/core/project-root.js.map +1 -0
- package/dist/core/reconciler.d.ts +1 -1
- package/dist/core/reconciler.d.ts.map +1 -1
- package/dist/core/reconciler.js +100 -18
- package/dist/core/reconciler.js.map +1 -1
- package/dist/core/redact.d.ts +17 -0
- package/dist/core/redact.d.ts.map +1 -0
- package/dist/core/redact.js +140 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/results-pipeline.d.ts +8 -2
- package/dist/core/results-pipeline.d.ts.map +1 -1
- package/dist/core/results-pipeline.js +50 -3
- package/dist/core/results-pipeline.js.map +1 -1
- package/dist/core/runtime-probe.d.ts +14 -0
- package/dist/core/runtime-probe.d.ts.map +1 -0
- package/dist/core/runtime-probe.js +57 -0
- package/dist/core/runtime-probe.js.map +1 -0
- package/dist/core/stable-id.d.ts +19 -0
- package/dist/core/stable-id.d.ts.map +1 -0
- package/dist/core/stable-id.js +148 -0
- package/dist/core/stable-id.js.map +1 -0
- package/dist/core/trust-mode.d.ts +29 -0
- package/dist/core/trust-mode.d.ts.map +1 -0
- package/dist/core/trust-mode.js +103 -0
- package/dist/core/trust-mode.js.map +1 -0
- package/dist/formatters/markdown.d.ts.map +1 -1
- package/dist/formatters/markdown.js +9 -0
- package/dist/formatters/markdown.js.map +1 -1
- package/dist/formatters/text.d.ts.map +1 -1
- package/dist/formatters/text.js +9 -0
- package/dist/formatters/text.js.map +1 -1
- package/dist/types.d.ts +44 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/review.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
2
|
import { execFileSync } from 'node:child_process';
|
|
5
3
|
import { loadConfig } from '../config/loader.js';
|
|
6
4
|
import { JobStore } from '../core/job-store.js';
|
|
7
|
-
import { checkInstalled, checkAuth } from '../core/auth.js';
|
|
5
|
+
import { checkInstalled, checkAuth, checkHttpAuth } from '../core/auth.js';
|
|
8
6
|
import { assemblePrompt } from '../core/prompt.js';
|
|
9
7
|
import { dispatchChannel } from '../core/dispatcher.js';
|
|
8
|
+
import { dispatchHttpChannel } from '../core/http-dispatcher.js';
|
|
10
9
|
import { runResultsPipeline } from '../core/results-pipeline.js';
|
|
11
|
-
import {
|
|
10
|
+
import { buildReviewAckStore } from '../core/ack-store.js';
|
|
11
|
+
import { classifyTrustMode } from '../core/trust-mode.js';
|
|
12
|
+
import { detectConfigChanges } from '../core/diff-introspect.js';
|
|
13
|
+
import { getCompensatingChannels, dispatchCompensatingPasses, getCompensatorChannel, resolveCompensatorChannelName, resolveCompensatorOutputParser, } from '../core/compensator.js';
|
|
14
|
+
import { formatJson } from '../formatters/json.js';
|
|
15
|
+
import { formatText } from '../formatters/text.js';
|
|
16
|
+
import { formatMarkdown } from '../formatters/markdown.js';
|
|
17
|
+
import { getSessionStore, resolveJobsDir, resolveSessionRoot, isValidSessionId, SESSION_ID_RULE, } from './sessions.js';
|
|
12
18
|
/** 10MB buffer for large diffs (default is ~1MB which can throw) */
|
|
13
19
|
const MAX_DIFF_BUFFER = 10 * 1024 * 1024;
|
|
14
20
|
/**
|
|
@@ -37,6 +43,120 @@ function resolveDiff(args) {
|
|
|
37
43
|
// Default: unstaged changes
|
|
38
44
|
return execFileSync('git', ['diff'], { encoding: 'utf-8', maxBuffer: MAX_DIFF_BUFFER });
|
|
39
45
|
}
|
|
46
|
+
/** Stamp proposed config/ack changes onto an output object (trust transparency). */
|
|
47
|
+
function annotateProposedChanges(target, changes) {
|
|
48
|
+
if (changes.ack_files_changed.length > 0)
|
|
49
|
+
target.proposed_acks = changes.ack_files_changed;
|
|
50
|
+
if (changes.config_file_changed)
|
|
51
|
+
target.proposed_config_change = true;
|
|
52
|
+
}
|
|
53
|
+
function formatReconciledResults(results, outputFormat) {
|
|
54
|
+
if (outputFormat === 'text')
|
|
55
|
+
return formatText(results);
|
|
56
|
+
if (outputFormat === 'markdown')
|
|
57
|
+
return formatMarkdown(results);
|
|
58
|
+
return formatJson(results);
|
|
59
|
+
}
|
|
60
|
+
function buildMaxRoundsExceededResult(session, round, maxRounds, fixThreshold) {
|
|
61
|
+
return {
|
|
62
|
+
job_id: `session-${session}`,
|
|
63
|
+
verdict: 'needs-user-decision',
|
|
64
|
+
fix_threshold: fixThreshold,
|
|
65
|
+
advisory_count: 0,
|
|
66
|
+
approved: false,
|
|
67
|
+
summary: `max_rounds_exceeded: session="${session}" round=${round} > max_rounds=${maxRounds}`,
|
|
68
|
+
reconciled_findings: [],
|
|
69
|
+
per_channel: {},
|
|
70
|
+
metadata: {
|
|
71
|
+
channels_dispatched: 0,
|
|
72
|
+
channels_completed: 0,
|
|
73
|
+
channels_partial: 0,
|
|
74
|
+
total_elapsed: '0s',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the list of channels to dispatch.
|
|
80
|
+
* - Filters out abstract channels from default resolution.
|
|
81
|
+
* - Rejects explicit abstract channel requests with a clear error.
|
|
82
|
+
* - Honors explicit --channels list when provided; otherwise enabled channels
|
|
83
|
+
* minus channels_disabled.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveDispatchChannels(channels, explicit, disabled) {
|
|
86
|
+
const isDispatchable = (name, explicitRequest = false) => {
|
|
87
|
+
const ch = channels[name];
|
|
88
|
+
if (!ch)
|
|
89
|
+
throw new Error(`Channel "${name}" not found in config`);
|
|
90
|
+
if (ch.abstract === true) {
|
|
91
|
+
if (explicitRequest) {
|
|
92
|
+
throw new Error(`Channel "${name}" is abstract and cannot be dispatched`);
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
if (explicit !== undefined) {
|
|
99
|
+
return explicit.filter((name) => isDispatchable(name, true));
|
|
100
|
+
}
|
|
101
|
+
return Object.entries(channels)
|
|
102
|
+
.filter(([name, ch]) => ch.enabled && !disabled.has(name) && !ch.abstract)
|
|
103
|
+
.map(([name]) => name);
|
|
104
|
+
}
|
|
105
|
+
function resolveTemplateCriteria(config, template) {
|
|
106
|
+
return template && config.templates?.[template]
|
|
107
|
+
? config.templates[template].criteria
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
function buildChannelPrompt(channel, prompt) {
|
|
111
|
+
const wrapper = channel.prompt_wrapper ?? '{{prompt}}';
|
|
112
|
+
return wrapper === '{{prompt}}'
|
|
113
|
+
? prompt
|
|
114
|
+
: wrapper.replaceAll('{{prompt}}', () => prompt);
|
|
115
|
+
}
|
|
116
|
+
function channelStatusFromAuthResult(status) {
|
|
117
|
+
return status === 'not_installed' ? 'not_installed'
|
|
118
|
+
: status === 'failed' ? 'auth_failed'
|
|
119
|
+
: status === 'timeout' ? 'timeout'
|
|
120
|
+
: 'skipped';
|
|
121
|
+
}
|
|
122
|
+
export async function checkConfiguredCompensatorAvailability(config) {
|
|
123
|
+
// undefined when no compensator is configured (default `claude -p` fallback).
|
|
124
|
+
const compChannel = getCompensatorChannel(config);
|
|
125
|
+
if (!compChannel)
|
|
126
|
+
return { status: 'ok', auth: 'ok' };
|
|
127
|
+
// HTTP compensator: probe over the wire (no install/command step).
|
|
128
|
+
if (compChannel.kind === 'http') {
|
|
129
|
+
const httpAuth = await checkHttpAuth(compChannel);
|
|
130
|
+
if (httpAuth.status === 'ok')
|
|
131
|
+
return { status: 'ok', auth: 'ok' };
|
|
132
|
+
const httpStatus = channelStatusFromAuthResult(httpAuth.status);
|
|
133
|
+
return {
|
|
134
|
+
status: httpStatus,
|
|
135
|
+
auth: httpStatus === 'skipped' ? 'skipped' : 'failed',
|
|
136
|
+
recovery: httpAuth.recovery,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Subprocess compensator.
|
|
140
|
+
if (!compChannel.command) {
|
|
141
|
+
return { status: 'skipped', auth: 'skipped', recovery: 'Compensator channel has no command' };
|
|
142
|
+
}
|
|
143
|
+
const cmd = compChannel.command.split(' ')[0];
|
|
144
|
+
const installed = await checkInstalled(cmd);
|
|
145
|
+
if (!installed) {
|
|
146
|
+
return { status: 'not_installed', auth: 'failed', recovery: `${cmd} not found on PATH` };
|
|
147
|
+
}
|
|
148
|
+
const authResult = await checkAuth(compChannel);
|
|
149
|
+
if (authResult.status === 'ok')
|
|
150
|
+
return { status: 'ok', auth: 'ok' };
|
|
151
|
+
if (authResult.status === 'skipped')
|
|
152
|
+
return { status: 'ok', auth: 'skipped' };
|
|
153
|
+
const status = channelStatusFromAuthResult(authResult.status);
|
|
154
|
+
return {
|
|
155
|
+
status,
|
|
156
|
+
auth: status === 'skipped' ? 'skipped' : 'failed',
|
|
157
|
+
recovery: authResult.recovery,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
40
160
|
export const reviewCommand = {
|
|
41
161
|
command: 'review',
|
|
42
162
|
describe: 'Dispatch a multi-model code review',
|
|
@@ -88,36 +208,204 @@ export const reviewCommand = {
|
|
|
88
208
|
type: 'string',
|
|
89
209
|
describe: 'Output format',
|
|
90
210
|
choices: ['json', 'text', 'markdown'],
|
|
211
|
+
})
|
|
212
|
+
.option('session', {
|
|
213
|
+
type: 'string',
|
|
214
|
+
describe: 'Session id (letters, digits, _ and -; reserved names like con/index/__proto__ are rejected)',
|
|
215
|
+
})
|
|
216
|
+
.option('round', {
|
|
217
|
+
type: 'number',
|
|
218
|
+
describe: 'One-based round counter within the session',
|
|
219
|
+
})
|
|
220
|
+
.option('max-rounds', {
|
|
221
|
+
type: 'number',
|
|
222
|
+
describe: 'Hard cap on rounds. Default 5 when --session is set without --max-rounds.',
|
|
223
|
+
})
|
|
224
|
+
.option('accept-new-acks', {
|
|
225
|
+
type: 'boolean',
|
|
226
|
+
default: false,
|
|
227
|
+
describe: 'Trust ack files newly introduced in the diff under review',
|
|
228
|
+
})
|
|
229
|
+
.option('trust-project-acks', {
|
|
230
|
+
type: 'boolean',
|
|
231
|
+
default: false,
|
|
232
|
+
describe: 'Trust working-tree project acks in non-Git or untrusted-HEAD modes',
|
|
233
|
+
})
|
|
234
|
+
.option('trust-project-config', {
|
|
235
|
+
type: 'boolean',
|
|
236
|
+
default: false,
|
|
237
|
+
describe: 'Trust working-tree .mmr.yaml channel config in untrusted modes',
|
|
238
|
+
})
|
|
239
|
+
.option('config-base-ref', {
|
|
240
|
+
type: 'string',
|
|
241
|
+
describe: 'Load project .mmr.yaml and acks from this trusted Git ref instead of HEAD',
|
|
91
242
|
})
|
|
92
243
|
.option('sync', {
|
|
93
244
|
type: 'boolean',
|
|
94
245
|
describe: 'Run full review pipeline: dispatch, parse, reconcile, and output results with verdict',
|
|
95
246
|
default: false,
|
|
247
|
+
})
|
|
248
|
+
.option('dry-run', {
|
|
249
|
+
type: 'boolean',
|
|
250
|
+
default: false,
|
|
251
|
+
describe: 'Resolve diff and assemble prompt without dispatching channels',
|
|
252
|
+
})
|
|
253
|
+
.check((argv) => {
|
|
254
|
+
if (typeof argv.session === 'string' && !isValidSessionId(argv.session)) {
|
|
255
|
+
throw new Error(`Invalid session id. Must match ${SESSION_ID_RULE}`);
|
|
256
|
+
}
|
|
257
|
+
if (typeof argv.round === 'number' && argv.round < 1) {
|
|
258
|
+
throw new Error('round must be >= 1');
|
|
259
|
+
}
|
|
260
|
+
if (typeof argv.maxRounds === 'number' && argv.maxRounds < 1) {
|
|
261
|
+
throw new Error('max-rounds must be >= 1');
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
})
|
|
265
|
+
.middleware((argv) => {
|
|
266
|
+
if (argv.session !== undefined && argv.maxRounds === undefined) {
|
|
267
|
+
argv.maxRounds = 5;
|
|
268
|
+
}
|
|
96
269
|
}),
|
|
97
270
|
handler: async (args) => {
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
271
|
+
// 0. Classify the trust mode (§5 decision 1) and derive the project
|
|
272
|
+
// config/ack loading policy. The explicit --trust-project-config /
|
|
273
|
+
// --trust-project-acks opt-ins mean "honor the working-tree (diff)
|
|
274
|
+
// config/acks" and take precedence in ALL modes (they're an operator
|
|
275
|
+
// decision, not attacker-controllable); without them, base-ref mode
|
|
276
|
+
// loads from the trusted ref and untrusted-HEAD/non-git load nothing.
|
|
277
|
+
const cwd = process.cwd();
|
|
278
|
+
const trust = classifyTrustMode({ cwd, args });
|
|
279
|
+
const baseRef = trust.trust_mode === 'base-ref' ? trust.base_ref : undefined;
|
|
280
|
+
const trustWorkingTreeConfig = args.trustProjectConfig === true;
|
|
281
|
+
const trustWorkingTreeAcks = args.trustProjectAcks === true;
|
|
282
|
+
// --accept-new-acks means "honor the ack files added in the diff", so it
|
|
283
|
+
// must also LOAD the working-tree acks (not merely skip the gate) — else an
|
|
284
|
+
// accepted new ack would be ratified but never applied (loaded from the
|
|
285
|
+
// base ref, which doesn't have it). This loads the whole working-tree acks
|
|
286
|
+
// dir, but in base-ref/PR review the working tree IS the head (= base +
|
|
287
|
+
// the diff under review), so that's exactly "base acks + the accepted new
|
|
288
|
+
// acks" — not arbitrary over-trust — and it reproduces in results/reconcile
|
|
289
|
+
// via the persisted policy. The flag is an operator decision, not
|
|
290
|
+
// attacker-controllable.
|
|
291
|
+
const honorWorkingTreeAcks = trustWorkingTreeAcks || args.acceptNewAcks === true;
|
|
292
|
+
const refHint = trust.trust_mode === 'non-git'
|
|
293
|
+
? ' (non-git: no base ref to compare against).'
|
|
294
|
+
: '; prefer --config-base-ref <ref> for a trusted source.';
|
|
295
|
+
if (trustWorkingTreeConfig) {
|
|
296
|
+
console.error(`[mmr] warning: --trust-project-config is honoring the working-tree .mmr.yaml${refHint}`);
|
|
297
|
+
}
|
|
298
|
+
if (trustWorkingTreeAcks) {
|
|
299
|
+
console.error(`[mmr] warning: --trust-project-acks is honoring working-tree .mmr/acks/${refHint}`);
|
|
300
|
+
}
|
|
301
|
+
// 1. Load config per the trust policy: explicit opt-in → working tree;
|
|
302
|
+
// else base ref → git show; else skip project config entirely.
|
|
303
|
+
const cliOverrides = {
|
|
304
|
+
fix_threshold: args['fix-threshold'],
|
|
305
|
+
timeout: args.timeout,
|
|
306
|
+
format: args.format,
|
|
307
|
+
};
|
|
308
|
+
const projectTrust = trustWorkingTreeConfig
|
|
309
|
+
? { trustProjectConfig: true }
|
|
310
|
+
: baseRef !== undefined
|
|
311
|
+
? { configBaseRef: baseRef }
|
|
312
|
+
: { skipProjectConfig: true };
|
|
313
|
+
const config = loadConfig({ projectRoot: cwd, cliOverrides, ...projectTrust });
|
|
314
|
+
// Defense-in-depth: the yargs `.check()` rejects invalid ids on the CLI
|
|
315
|
+
// path, but the handler is also invoked directly by programmatic callers
|
|
316
|
+
// and tests that bypass `.check()`, so validate here too before any I/O.
|
|
317
|
+
if (args.session !== undefined && !isValidSessionId(args.session)) {
|
|
318
|
+
console.error(`Invalid session id: ${args.session} - must match ${SESSION_ID_RULE}`);
|
|
319
|
+
process.exitCode = 1;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const configCap = config.defaults.loop_control?.max_rounds_default ?? 5;
|
|
323
|
+
const maxRounds = args['max-rounds'] ?? args.maxRounds ?? configCap;
|
|
324
|
+
// Persist the EFFECTIVE policy (not the raw flags) so `mmr results`/
|
|
325
|
+
// `reconcile` rebuild the ack store the same way: base-ref mode → base ref;
|
|
326
|
+
// untrusted mode → only the working-tree trust opt-ins that actually applied.
|
|
327
|
+
const reviewControls = {
|
|
328
|
+
max_rounds: maxRounds,
|
|
329
|
+
accept_new_acks: args.acceptNewAcks === true,
|
|
330
|
+
trust_project_acks: honorWorkingTreeAcks,
|
|
331
|
+
trust_project_config: trustWorkingTreeConfig,
|
|
332
|
+
config_base_ref: baseRef,
|
|
333
|
+
};
|
|
334
|
+
if ((args.round ?? 1) > maxRounds) {
|
|
335
|
+
const outputFormat = (args.format ?? config.defaults.format ?? 'json');
|
|
336
|
+
const results = buildMaxRoundsExceededResult(args.session ?? 'default', args.round ?? 1, maxRounds, config.defaults.fix_threshold);
|
|
337
|
+
console.log(formatReconciledResults(results, outputFormat));
|
|
338
|
+
process.exitCode = 3;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
107
341
|
// 2. Resolve diff input
|
|
108
342
|
const diff = resolveDiff(args);
|
|
109
343
|
if (!diff.trim()) {
|
|
110
344
|
console.error('No diff content found. Provide --diff, --pr, --staged, or --base/--head.');
|
|
111
345
|
process.exit(1);
|
|
112
346
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
347
|
+
// 2a. In base-ref mode, a diff that proposes new project config/acks must
|
|
348
|
+
// not be auto-applied: force needs-user-decision unless the caller
|
|
349
|
+
// opts in (--trust-project-config for .mmr.yaml, --accept-new-acks for
|
|
350
|
+
// ack files). The reviewed content is loaded from the trusted base ref,
|
|
351
|
+
// so these surface the *proposed* changes for a human to ratify.
|
|
352
|
+
const diffChanges = detectConfigChanges(diff);
|
|
353
|
+
// The gate opt-outs are the same explicit flags that honor the working-tree
|
|
354
|
+
// config/acks above: passing --trust-project-config / --accept-new-acks both
|
|
355
|
+
// applies the proposed change AND ratifies it (no needs-user-decision).
|
|
356
|
+
const blockingConfigChange = baseRef !== undefined && diffChanges.config_file_changed && !trustWorkingTreeConfig;
|
|
357
|
+
// Bypassed by EITHER ack opt-in: --accept-new-acks (accept the diff's acks)
|
|
358
|
+
// or --trust-project-acks (trust all working-tree acks, which implies the
|
|
359
|
+
// new ones) — aligned with honorWorkingTreeAcks so the gate matches loading.
|
|
360
|
+
const blockingAckChange = baseRef !== undefined && diffChanges.ack_files_changed.length > 0 && !honorWorkingTreeAcks;
|
|
361
|
+
// 2b. Trust gate — UNCONDITIONAL (before dry-run, job creation, and
|
|
362
|
+
// dispatch), so it can't be bypassed by omitting --sync or using
|
|
363
|
+
// --dry-run. A base-ref diff proposing project config/acks short-
|
|
364
|
+
// circuits to needs-user-decision (exit 2) until a human ratifies.
|
|
365
|
+
if (blockingConfigChange || blockingAckChange) {
|
|
366
|
+
const outputFormat = (args.format ?? config.defaults.format ?? 'json');
|
|
367
|
+
const reason = blockingConfigChange && blockingAckChange
|
|
368
|
+
? 'the diff proposes project config (.mmr.yaml) and ack changes'
|
|
369
|
+
: blockingConfigChange
|
|
370
|
+
? 'the diff proposes a project config change (.mmr.yaml)'
|
|
371
|
+
: 'the diff proposes project ack changes (.mmr/acks/)';
|
|
372
|
+
const decision = {
|
|
373
|
+
verdict: 'needs-user-decision',
|
|
374
|
+
fix_threshold: config.defaults.fix_threshold,
|
|
375
|
+
reconciled_findings: [],
|
|
376
|
+
advisory_count: 0,
|
|
377
|
+
approved: false,
|
|
378
|
+
summary: `Needs user decision — ${reason}. Re-run with ` +
|
|
379
|
+
'--trust-project-config (.mmr.yaml) / --accept-new-acks (acks) to proceed.',
|
|
380
|
+
trust_mode: trust.trust_mode,
|
|
381
|
+
};
|
|
382
|
+
annotateProposedChanges(decision, diffChanges);
|
|
383
|
+
console.log(outputFormat === 'json' ? JSON.stringify(decision, null, 2) : String(decision.summary));
|
|
384
|
+
// exitCode + return (not process.exit) for consistency with the early
|
|
385
|
+
// guards and so the handler stays unit-testable without mocking exit.
|
|
386
|
+
process.exitCode = 2;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let sessionLink;
|
|
390
|
+
if (args.session !== undefined) {
|
|
391
|
+
sessionLink = { store: getSessionStore(), id: args.session };
|
|
392
|
+
}
|
|
393
|
+
// 3. Determine enabled channels — channels_disabled applies to the default list only;
|
|
394
|
+
// explicit --channels args override it (users know what they're asking for).
|
|
395
|
+
// Abstract channels are always filtered out.
|
|
396
|
+
const disabledSet = new Set(config.channels_disabled ?? []);
|
|
397
|
+
const channelNames = resolveDispatchChannels(config.channels, args.channels, disabledSet);
|
|
117
398
|
if (channelNames.length === 0) {
|
|
118
399
|
console.error('No channels enabled. Configure channels or pass --channels.');
|
|
119
400
|
process.exit(1);
|
|
120
401
|
}
|
|
402
|
+
const templateCriteria = resolveTemplateCriteria(config, args.template);
|
|
403
|
+
const prompt = assemblePrompt({
|
|
404
|
+
diff,
|
|
405
|
+
reviewCriteria: config.review_criteria,
|
|
406
|
+
templateCriteria,
|
|
407
|
+
focus: args.focus,
|
|
408
|
+
});
|
|
121
409
|
// 4. Auth-check each channel
|
|
122
410
|
const validChannels = [];
|
|
123
411
|
const authResults = {};
|
|
@@ -127,6 +415,23 @@ export const reviewCommand = {
|
|
|
127
415
|
authResults[name] = { status: 'skipped', recovery: `Channel "${name}" not found in config` };
|
|
128
416
|
continue;
|
|
129
417
|
}
|
|
418
|
+
if (chConfig.abstract) {
|
|
419
|
+
authResults[name] = { status: 'skipped', recovery: `Channel "${name}" is abstract and cannot run directly` };
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// HTTP channels have no command/install step — probe over the wire.
|
|
423
|
+
if (chConfig.kind === 'http') {
|
|
424
|
+
const authResult = await checkHttpAuth(chConfig);
|
|
425
|
+
authResults[name] = authResult;
|
|
426
|
+
if (authResult.status === 'ok') {
|
|
427
|
+
validChannels.push(name);
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!chConfig.command) {
|
|
432
|
+
authResults[name] = { status: 'skipped', recovery: `Channel "${name}" is missing command` };
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
130
435
|
const cmd = chConfig.command.split(' ')[0];
|
|
131
436
|
const installed = await checkInstalled(cmd);
|
|
132
437
|
if (!installed) {
|
|
@@ -139,6 +444,24 @@ export const reviewCommand = {
|
|
|
139
444
|
validChannels.push(name);
|
|
140
445
|
}
|
|
141
446
|
}
|
|
447
|
+
if (args['dry-run']) {
|
|
448
|
+
console.log('=== DRY RUN - no channels will be dispatched ===');
|
|
449
|
+
console.log(`Channels that would dispatch: ${validChannels.join(', ') || '(none)'}`);
|
|
450
|
+
for (const [name, status] of Object.entries(authResults)) {
|
|
451
|
+
if (!validChannels.includes(name)) {
|
|
452
|
+
console.log(` ${name}: ${status.status}${status.recovery ? ` — ${status.recovery}` : ''}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const name of validChannels) {
|
|
456
|
+
const ch = config.channels[name];
|
|
457
|
+
console.log(`\n--- Assembled prompt for ${name} ---`);
|
|
458
|
+
console.log(buildChannelPrompt(ch, prompt));
|
|
459
|
+
}
|
|
460
|
+
if (validChannels.length === 0) {
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
142
465
|
if (validChannels.length === 0) {
|
|
143
466
|
console.error('No channels passed auth check:');
|
|
144
467
|
for (const [name, result] of Object.entries(authResults)) {
|
|
@@ -147,21 +470,52 @@ export const reviewCommand = {
|
|
|
147
470
|
process.exit(1);
|
|
148
471
|
}
|
|
149
472
|
// 5. Create job
|
|
150
|
-
const jobsDir =
|
|
473
|
+
const jobsDir = resolveJobsDir();
|
|
151
474
|
const store = new JobStore(jobsDir);
|
|
152
475
|
const job = store.createJob({
|
|
153
476
|
fix_threshold: config.defaults.fix_threshold,
|
|
154
477
|
format: config.defaults.format,
|
|
155
478
|
channels: channelNames,
|
|
479
|
+
session_id: args.session,
|
|
480
|
+
round: args.round,
|
|
481
|
+
review_controls: reviewControls,
|
|
482
|
+
// Persist trust context so the pipeline re-surfaces it on every run.
|
|
483
|
+
trust_mode: trust.trust_mode,
|
|
484
|
+
...(diffChanges.ack_files_changed.length > 0 ? { proposed_acks: diffChanges.ack_files_changed } : {}),
|
|
485
|
+
...(diffChanges.config_file_changed ? { proposed_config_change: true } : {}),
|
|
156
486
|
});
|
|
487
|
+
if (sessionLink) {
|
|
488
|
+
try {
|
|
489
|
+
sessionLink.store.addJob(sessionLink.id, job.job_id, args.round ?? 1);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
// Linking failed after the job dir was created. Remove the orphaned job
|
|
493
|
+
// so the auto-link invariant holds: a job that records a session_id is
|
|
494
|
+
// always present in that session's jobs[] array (never half-linked).
|
|
495
|
+
// The invariant covers in-process failures; abrupt termination (SIGKILL,
|
|
496
|
+
// OOM) between createJob and addJob can still leave a half-linked job,
|
|
497
|
+
// but its job.json carries session_id so it remains traceable.
|
|
498
|
+
// Guard the cleanup so a failed rmSync can't mask the original error.
|
|
499
|
+
try {
|
|
500
|
+
fs.rmSync(store.getJobDir(job.job_id), { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// best-effort cleanup; fall through to report the original failure
|
|
504
|
+
}
|
|
505
|
+
console.error(`Failed to link job ${job.job_id} to session ${sessionLink.id}: ` +
|
|
506
|
+
(err instanceof Error ? err.message : String(err)));
|
|
507
|
+
// Set the exit code and return rather than process.exit(1): it lets
|
|
508
|
+
// stderr flush, and keeps the handler unit-testable without mocking
|
|
509
|
+
// process.exit. Returning aborts before channel dispatch, as intended.
|
|
510
|
+
process.exitCode = 1;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
157
514
|
// Record skipped/auth-failed channels in job metadata
|
|
158
515
|
for (const name of channelNames) {
|
|
159
516
|
if (!validChannels.includes(name)) {
|
|
160
517
|
const authStatus = authResults[name];
|
|
161
|
-
const channelStatus = authStatus?.status
|
|
162
|
-
: authStatus?.status === 'failed' ? 'auth_failed'
|
|
163
|
-
: authStatus?.status === 'timeout' ? 'timeout'
|
|
164
|
-
: 'skipped';
|
|
518
|
+
const channelStatus = channelStatusFromAuthResult(authStatus?.status ?? 'skipped');
|
|
165
519
|
store.updateChannel(job.job_id, name, {
|
|
166
520
|
status: channelStatus,
|
|
167
521
|
auth: channelStatus === 'skipped' ? 'skipped' : 'failed',
|
|
@@ -169,16 +523,6 @@ export const reviewCommand = {
|
|
|
169
523
|
});
|
|
170
524
|
}
|
|
171
525
|
}
|
|
172
|
-
// 6. Assemble prompt
|
|
173
|
-
const templateCriteria = args.template && config.templates?.[args.template]
|
|
174
|
-
? config.templates[args.template].criteria
|
|
175
|
-
: undefined;
|
|
176
|
-
const prompt = assemblePrompt({
|
|
177
|
-
diff,
|
|
178
|
-
reviewCriteria: config.review_criteria,
|
|
179
|
-
templateCriteria,
|
|
180
|
-
focus: args.focus,
|
|
181
|
-
});
|
|
182
526
|
// 7. Save prompt + diff to job store
|
|
183
527
|
store.savePrompt(job.job_id, prompt);
|
|
184
528
|
store.saveDiff(job.job_id, diff);
|
|
@@ -187,16 +531,34 @@ export const reviewCommand = {
|
|
|
187
531
|
const dispatches = [];
|
|
188
532
|
for (const name of validChannels) {
|
|
189
533
|
const chConfig = config.channels[name];
|
|
534
|
+
if (chConfig.kind === 'http') {
|
|
535
|
+
store.updateChannel(job.job_id, name, { output_parser: chConfig.output_parser });
|
|
536
|
+
dispatches.push(dispatchHttpChannel(store, job.job_id, name, {
|
|
537
|
+
channel: chConfig,
|
|
538
|
+
prompt: buildChannelPrompt(chConfig, prompt),
|
|
539
|
+
timeout: chConfig.timeout ?? config.defaults.timeout,
|
|
540
|
+
}));
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!chConfig.command) {
|
|
544
|
+
store.updateChannel(job.job_id, name, {
|
|
545
|
+
status: 'skipped',
|
|
546
|
+
recovery: `Channel "${name}" is missing command`,
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
190
550
|
store.updateChannel(job.job_id, name, { output_parser: chConfig.output_parser });
|
|
191
551
|
dispatches.push(dispatchChannel(store, job.job_id, name, {
|
|
192
552
|
command: chConfig.command,
|
|
193
|
-
prompt,
|
|
553
|
+
prompt: buildChannelPrompt(chConfig, prompt),
|
|
194
554
|
flags: chConfig.flags,
|
|
195
555
|
env: chConfig.env,
|
|
196
556
|
timeout: chConfig.timeout ?? config.defaults.timeout,
|
|
197
557
|
stderr: chConfig.stderr === 'passthrough' ? 'passthrough'
|
|
198
558
|
: chConfig.stderr === 'suppress' ? 'suppress'
|
|
199
559
|
: 'capture',
|
|
560
|
+
promptDelivery: chConfig.prompt_delivery,
|
|
561
|
+
cwd: chConfig.cwd,
|
|
200
562
|
}));
|
|
201
563
|
}
|
|
202
564
|
await Promise.all(dispatches);
|
|
@@ -204,40 +566,89 @@ export const reviewCommand = {
|
|
|
204
566
|
else {
|
|
205
567
|
for (const name of validChannels) {
|
|
206
568
|
const chConfig = config.channels[name];
|
|
569
|
+
if (chConfig.kind === 'http') {
|
|
570
|
+
store.updateChannel(job.job_id, name, { output_parser: chConfig.output_parser });
|
|
571
|
+
await dispatchHttpChannel(store, job.job_id, name, {
|
|
572
|
+
channel: chConfig,
|
|
573
|
+
prompt: buildChannelPrompt(chConfig, prompt),
|
|
574
|
+
timeout: chConfig.timeout ?? config.defaults.timeout,
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (!chConfig.command) {
|
|
579
|
+
store.updateChannel(job.job_id, name, {
|
|
580
|
+
status: 'skipped',
|
|
581
|
+
recovery: `Channel "${name}" is missing command`,
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
207
585
|
store.updateChannel(job.job_id, name, { output_parser: chConfig.output_parser });
|
|
208
586
|
await dispatchChannel(store, job.job_id, name, {
|
|
209
587
|
command: chConfig.command,
|
|
210
|
-
prompt,
|
|
588
|
+
prompt: buildChannelPrompt(chConfig, prompt),
|
|
211
589
|
flags: chConfig.flags,
|
|
212
590
|
env: chConfig.env,
|
|
213
591
|
timeout: chConfig.timeout ?? config.defaults.timeout,
|
|
214
592
|
stderr: chConfig.stderr === 'passthrough' ? 'passthrough'
|
|
215
593
|
: chConfig.stderr === 'suppress' ? 'suppress'
|
|
216
594
|
: 'capture',
|
|
595
|
+
promptDelivery: chConfig.prompt_delivery,
|
|
596
|
+
cwd: chConfig.cwd,
|
|
217
597
|
});
|
|
218
598
|
}
|
|
219
599
|
}
|
|
220
600
|
// 8b. Dispatch compensating passes for unavailable channels
|
|
221
601
|
const completedJob1 = store.loadJob(job.job_id);
|
|
222
602
|
const channelStatuses = Object.fromEntries(Object.entries(completedJob1.channels).map(([n, ch]) => [n, ch.status]));
|
|
223
|
-
const compensating = getCompensatingChannels(channelStatuses);
|
|
603
|
+
const compensating = getCompensatingChannels(channelStatuses, resolveCompensatorChannelName(config));
|
|
224
604
|
if (compensating.length > 0) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
605
|
+
const compensatorAvailability = await checkConfiguredCompensatorAvailability(config);
|
|
606
|
+
if (compensatorAvailability.status === 'ok') {
|
|
607
|
+
// Kind-aware: the compensator may be a subprocess or an http channel.
|
|
608
|
+
const compensatorOutputParser = resolveCompensatorOutputParser(config);
|
|
609
|
+
// Register compensating channels in job.json so loadJob can discover them
|
|
610
|
+
for (const comp of compensating) {
|
|
611
|
+
store.registerChannel(job.job_id, comp.compensatingName, {
|
|
612
|
+
status: 'dispatched',
|
|
613
|
+
auth: 'ok',
|
|
614
|
+
output_parser: compensatorOutputParser,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
await dispatchCompensatingPasses(store, job.job_id, prompt, compensating, config);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
for (const comp of compensating) {
|
|
621
|
+
store.registerChannel(job.job_id, comp.compensatingName, {
|
|
622
|
+
status: compensatorAvailability.status,
|
|
623
|
+
auth: compensatorAvailability.auth,
|
|
624
|
+
recovery: compensatorAvailability.recovery,
|
|
625
|
+
output_parser: 'default',
|
|
626
|
+
});
|
|
627
|
+
}
|
|
232
628
|
}
|
|
233
|
-
await dispatchCompensatingPasses(store, job.job_id, prompt, compensating, config.defaults.timeout);
|
|
234
629
|
}
|
|
235
630
|
// 9. Output results
|
|
236
631
|
if (args.sync) {
|
|
237
632
|
// --sync: full results pipeline (dispatch -> parse -> reconcile -> format -> exit)
|
|
238
633
|
const completedJob = store.loadJob(job.job_id);
|
|
239
634
|
const outputFormat = (args.format ?? completedJob.format ?? 'json');
|
|
240
|
-
|
|
635
|
+
// User-scope acks always load; project-scope acks (working tree) load
|
|
636
|
+
// only when explicitly trusted, so an untrusted PR checkout can't commit
|
|
637
|
+
// acks to self-suppress its own findings. The trust-mode thread adds the
|
|
638
|
+
// trusted default path (project acks from a git base ref). The pipeline
|
|
639
|
+
// fails safe if the acks tree is unreadable.
|
|
640
|
+
const ackStore = buildReviewAckStore({
|
|
641
|
+
trustProjectAcks: honorWorkingTreeAcks,
|
|
642
|
+
userRoot: resolveSessionRoot(),
|
|
643
|
+
configBaseRef: baseRef,
|
|
644
|
+
cwd,
|
|
645
|
+
});
|
|
646
|
+
const { results, formatted, exitCode } = runResultsPipeline(store, completedJob, outputFormat, false, {
|
|
647
|
+
ackStore,
|
|
648
|
+
});
|
|
649
|
+
// runResultsPipeline already stamped trust_mode/proposed_* onto results
|
|
650
|
+
// from the job (persisted at createJob), so results and `formatted` carry
|
|
651
|
+
// the trust context — and `mmr results`/`reconcile` reproduce it.
|
|
241
652
|
store.saveResults(job.job_id, results);
|
|
242
653
|
console.log(formatted);
|
|
243
654
|
process.exit(exitCode);
|
|
@@ -248,12 +659,16 @@ export const reviewCommand = {
|
|
|
248
659
|
const result = {
|
|
249
660
|
job_id: job.job_id,
|
|
250
661
|
status: completedJob.status,
|
|
662
|
+
trust_mode: trust.trust_mode,
|
|
251
663
|
channels: Object.fromEntries(channelNames.map((name) => [
|
|
252
664
|
name,
|
|
253
665
|
completedJob.channels[name]?.status ?? authResults[name]?.status ?? 'skipped',
|
|
254
666
|
])),
|
|
255
667
|
valid_channels: validChannels,
|
|
256
668
|
};
|
|
669
|
+
// Surface opted-into proposed changes for transparency (the blocking case
|
|
670
|
+
// already short-circuited to needs-user-decision before dispatch).
|
|
671
|
+
annotateProposedChanges(result, diffChanges);
|
|
257
672
|
console.log(JSON.stringify(result, null, 2));
|
|
258
673
|
}
|
|
259
674
|
},
|