@neurcode-ai/cli 0.9.42 → 0.9.43

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.
@@ -1,101 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.remediateCommand = remediateCommand;
4
+ const crypto_1 = require("crypto");
4
5
  const child_process_1 = require("child_process");
5
6
  const fs_1 = require("fs");
7
+ const path_1 = require("path");
6
8
  const project_root_1 = require("../utils/project-root");
9
+ const manual_approvals_1 = require("../utils/manual-approvals");
7
10
  const core_1 = require("@neurcode-ai/core");
8
11
  const analysis_1 = require("@neurcode-ai/analysis");
9
- let chalk;
10
- try {
11
- chalk = require('chalk');
12
- }
13
- catch {
14
- chalk = {
15
- green: (value) => value,
16
- yellow: (value) => value,
17
- red: (value) => value,
18
- bold: (value) => value,
19
- dim: (value) => value,
20
- cyan: (value) => value,
21
- };
22
- }
12
+ const cli_json_1 = require("../utils/cli-json");
13
+ const chalk = (0, cli_json_1.loadChalk)();
23
14
  function emitJson(payload) {
24
- console.log(JSON.stringify(payload, null, 2));
25
- }
26
- function stripAnsi(value) {
27
- return value.replace(/\u001b\[[0-9;]*m/g, '');
28
- }
29
- function extractLastJsonObject(output) {
30
- const clean = stripAnsi(output).trim();
31
- const end = clean.lastIndexOf('}');
32
- if (end === -1)
33
- return null;
34
- for (let start = end; start >= 0; start -= 1) {
35
- if (clean[start] !== '{')
36
- continue;
37
- const candidate = clean.slice(start, end + 1);
38
- try {
39
- const parsed = JSON.parse(candidate);
40
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
41
- return parsed;
42
- }
43
- }
44
- catch {
45
- // Keep searching.
46
- }
47
- }
48
- return null;
49
- }
50
- function asString(record, key) {
51
- if (!record)
52
- return null;
53
- const value = record[key];
54
- return typeof value === 'string' ? value : null;
55
- }
56
- function asNumber(record, key) {
57
- if (!record)
58
- return null;
59
- const value = record[key];
60
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
61
- }
62
- function asViolationsCount(record) {
63
- if (!record)
64
- return 0;
65
- const value = record.violations;
66
- if (!Array.isArray(value))
67
- return 0;
68
- return value.length;
69
- }
70
- async function runCliJson(commandArgs) {
71
- const args = commandArgs.includes('--json') ? [...commandArgs] : [...commandArgs, '--json'];
72
- const stdoutChunks = [];
73
- const stderrChunks = [];
74
- const exitCode = await new Promise((resolvePromise, reject) => {
75
- const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], ...args], {
76
- cwd: process.cwd(),
77
- env: {
78
- ...process.env,
79
- CI: process.env.CI || 'true',
80
- FORCE_COLOR: '0',
81
- },
82
- stdio: ['ignore', 'pipe', 'pipe'],
83
- });
84
- child.stdout.on('data', (chunk) => stdoutChunks.push(String(chunk)));
85
- child.stderr.on('data', (chunk) => stderrChunks.push(String(chunk)));
86
- child.on('error', (error) => reject(error));
87
- child.on('close', (code) => resolvePromise(typeof code === 'number' ? code : 1));
88
- });
89
- const stdout = stdoutChunks.join('');
90
- const stderr = stderrChunks.join('');
91
- const payload = extractLastJsonObject(`${stdout}\n${stderr}`);
92
- return {
93
- exitCode,
94
- stdout,
95
- stderr,
96
- payload,
97
- command: args,
98
- };
15
+ (0, cli_json_1.emitJson)(payload);
99
16
  }
