@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.
Files changed (130) hide show
  1. package/README.md +444 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +4 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/ack.d.ts +11 -0
  6. package/dist/commands/ack.d.ts.map +1 -0
  7. package/dist/commands/ack.js +123 -0
  8. package/dist/commands/ack.js.map +1 -0
  9. package/dist/commands/config.d.ts +5 -0
  10. package/dist/commands/config.d.ts.map +1 -1
  11. package/dist/commands/config.js +248 -14
  12. package/dist/commands/config.js.map +1 -1
  13. package/dist/commands/jobs.d.ts.map +1 -1
  14. package/dist/commands/jobs.js +3 -4
  15. package/dist/commands/jobs.js.map +1 -1
  16. package/dist/commands/reconcile.d.ts.map +1 -1
  17. package/dist/commands/reconcile.js +12 -5
  18. package/dist/commands/reconcile.js.map +1 -1
  19. package/dist/commands/results.d.ts.map +1 -1
  20. package/dist/commands/results.js +13 -5
  21. package/dist/commands/results.js.map +1 -1
  22. package/dist/commands/review.d.ts +25 -0
  23. package/dist/commands/review.d.ts.map +1 -1
  24. package/dist/commands/review.js +459 -44
  25. package/dist/commands/review.js.map +1 -1
  26. package/dist/commands/sessions.d.ts +58 -0
  27. package/dist/commands/sessions.d.ts.map +1 -0
  28. package/dist/commands/sessions.js +266 -0
  29. package/dist/commands/sessions.js.map +1 -0
  30. package/dist/commands/status.d.ts.map +1 -1
  31. package/dist/commands/status.js +2 -3
  32. package/dist/commands/status.js.map +1 -1
  33. package/dist/config/defaults.d.ts +2 -2
  34. package/dist/config/defaults.d.ts.map +1 -1
  35. package/dist/config/defaults.js +76 -0
  36. package/dist/config/defaults.js.map +1 -1
  37. package/dist/config/loader.d.ts +22 -0
  38. package/dist/config/loader.d.ts.map +1 -1
  39. package/dist/config/loader.js +279 -36
  40. package/dist/config/loader.js.map +1 -1
  41. package/dist/config/schema.d.ts +897 -53
  42. package/dist/config/schema.d.ts.map +1 -1
  43. package/dist/config/schema.js +155 -4
  44. package/dist/config/schema.js.map +1 -1
  45. package/dist/core/ack-store.d.ts +109 -0
  46. package/dist/core/ack-store.d.ts.map +1 -0
  47. package/dist/core/ack-store.js +363 -0
  48. package/dist/core/ack-store.js.map +1 -0
  49. package/dist/core/auth.d.ts +10 -1
  50. package/dist/core/auth.d.ts.map +1 -1
  51. package/dist/core/auth.js +106 -35
  52. package/dist/core/auth.js.map +1 -1
  53. package/dist/core/compensator.d.ts +33 -4
  54. package/dist/core/compensator.d.ts.map +1 -1
  55. package/dist/core/compensator.js +120 -15
  56. package/dist/core/compensator.js.map +1 -1
  57. package/dist/core/diff-introspect.d.ts +21 -0
  58. package/dist/core/diff-introspect.d.ts.map +1 -0
  59. package/dist/core/diff-introspect.js +42 -0
  60. package/dist/core/diff-introspect.js.map +1 -0
  61. package/dist/core/dispatcher.d.ts +10 -0
  62. package/dist/core/dispatcher.d.ts.map +1 -1
  63. package/dist/core/dispatcher.js +91 -20
  64. package/dist/core/dispatcher.js.map +1 -1
  65. package/dist/core/git-show.d.ts +31 -0
  66. package/dist/core/git-show.d.ts.map +1 -0
  67. package/dist/core/git-show.js +72 -0
  68. package/dist/core/git-show.js.map +1 -0
  69. package/dist/core/host-isolation.d.ts +24 -0
  70. package/dist/core/host-isolation.d.ts.map +1 -0
  71. package/dist/core/host-isolation.js +107 -0
  72. package/dist/core/host-isolation.js.map +1 -0
  73. package/dist/core/http-dispatcher.d.ts +20 -0
  74. package/dist/core/http-dispatcher.d.ts.map +1 -0
  75. package/dist/core/http-dispatcher.js +125 -0
  76. package/dist/core/http-dispatcher.js.map +1 -0
  77. package/dist/core/job-store.d.ts +7 -1
  78. package/dist/core/job-store.d.ts.map +1 -1
  79. package/dist/core/job-store.js +21 -1
  80. package/dist/core/job-store.js.map +1 -1
  81. package/dist/core/jsonpath.d.ts +15 -0
  82. package/dist/core/jsonpath.d.ts.map +1 -0
  83. package/dist/core/jsonpath.js +63 -0
  84. package/dist/core/jsonpath.js.map +1 -0
  85. package/dist/core/oss-examples.d.ts +18 -0
  86. package/dist/core/oss-examples.d.ts.map +1 -0
  87. package/dist/core/oss-examples.js +66 -0
  88. package/dist/core/oss-examples.js.map +1 -0
  89. package/dist/core/parser.d.ts +8 -3
  90. package/dist/core/parser.d.ts.map +1 -1
  91. package/dist/core/parser.js +157 -6
  92. package/dist/core/parser.js.map +1 -1
  93. package/dist/core/project-root.d.ts +10 -0
  94. package/dist/core/project-root.d.ts.map +1 -0
  95. package/dist/core/project-root.js +23 -0
  96. package/dist/core/project-root.js.map +1 -0
  97. package/dist/core/reconciler.d.ts +1 -1
  98. package/dist/core/reconciler.d.ts.map +1 -1
  99. package/dist/core/reconciler.js +100 -18
  100. package/dist/core/reconciler.js.map +1 -1
  101. package/dist/core/redact.d.ts +17 -0
  102. package/dist/core/redact.d.ts.map +1 -0
  103. package/dist/core/redact.js +140 -0
  104. package/dist/core/redact.js.map +1 -0
  105. package/dist/core/results-pipeline.d.ts +8 -2
  106. package/dist/core/results-pipeline.d.ts.map +1 -1
  107. package/dist/core/results-pipeline.js +50 -3
  108. package/dist/core/results-pipeline.js.map +1 -1
  109. package/dist/core/runtime-probe.d.ts +14 -0
  110. package/dist/core/runtime-probe.d.ts.map +1 -0
  111. package/dist/core/runtime-probe.js +57 -0
  112. package/dist/core/runtime-probe.js.map +1 -0
  113. package/dist/core/stable-id.d.ts +19 -0
  114. package/dist/core/stable-id.d.ts.map +1 -0
  115. package/dist/core/stable-id.js +148 -0
  116. package/dist/core/stable-id.js.map +1 -0
  117. package/dist/core/trust-mode.d.ts +29 -0
  118. package/dist/core/trust-mode.d.ts.map +1 -0
  119. package/dist/core/trust-mode.js +103 -0
  120. package/dist/core/trust-mode.js.map +1 -0
  121. package/dist/formatters/markdown.d.ts.map +1 -1
  122. package/dist/formatters/markdown.js +9 -0
  123. package/dist/formatters/markdown.js.map +1 -1
  124. package/dist/formatters/text.d.ts.map +1 -1
  125. package/dist/formatters/text.js +9 -0
  126. package/dist/formatters/text.js.map +1 -1
  127. package/dist/types.d.ts +44 -1
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/types.js.map +1 -1
  130. package/package.json +2 -2
@@ -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 { getCompensatingChannels, dispatchCompensatingPasses } from '../core/compensator.js';
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
- // 1. Load config with CLI overrides
99
- const config = loadConfig({
100
- projectRoot: process.cwd(),
101
- cliOverrides: {
102
- fix_threshold: args['fix-threshold'],
103
- timeout: args.timeout,
104
- format: args.format,
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
- // 3. Determine enabled channels
114
- const channelNames = args.channels ?? Object.entries(config.channels)
115
- .filter(([, ch]) => ch.enabled)
116
- .map(([name]) => name);
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 = path.join(os.homedir(), '.mmr', 'jobs');
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 === 'not_installed' ? 'not_installed'
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
- // Register compensating channels in job.json so loadJob can discover them
226
- for (const comp of compensating) {
227
- store.registerChannel(job.job_id, comp.compensatingName, {
228
- status: 'dispatched',
229
- auth: 'ok',
230
- output_parser: 'default',
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
- const { results, formatted, exitCode } = runResultsPipeline(store, completedJob, outputFormat);
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
  },