100
17
  function resolveStrictArtifacts(options) {
101
18
  if (options.strictArtifacts === true)
@@ -120,6 +37,319 @@ function resolveRequireRuntimeGuard(options) {
120
37
  return false;
121
38
  return process.env.NEURCODE_REMEDIATE_REQUIRE_RUNTIME_GUARD === '1';
122
39
  }
40
+ function parsePositiveInt(raw) {
41
+ if (!raw)
42
+ return null;
43
+ const parsed = Number.parseInt(raw, 10);
44
+ if (!Number.isFinite(parsed) || parsed <= 0)
45
+ return null;
46
+ return parsed;
47
+ }
48
+ function resolveRequireApproval(options) {
49
+ if (options.requireApproval === true)
50
+ return true;
51
+ if (options.requireApproval === false)
52
+ return false;
53
+ return process.env.NEURCODE_REMEDIATE_REQUIRE_APPROVAL === '1';
54
+ }
55
+ function resolveMinApprovals(options) {
56
+ if (Number.isFinite(options.minApprovals)) {
57
+ return Math.max(1, Math.min(5, Math.floor(Number(options.minApprovals))));
58
+ }
59
+ const envValue = parsePositiveInt(process.env.NEURCODE_REMEDIATE_MIN_APPROVALS);
60
+ if (envValue != null) {
61
+ return Math.max(1, Math.min(5, envValue));
62
+ }
63
+ return 1;
64
+ }
65
+ function resolveRollbackOnRegression(options) {
66
+ if (options.rollbackOnRegression === true)
67
+ return true;
68
+ if (options.rollbackOnRegression === false)
69
+ return false;
70
+ if (process.env.NEURCODE_REMEDIATE_ROLLBACK_ON_REGRESSION === '0')
71
+ return false;
72
+ return true;
73
+ }
74
+ function resolveRequireRollbackSnapshot(options) {
75
+ if (options.requireRollbackSnapshot === true)
76
+ return true;
77
+ if (options.requireRollbackSnapshot === false)
78
+ return false;
79
+ if (process.env.NEURCODE_ENTERPRISE_MODE === '1')
80
+ return true;
81
+ return process.env.NEURCODE_REMEDIATE_REQUIRE_ROLLBACK_SNAPSHOT === '1';
82
+ }
83
+ function resolveSnapshotLimits(options) {
84
+ const maxFiles = Number.isFinite(options.snapshotMaxFiles)
85
+ ? Math.max(100, Math.floor(Number(options.snapshotMaxFiles)))
86
+ : (parsePositiveInt(process.env.NEURCODE_REMEDIATE_SNAPSHOT_MAX_FILES) || 5000);
87
+ const maxBytes = Number.isFinite(options.snapshotMaxBytes)
88
+ ? Math.max(5_000_000, Math.floor(Number(options.snapshotMaxBytes)))
89
+ : (parsePositiveInt(process.env.NEURCODE_REMEDIATE_SNAPSHOT_MAX_BYTES) || 128_000_000);
90
+ const maxFileBytes = Number.isFinite(options.snapshotMaxFileBytes)
91
+ ? Math.max(100_000, Math.floor(Number(options.snapshotMaxFileBytes)))
92
+ : (parsePositiveInt(process.env.NEURCODE_REMEDIATE_SNAPSHOT_MAX_FILE_BYTES) || 8_000_000);
93
+ return {
94
+ maxFiles,
95
+ maxBytes,
96
+ maxFileBytes,
97
+ };
98
+ }
99
+ function resolveApprovalCommitSha(projectRoot, explicitCommit) {
100
+ if (explicitCommit && explicitCommit.trim()) {
101
+ return explicitCommit.trim().toLowerCase();
102
+ }
103
+ const run = (0, child_process_1.spawnSync)('git', ['rev-parse', 'HEAD'], {
104
+ cwd: projectRoot,
105
+ encoding: 'utf-8',
106
+ stdio: ['ignore', 'pipe', 'pipe'],
107
+ });
108
+ if (run.status !== 0) {
109
+ return null;
110
+ }
111
+ const sha = String(run.stdout || '').trim().toLowerCase();
112
+ return sha || null;
113
+ }
114
+ function resolveApprovalState(projectRoot, commitSha, minApprovals) {
115
+ if (!commitSha) {
116
+ return {
117
+ commitSha: null,
118
+ distinctApprovers: 0,
119
+ satisfied: false,
120
+ message: 'Unable to resolve HEAD commit for manual approval gating.',
121
+ };
122
+ }
123
+ const approvals = (0, manual_approvals_1.getManualApprovalsForCommit)(projectRoot, commitSha);
124
+ const distinctApprovers = (0, manual_approvals_1.countDistinctApprovers)(approvals);
125
+ const satisfied = distinctApprovers >= minApprovals;
126
+ const message = satisfied
127
+ ? `Manual approvals satisfied (${distinctApprovers}/${minApprovals}) for commit ${commitSha}.`
128
+ : `Manual approvals required (${distinctApprovers}/${minApprovals}) for commit ${commitSha}.`;
129
+ return {
130
+ commitSha,
131
+ distinctApprovers,
132
+ satisfied,
133
+ message,
134
+ };
135
+ }
136
+ function parseNulSeparated(raw) {
137
+ return raw
138
+ .split('\u0000')
139
+ .map((entry) => entry.trim())
140
+ .filter(Boolean);
141
+ }
142
+ function runGitCapture(projectRoot, args) {
143
+ const run = (0, child_process_1.spawnSync)('git', args, {
144
+ cwd: projectRoot,
145
+ encoding: 'utf-8',
146
+ stdio: ['ignore', 'pipe', 'pipe'],
147
+ });
148
+ return {
149
+ ok: run.status === 0,
150
+ stdout: String(run.stdout || ''),
151
+ stderr: String(run.stderr || ''),
152
+ };
153
+ }
154
+ function listSnapshotPaths(projectRoot) {
155
+ const tracked = runGitCapture(projectRoot, ['ls-files', '-z']);
156
+ if (!tracked.ok) {
157
+ return {
158
+ paths: [],
159
+ error: `git ls-files failed: ${tracked.stderr.trim() || 'unknown error'}`,
160
+ };
161
+ }
162
+ const untracked = runGitCapture(projectRoot, ['ls-files', '--others', '--exclude-standard', '-z']);
163
+ if (!untracked.ok) {
164
+ return {
165
+ paths: [],
166
+ error: `git ls-files --others failed: ${untracked.stderr.trim() || 'unknown error'}`,
167
+ };
168
+ }
169
+ const unique = new Set();
170
+ for (const entry of [...parseNulSeparated(tracked.stdout), ...parseNulSeparated(untracked.stdout)]) {
171
+ const normalized = entry.replace(/\\/g, '/').replace(/^\.\//, '').trim();
172
+ if (!normalized || normalized.startsWith('.git/'))
173
+ continue;
174
+ if (normalized.includes('\u0000'))
175
+ continue;
176
+ unique.add(normalized);
177
+ }
178
+ return {
179
+ paths: [...unique].sort((a, b) => a.localeCompare(b)),
180
+ error: null,
181
+ };
182
+ }
183
+ function withinProject(projectRoot, relativePath) {
184
+ if (!relativePath)
185
+ return false;
186
+ if (relativePath.startsWith('/'))
187
+ return false;
188
+ if (relativePath.startsWith('../'))
189
+ return false;
190
+ const normalized = relativePath.replace(/\\/g, '/');
191
+ if (normalized.includes('/../') || normalized === '..')
192
+ return false;
193
+ const absolute = (0, path_1.resolve)(projectRoot, relativePath);
194
+ return absolute === projectRoot || absolute.startsWith(`${projectRoot}${path_1.sep}`);
195
+ }
196
+ function createRollbackSnapshot(projectRoot, limits) {
197
+ const listed = listSnapshotPaths(projectRoot);
198
+ if (listed.error) {
199
+ return {
200
+ snapshot: null,
201
+ message: listed.error,
202
+ };
203
+ }
204
+ if (listed.paths.length > limits.maxFiles) {
205
+ return {
206
+ snapshot: null,
207
+ message: `rollback snapshot skipped: ${listed.paths.length} files exceeds maxFiles=${limits.maxFiles}`,
208
+ };
209
+ }
210
+ const snapshotId = `snapshot-${Date.now()}`;
211
+ const rootDir = (0, path_1.join)(projectRoot, '.neurcode', 'remediate', 'snapshots', snapshotId);
212
+ const filesDir = (0, path_1.join)(rootDir, 'files');
213
+ (0, fs_1.mkdirSync)(filesDir, { recursive: true });
214
+ const files = [];
215
+ let totalBytes = 0;
216
+ for (const relativePath of listed.paths) {
217
+ if (!withinProject(projectRoot, relativePath)) {
218
+ (0, fs_1.rmSync)(rootDir, { recursive: true, force: true });
219
+ return {
220
+ snapshot: null,
221
+ message: `rollback snapshot skipped unsafe path: ${relativePath}`,
222
+ };
223
+ }
224
+ const absolutePath = (0, path_1.join)(projectRoot, relativePath);
225
+ let stats;
226
+ try {
227
+ stats = (0, fs_1.statSync)(absolutePath);
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ if (!stats.isFile())
233
+ continue;
234
+ const fileSize = Number(stats.size) || 0;
235
+ if (fileSize > limits.maxFileBytes) {
236
+ (0, fs_1.rmSync)(rootDir, { recursive: true, force: true });
237
+ return {
238
+ snapshot: null,
239
+ message: `rollback snapshot skipped: ${relativePath} size ${fileSize} exceeds maxFileBytes=${limits.maxFileBytes}`,
240
+ };
241
+ }
242
+ totalBytes += fileSize;
243
+ if (totalBytes > limits.maxBytes) {
244
+ (0, fs_1.rmSync)(rootDir, { recursive: true, force: true });
245
+ return {
246
+ snapshot: null,
247
+ message: `rollback snapshot skipped: total bytes ${totalBytes} exceeds maxBytes=${limits.maxBytes}`,
248
+ };
249
+ }
250
+ const content = (0, fs_1.readFileSync)(absolutePath);
251
+ const snapshotFilePath = (0, path_1.join)(filesDir, relativePath);
252
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(snapshotFilePath), { recursive: true });
253
+ (0, fs_1.writeFileSync)(snapshotFilePath, content);
254
+ files.push({
255
+ path: relativePath,
256
+ sha256: (0, crypto_1.createHash)('sha256').update(content).digest('hex'),
257
+ size: fileSize,
258
+ });
259
+ }
260
+ const manifestPath = (0, path_1.join)(rootDir, 'manifest.json');
261
+ (0, fs_1.writeFileSync)(manifestPath, JSON.stringify({
262
+ snapshotId,
263
+ createdAt: new Date().toISOString(),
264
+ projectRoot,
265
+ totalBytes,
266
+ fileCount: files.length,
267
+ limits,
268
+ files,
269
+ }, null, 2) + '\n', 'utf-8');
270
+ return {
271
+ snapshot: {
272
+ snapshotId,
273
+ rootDir,
274
+ filesDir,
275
+ files,
276
+ totalBytes,
277
+ },
278
+ message: `rollback snapshot created (${files.length} files, ${totalBytes} bytes)`,
279
+ };
280
+ }
281
+ function restoreRollbackSnapshot(projectRoot, snapshot) {
282
+ const listed = listSnapshotPaths(projectRoot);
283
+ if (listed.error) {
284
+ return {
285
+ restored: false,
286
+ message: `rollback restore failed: ${listed.error}`,
287
+ };
288
+ }
289
+ const snapshotPaths = new Set(snapshot.files.map((entry) => entry.path));
290
+ const snapshotRootRelative = snapshot.rootDir
291
+ .replace(projectRoot, '')
292
+ .replace(/^[\\/]+/, '')
293
+ .replace(/\\/g, '/');
294
+ let removedCount = 0;
295
+ for (const currentPath of listed.paths) {
296
+ if (currentPath.startsWith('.git/'))
297
+ continue;
298
+ if (snapshotRootRelative
299
+ && (currentPath === snapshotRootRelative || currentPath.startsWith(`${snapshotRootRelative}/`))) {
300
+ continue;
301
+ }
302
+ if (snapshotPaths.has(currentPath))
303
+ continue;
304
+ if (!withinProject(projectRoot, currentPath))
305
+ continue;
306
+ const absolutePath = (0, path_1.join)(projectRoot, currentPath);
307
+ try {
308
+ const stats = (0, fs_1.statSync)(absolutePath);
309
+ if (!stats.isFile())
310
+ continue;
311
+ (0, fs_1.rmSync)(absolutePath, { force: true });
312
+ removedCount += 1;
313
+ }
314
+ catch {
315
+ // Ignore best-effort cleanup failures.
316
+ }
317
+ }
318
+ let restoredCount = 0;
319
+ for (const entry of snapshot.files) {
320
+ if (!withinProject(projectRoot, entry.path)) {
321
+ return {
322
+ restored: false,
323
+ message: `rollback restore aborted due to unsafe snapshot path: ${entry.path}`,
324
+ };
325
+ }
326
+ const source = (0, path_1.join)(snapshot.filesDir, entry.path);
327
+ const destination = (0, path_1.join)(projectRoot, entry.path);
328
+ if (!(0, fs_1.existsSync)(source)) {
329
+ return {
330
+ restored: false,
331
+ message: `rollback restore failed: missing snapshot file ${entry.path}`,
332
+ };
333
+ }
334
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(destination), { recursive: true });
335
+ (0, fs_1.copyFileSync)(source, destination);
336
+ restoredCount += 1;
337
+ }
338
+ return {
339
+ restored: true,
340
+ message: `rollback restored ${restoredCount} files and removed ${removedCount} generated files`,
341
+ };
342
+ }
343
+ function cleanupRollbackSnapshot(snapshot) {
344
+ if (!snapshot)
345
+ return;
346
+ try {
347
+ (0, fs_1.rmSync)(snapshot.rootDir, { recursive: true, force: true });
348
+ }
349
+ catch {
350
+ // Ignore cleanup errors.
351
+ }
352
+ }
123
353
  function buildVerifyArgs(options, strictArtifacts, enforceChangeContract) {
124
354
  const args = ['verify'];
125
355
  if (options.planId)
@@ -166,10 +396,10 @@ function isVerifyPass(snapshot) {
166
396
  function toVerifySnapshot(result) {
167
397
  return {
168
398
  exitCode: result.exitCode,
169
- verdict: asString(result.payload, 'verdict'),
170
- score: asNumber(result.payload, 'score'),
171
- message: asString(result.payload, 'message'),
172
- violations: asViolationsCount(result.payload),
399
+ verdict: (0, cli_json_1.asString)(result.payload, 'verdict'),
400
+ score: (0, cli_json_1.asNumber)(result.payload, 'score'),
401
+ message: (0, cli_json_1.asString)(result.payload, 'message'),
402
+ violations: (0, cli_json_1.asViolationsCount)(result.payload),
173
403
  };
174
404
  }
175
405
  function hasImproved(before, after) {
@@ -268,7 +498,7 @@ function extractChangeJustificationFromLog(projectRoot) {
268
498
  return null;
269
499
  }
270
500
  function shouldAttemptAiLogRepair(verifyRun) {
271
- const payloadMessage = asString(verifyRun.payload, 'message') || '';
501
+ const payloadMessage = (0, cli_json_1.asString)(verifyRun.payload, 'message') || '';
272
502
  const combined = `${payloadMessage}\n${verifyRun.stdout}\n${verifyRun.stderr}`.toLowerCase();
273
503
  if (combined.includes('ai change-log integrity check failed')) {
274
504
  return true;
@@ -343,12 +573,18 @@ async function remediateCommand(options = {}) {
343
573
  const strictArtifacts = resolveStrictArtifacts(options);
344
574
  const enforceChangeContract = resolveEnforceChangeContract(options, strictArtifacts);
345
575
  const requireRuntimeGuard = resolveRequireRuntimeGuard(options);
576
+ const requireApproval = resolveRequireApproval(options);
577
+ const minApprovals = resolveMinApprovals(options);
578
+ const approvalCommit = resolveApprovalCommitSha(projectRoot, options.approvalCommit);
346
579
  const autoRepairAiLog = resolveAutoRepairAiLog(options);
580
+ const rollbackOnRegression = resolveRollbackOnRegression(options);
581
+ const requireRollbackSnapshot = resolveRequireRollbackSnapshot(options);
582
+ const snapshotLimits = resolveSnapshotLimits(options);
347
583
  const maxAttempts = Number.isFinite(options.maxFixAttempts) && Number(options.maxFixAttempts) >= 0
348
584
  ? Math.floor(Number(options.maxFixAttempts))
349
585
  : 2;
350
586
  try {
351
- let baselineVerifyRun = await runCliJson(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
587
+ let baselineVerifyRun = await (0, cli_json_1.runCliJson)(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
352
588
  let currentSnapshot = toVerifySnapshot(baselineVerifyRun);
353
589
  let aiLogRepair = {
354
590
  attempted: false,
@@ -359,7 +595,7 @@ async function remediateCommand(options = {}) {
359
595
  if (autoRepairAiLog && !isVerifyPass(currentSnapshot) && shouldAttemptAiLogRepair(baselineVerifyRun)) {
360
596
  aiLogRepair = attemptAiLogIntegrityRepair(projectRoot);
361
597
  if (aiLogRepair.repaired) {
362
- baselineVerifyRun = await runCliJson(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
598
+ baselineVerifyRun = await (0, cli_json_1.runCliJson)(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
363
599
  currentSnapshot = toVerifySnapshot(baselineVerifyRun);
364
600
  }
365
601
  }
@@ -377,6 +613,18 @@ async function remediateCommand(options = {}) {
377
613
  enforceChangeContract,
378
614
  requireRuntimeGuard,
379
615
  },
616
+ governance: {
617
+ requireApproval,
618
+ minApprovals,
619
+ approvalCommit,
620
+ rollbackOnRegression,
621
+ requireRollbackSnapshot,
622
+ snapshotLimits: {
623
+ maxFiles: snapshotLimits.maxFiles,
624
+ maxBytes: snapshotLimits.maxBytes,
625
+ maxFileBytes: snapshotLimits.maxFileBytes,
626
+ },
627
+ },
380
628
  baseline: currentSnapshot,
381
629
  attempts,
382
630
  finalVerify: currentSnapshot,
@@ -411,6 +659,18 @@ async function remediateCommand(options = {}) {
411
659
  enforceChangeContract,
412
660
  requireRuntimeGuard,
413
661
  },
662
+ governance: {
663
+ requireApproval,
664
+ minApprovals,
665
+ approvalCommit,
666
+ rollbackOnRegression,
667
+ requireRollbackSnapshot,
668
+ snapshotLimits: {
669
+ maxFiles: snapshotLimits.maxFiles,
670
+ maxBytes: snapshotLimits.maxBytes,
671
+ maxFileBytes: snapshotLimits.maxFileBytes,
672
+ },
673
+ },
414
674
  baseline: currentSnapshot,
415
675
  attempts,
416
676
  finalVerify: currentSnapshot,
@@ -431,6 +691,14 @@ async function remediateCommand(options = {}) {
431
691
  const attemptSummary = {
432
692
  attempt,
433
693
  before: currentSnapshot,
694
+ approval: {
695
+ required: requireApproval,
696
+ satisfied: !requireApproval,
697
+ commitSha: approvalCommit,
698
+ minimumApprovals: minApprovals,
699
+ distinctApprovers: 0,
700
+ message: requireApproval ? 'Approval check pending.' : 'Approval gate disabled.',
701
+ },
434
702
  runtimeGuard: {
435
703
  executed: false,
436
704
  pass: null,
@@ -448,29 +716,69 @@ async function remediateCommand(options = {}) {
448
716
  score: null,
449
717
  violations: null,
450
718
  },
719
+ rollback: {
720
+ snapshotCreated: false,
721
+ snapshotId: null,
722
+ restored: false,
723
+ postRollbackVerify: null,
724
+ message: rollbackOnRegression ? 'Rollback snapshot pending.' : 'Rollback disabled.',
725
+ },
451
726
  stopReason: null,
452
727
  };
728
+ if (requireApproval) {
729
+ const approvalState = resolveApprovalState(projectRoot, approvalCommit, minApprovals);
730
+ attemptSummary.approval.commitSha = approvalState.commitSha;
731
+ attemptSummary.approval.distinctApprovers = approvalState.distinctApprovers;
732
+ attemptSummary.approval.satisfied = approvalState.satisfied;
733
+ attemptSummary.approval.message = approvalState.message;
734
+ if (!approvalState.satisfied) {
735
+ attemptSummary.stopReason = 'approval_required';
736
+ attempts.push(attemptSummary);
737
+ stopReason = 'approval_required';
738
+ break;
739
+ }
740
+ }
741
+ let rollbackSnapshot = null;
742
+ if (rollbackOnRegression) {
743
+ const snapshotResult = createRollbackSnapshot(projectRoot, snapshotLimits);
744
+ if (snapshotResult.snapshot) {
745
+ rollbackSnapshot = snapshotResult.snapshot;
746
+ attemptSummary.rollback.snapshotCreated = true;
747
+ attemptSummary.rollback.snapshotId = rollbackSnapshot.snapshotId;
748
+ attemptSummary.rollback.message = snapshotResult.message;
749
+ }
750
+ else {
751
+ attemptSummary.rollback.message = snapshotResult.message;
752
+ if (requireRollbackSnapshot) {
753
+ attemptSummary.stopReason = 'rollback_snapshot_unavailable';
754
+ attempts.push(attemptSummary);
755
+ stopReason = 'rollback_snapshot_unavailable';
756
+ break;
757
+ }
758
+ }
759
+ }
453
760
  if (requireRuntimeGuard) {
454
761
  attemptSummary.runtimeGuard.executed = true;
455
- const guardRun = await runCliJson(['guard', 'check', '--head']);
762
+ const guardRun = await (0, cli_json_1.runCliJson)(['guard', 'check', '--head']);
456
763
  const guardPass = guardRun.exitCode === 0;
457
764
  attemptSummary.runtimeGuard.pass = guardPass;
458
765
  attemptSummary.runtimeGuard.message =
459
- asString(guardRun.payload, 'message')
766
+ (0, cli_json_1.asString)(guardRun.payload, 'message')
460
767
  || (guardPass ? 'Runtime guard check passed.' : 'Runtime guard check failed.');
461
768
  if (!guardPass) {
769
+ cleanupRollbackSnapshot(rollbackSnapshot);
462
770
  attemptSummary.stopReason = 'runtime_guard_blocked';
463
771
  attempts.push(attemptSummary);
464
772
  stopReason = 'runtime_guard_blocked';
465
773
  break;
466
774
  }
467
775
  }
468
- const shipRun = await runCliJson(buildShipArgs(options));
776
+ const shipRun = await (0, cli_json_1.runCliJson)(buildShipArgs(options));
469
777
  attemptSummary.ship.executed = true;
470
778
  attemptSummary.ship.exitCode = shipRun.exitCode;
471
- attemptSummary.ship.status = asString(shipRun.payload, 'status');
472
- attemptSummary.ship.finalPlanId = asString(shipRun.payload, 'finalPlanId');
473
- const afterVerifyRun = await runCliJson(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
779
+ attemptSummary.ship.status = (0, cli_json_1.asString)(shipRun.payload, 'status');
780
+ attemptSummary.ship.finalPlanId = (0, cli_json_1.asString)(shipRun.payload, 'finalPlanId');
781
+ const afterVerifyRun = await (0, cli_json_1.runCliJson)(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
474
782
  const afterSnapshot = toVerifySnapshot(afterVerifyRun);
475
783
  attemptSummary.after = afterSnapshot;
476
784
  attemptSummary.delta = {
@@ -480,8 +788,38 @@ async function remediateCommand(options = {}) {
480
788
  violations: afterSnapshot.violations - currentSnapshot.violations,
481
789
  };
482
790
  attemptSummary.improved = hasImproved(currentSnapshot, afterSnapshot);
791
+ if (attemptSummary.improved === false && rollbackOnRegression) {
792
+ if (rollbackSnapshot) {
793
+ const restoreResult = restoreRollbackSnapshot(projectRoot, rollbackSnapshot);
794
+ attemptSummary.rollback.restored = restoreResult.restored;
795
+ attemptSummary.rollback.message = restoreResult.message;
796
+ if (!restoreResult.restored && requireRollbackSnapshot) {
797
+ attemptSummary.stopReason = 'rollback_restore_failed';
798
+ attempts.push(attemptSummary);
799
+ stopReason = 'rollback_restore_failed';
800
+ cleanupRollbackSnapshot(rollbackSnapshot);
801
+ break;
802
+ }
803
+ if (restoreResult.restored) {
804
+ const rollbackVerifyRun = await (0, cli_json_1.runCliJson)(buildVerifyArgs(options, strictArtifacts, enforceChangeContract));
805
+ const rollbackSnapshotVerify = toVerifySnapshot(rollbackVerifyRun);
806
+ attemptSummary.rollback.postRollbackVerify = rollbackSnapshotVerify;
807
+ currentSnapshot = rollbackSnapshotVerify;
808
+ }
809
+ else {
810
+ currentSnapshot = afterSnapshot;
811
+ }
812
+ }
813
+ else {
814
+ attemptSummary.rollback.message = 'Rollback requested but snapshot was not available for this attempt.';
815
+ currentSnapshot = afterSnapshot;
816
+ }
817
+ }
818
+ else {
819
+ currentSnapshot = afterSnapshot;
820
+ }
821
+ cleanupRollbackSnapshot(rollbackSnapshot);
483
822
  attempts.push(attemptSummary);
484
- currentSnapshot = afterSnapshot;
485
823
  if (isVerifyPass(afterSnapshot)) {
486
824
  stopReason = 'verify_passed_after_remediation';
487
825
  break;
@@ -507,6 +845,18 @@ async function remediateCommand(options = {}) {
507
845
  enforceChangeContract,
508
846
  requireRuntimeGuard,
509
847
  },
848
+ governance: {
849
+ requireApproval,
850
+ minApprovals,
851
+ approvalCommit,
852
+ rollbackOnRegression,
853
+ requireRollbackSnapshot,
854
+ snapshotLimits: {
855
+ maxFiles: snapshotLimits.maxFiles,
856
+ maxBytes: snapshotLimits.maxBytes,
857
+ maxFileBytes: snapshotLimits.maxFileBytes,
858
+ },
859
+ },
510
860
  baseline: toVerifySnapshot(baselineVerifyRun),
511
861
  attempts,
512
862
  finalVerify: currentSnapshot,
@@ -535,6 +885,9 @@ async function remediateCommand(options = {}) {
535
885
  console.log(chalk.dim(`Strict mode: artifacts=${strictArtifacts ? 'on' : 'off'}, `
536
886
  + `change-contract=${enforceChangeContract ? 'on' : 'off'}, `
537
887
  + `runtime-guard=${requireRuntimeGuard ? 'required' : 'optional'}`));
888
+ console.log(chalk.dim(`Governance: approval=${requireApproval ? `required(${minApprovals})` : 'optional'}, `
889
+ + `rollback=${rollbackOnRegression ? 'on' : 'off'}`
890
+ + `${rollbackOnRegression ? ` [maxFiles=${snapshotLimits.maxFiles}, maxBytes=${snapshotLimits.maxBytes}]` : ''}`));
538
891
  for (const attempt of output.attempts) {
539
892
  const after = attempt.after;
540
893
  const afterLabel = after
@@ -545,6 +898,23 @@ async function remediateCommand(options = {}) {
545
898
  console.log(chalk.dim(` runtime guard: ${attempt.runtimeGuard.pass ? 'pass' : 'block'}`
546
899
  + `${attempt.runtimeGuard.message ? ` (${attempt.runtimeGuard.message})` : ''}`));
547
900
  }
901
+ if (attempt.approval.required) {
902
+ console.log(chalk.dim(` approvals: ${attempt.approval.satisfied ? 'satisfied' : 'blocked'} `
903
+ + `(${attempt.approval.distinctApprovers}/${attempt.approval.minimumApprovals})`
904
+ + `${attempt.approval.commitSha ? ` on ${attempt.approval.commitSha}` : ''}`));
905
+ }
906
+ if (attempt.rollback.snapshotCreated || attempt.rollback.restored || attempt.rollback.message) {
907
+ const rollbackPrefix = attempt.rollback.restored ? 'restored' : (attempt.rollback.snapshotCreated ? 'captured' : 'skipped');
908
+ console.log(chalk.dim(` rollback: ${rollbackPrefix}`
909
+ + `${attempt.rollback.snapshotId ? ` (${attempt.rollback.snapshotId})` : ''}`
910
+ + `${attempt.rollback.message ? ` - ${attempt.rollback.message}` : ''}`));
911
+ }
912
+ if (attempt.rollback.postRollbackVerify) {
913
+ const rollbackVerify = attempt.rollback.postRollbackVerify;
914
+ console.log(chalk.dim(` rollback verify: ${rollbackVerify.verdict || 'UNKNOWN'}`
915
+ + `${rollbackVerify.score != null ? ` (score ${rollbackVerify.score})` : ''}`
916
+ + `, violations: ${rollbackVerify.violations}`));
917
+ }
548
918
  if (attempt.improved === false) {
549
919
  console.log(chalk.yellow(' no measurable governance improvement; stopping remediation loop'));
550
920
  }
@@ -579,6 +949,18 @@ async function remediateCommand(options = {}) {
579
949
  enforceChangeContract,
580
950
  requireRuntimeGuard,
581
951
  },
952
+ governance: {
953
+ requireApproval,
954
+ minApprovals,
955
+ approvalCommit,
956
+ rollbackOnRegression,
957
+ requireRollbackSnapshot,
958
+ snapshotLimits: {
959
+ maxFiles: snapshotLimits.maxFiles,
960
+ maxBytes: snapshotLimits.maxBytes,
961
+ maxFileBytes: snapshotLimits.maxFileBytes,
962
+ },
963
+ },
582
964
  baseline: {
583
965
  exitCode: 1,
584
966
  verdict: null,