sneakoscope 3.1.11 → 3.1.13

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 (49) hide show
  1. package/README.md +8 -7
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +1 -1
  5. package/dist/bin/sks.js +1 -1
  6. package/dist/commands/doctor.js +161 -2
  7. package/dist/core/agents/agent-role-config.js +12 -1
  8. package/dist/core/codex/agent-config-file-repair.js +157 -0
  9. package/dist/core/codex/codex-startup-config-postcheck.js +83 -0
  10. package/dist/core/codex-control/codex-0140-capability.js +136 -0
  11. package/dist/core/codex-control/codex-0140-feature-probes.js +195 -0
  12. package/dist/core/codex-control/codex-0140-probe-runner.js +5 -0
  13. package/dist/core/codex-control/codex-0140-real-probe-summary.js +12 -0
  14. package/dist/core/codex-control/codex-0140-real-probes.js +69 -0
  15. package/dist/core/codex-control/codex-0140-usage-parser.js +81 -0
  16. package/dist/core/codex-native/codex-native-feature-broker.js +15 -1
  17. package/dist/core/codex-native/native-capability-postcheck.js +5 -2
  18. package/dist/core/codex-native/native-capability-repair-matrix.js +4 -4
  19. package/dist/core/config/config-migration-journal.js +2 -0
  20. package/dist/core/config/secret-preservation.js +108 -11
  21. package/dist/core/config/supabase-secret-preservation.js +1 -0
  22. package/dist/core/doctor/codex-startup-config-repair.js +40 -0
  23. package/dist/core/doctor/context7-mcp-repair.js +77 -0
  24. package/dist/core/doctor/doctor-codex-startup-repair.js +127 -15
  25. package/dist/core/doctor/doctor-context7-repair.js +40 -1
  26. package/dist/core/doctor/doctor-repair-postcheck.js +17 -0
  27. package/dist/core/doctor/doctor-transaction.js +126 -0
  28. package/dist/core/doctor/supabase-mcp-repair.js +66 -0
  29. package/dist/core/fsx.js +1 -1
  30. package/dist/core/loops/loop-concurrency-budget.js +22 -0
  31. package/dist/core/mcp/mcp-config-preservation.js +53 -0
  32. package/dist/core/naruto/naruto-loop-mesh.js +5 -1
  33. package/dist/core/version.js +1 -1
  34. package/dist/core/zellij/zellij-fake-adapter.js +8 -2
  35. package/dist/core/zellij/zellij-launcher.js +16 -0
  36. package/dist/core/zellij/zellij-worker-pane-manager.js +19 -2
  37. package/dist/scripts/codex-0140-feature-gate-lib.js +14 -0
  38. package/dist/scripts/release-3112-required-gates.js +30 -0
  39. package/dist/scripts/release-3113-required-gates.js +25 -0
  40. package/package.json +38 -2
  41. package/dist/.sks-build-stamp.json +0 -8
  42. package/dist/scripts/loop-directive-check-lib.js +0 -388
  43. package/dist/scripts/loop-hardening-check-lib.js +0 -289
  44. package/dist/scripts/sks-1-12-real-execution-check-lib.js +0 -27
  45. package/dist/scripts/sks-3-1-4-directive-check-lib.js +0 -212
  46. package/dist/scripts/sks-3-1-5-directive-check-lib.js +0 -318
  47. package/dist/scripts/sks-3-1-6-directive-check-lib.js +0 -522
  48. package/dist/scripts/sks-3-1-7-directive-check-lib.js +0 -58
  49. package/dist/scripts/sks-3-1-8-check-lib.js +0 -30
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { ensureDir, nowIso, readJson, readText, sha256, writeJsonAtomic } from '../fsx.js';
5
5
  import { PROTECTED_SECRET_KEYS, PROTECTED_SUPABASE_ENV_KEYS } from './supabase-secret-preservation.js';
6
- const activeGuardRoots = new Set();
6
+ const activeGuardRoots = new Map();
7
7
  export async function captureSecretPreservationSnapshot(input) {
8
8
  const root = path.resolve(input.root);
9
9
  const sources = secretSources(root);
@@ -32,9 +32,13 @@ export async function captureSecretPreservationSnapshot(input) {
32
32
  }
33
33
  export async function withSecretPreservationGuard(root, operationName, fn) {
34
34
  const resolvedRoot = path.resolve(root);
35
- if (activeGuardRoots.has(resolvedRoot))
35
+ const active = activeGuardRoots.get(resolvedRoot);
36
+ if (active) {
37
+ active.nestedOperations.push(operationName);
36
38
  return fn();
37
- activeGuardRoots.add(resolvedRoot);
39
+ }
40
+ const guardContext = { nestedOperations: [] };
41
+ activeGuardRoots.set(resolvedRoot, guardContext);
38
42
  const reportDir = path.join(resolvedRoot, '.sneakoscope', 'reports');
39
43
  await ensureDir(reportDir);
40
44
  const beforePath = path.join(reportDir, 'secret-preservation-before.json');
@@ -56,9 +60,13 @@ export async function withSecretPreservationGuard(root, operationName, fn) {
56
60
  let rollbackAttempted = false;
57
61
  let rollbackOk = false;
58
62
  let restoredKeysCount = 0;
63
+ let restoreMode = 'none';
64
+ let unrelatedChangesPreserved = true;
59
65
  if (changedOrMissing.length) {
60
66
  rollbackAttempted = true;
61
- await restoreChangedSecretSources(changedOrMissing, backup.bySource);
67
+ const restore = await restoreChangedSecretSources(changedOrMissing, backup.bySource);
68
+ restoreMode = restore.mode;
69
+ unrelatedChangesPreserved = restore.unrelated_changes_preserved;
62
70
  const restored = await captureSecretPreservationSnapshot({
63
71
  root: resolvedRoot,
64
72
  artifactPath: path.join(reportDir, 'secret-preservation-after-restore.json')
@@ -67,12 +75,12 @@ export async function withSecretPreservationGuard(root, operationName, fn) {
67
75
  rollbackOk = remaining.length === 0;
68
76
  restoredKeysCount = rollbackOk ? changedOrMissing.length : 0;
69
77
  if (!rollbackOk) {
70
- const failedReport = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, false, backup.paths);
78
+ const failedReport = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, false, backup.paths, 'failed', false, guardContext.nestedOperations);
71
79
  await writeJsonAtomic(guardPath, operationError ? { ...failedReport, ok: false, operation_error: sanitizeErrorMessage(operationError) } : failedReport).catch(() => undefined);
72
80
  throw new Error(`secret_preservation_rollback_failed:${changedOrMissing.map((item) => `${safeSourceForError(resolvedRoot, item.source)}:${item.key}:${item.reason}`).join(',')}`);
73
81
  }
74
82
  }
75
- const report = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackAttempted ? rollbackOk : true, backup.paths);
83
+ const report = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackAttempted ? rollbackOk : true, backup.paths, restoreMode, unrelatedChangesPreserved, guardContext.nestedOperations);
76
84
  if (operationError) {
77
85
  report.ok = false;
78
86
  report.operation_error = sanitizeErrorMessage(operationError);
@@ -80,7 +88,7 @@ export async function withSecretPreservationGuard(root, operationName, fn) {
80
88
  await writeJsonAtomic(guardPath, report).catch(() => undefined);
81
89
  if (operationError)
82
90
  throw operationError;
83
- if (operationName === 'doctor-fix' && rollbackAttempted) {
91
+ if (rollbackAttempted) {
84
92
  throw new Error(`secret_preservation_restored:${changedOrMissing.map((item) => `${safeSourceForError(resolvedRoot, item.source)}:${item.key}:${item.reason}`).join(',')}`);
85
93
  }
86
94
  return result;
@@ -214,7 +222,7 @@ function dedupeFingerprints(fingerprints) {
214
222
  byKey.set(`${fp.source}\0${fp.key}`, fp);
215
223
  return [...byKey.values()].sort((a, b) => a.source.localeCompare(b.source) || a.key.localeCompare(b.key));
216
224
  }
217
- function guardReport(operation, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackOk, backupPaths) {
225
+ function guardReport(operation, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackOk, backupPaths, restoreMode, unrelatedChangesPreserved, nestedOperations) {
218
226
  return {
219
227
  schema: 'sks.secret-preservation-guard.v1',
220
228
  generated_at: nowIso(),
@@ -227,6 +235,9 @@ function guardReport(operation, beforePath, afterPath, changedOrMissing, restore
227
235
  missing_after: changedOrMissing.filter((item) => item.reason === 'missing').map((item) => ({ key: item.key, source: item.source })),
228
236
  rollback_attempted: rollbackAttempted,
229
237
  rollback_ok: rollbackOk,
238
+ restore_mode: restoreMode,
239
+ unrelated_changes_preserved: unrelatedChangesPreserved,
240
+ nested_operations: nestedOperations,
230
241
  backup_paths: backupPaths,
231
242
  raw_values_recorded: false
232
243
  };
@@ -246,13 +257,96 @@ async function backupSecretBearingSources(root, operationName, snapshot) {
246
257
  return { bySource, paths: [...bySource.values()] };
247
258
  }
248
259
  async function restoreChangedSecretSources(changedOrMissing, backups) {
260
+ let wholeFileFallback = false;
249
261
  for (const source of [...new Set(changedOrMissing.map((item) => item.source))]) {
250
262
  const backup = backups.get(source);
251
- if (!backup)
263
+ if (!backup) {
264
+ wholeFileFallback = true;
252
265
  continue;
253
- await ensureDir(path.dirname(source));
254
- await fs.copyFile(backup, source);
266
+ }
267
+ const sourceChanges = changedOrMissing.filter((item) => item.source === source);
268
+ const restoredLineLevel = await restoreSecretLines(source, backup, sourceChanges);
269
+ if (!restoredLineLevel) {
270
+ wholeFileFallback = true;
271
+ await ensureDir(path.dirname(source));
272
+ await fs.copyFile(backup, source);
273
+ }
274
+ }
275
+ return { mode: wholeFileFallback ? 'whole-file' : 'line-level', unrelated_changes_preserved: !wholeFileFallback };
276
+ }
277
+ async function restoreSecretLines(source, backup, changes) {
278
+ if (source.endsWith('.json'))
279
+ return false;
280
+ const current = await fs.readFile(source, 'utf8').catch(() => null);
281
+ const before = await fs.readFile(backup, 'utf8').catch(() => null);
282
+ if (current == null || before == null)
283
+ return false;
284
+ let lines = current.split(/\r?\n/);
285
+ const beforeLines = before.split(/\r?\n/);
286
+ let changed = false;
287
+ for (const item of changes) {
288
+ const beforeLine = findAssignmentLine(beforeLines, item.key);
289
+ if (!beforeLine)
290
+ return false;
291
+ const currentLine = findAssignmentLine(lines, item.key);
292
+ if (currentLine) {
293
+ lines[currentLine.index] = beforeLine.line;
294
+ }
295
+ else {
296
+ const insertAt = insertionIndexForKey(lines, item.key);
297
+ lines.splice(insertAt, 0, beforeLine.line);
298
+ }
299
+ changed = true;
300
+ }
301
+ if (changed)
302
+ await fs.writeFile(source, lines.join('\n'), 'utf8');
303
+ return true;
304
+ }
305
+ function findAssignmentLine(lines, key) {
306
+ const sectionKey = sectionKeyFor(key);
307
+ if (sectionKey) {
308
+ const range = sectionRange(lines, sectionKey.section);
309
+ if (!range)
310
+ return null;
311
+ const re = new RegExp(`^\\s*${escapeRegExp(sectionKey.key)}\\s*=`);
312
+ for (let index = range.start + 1; index < range.end; index += 1) {
313
+ if (re.test(lines[index] || ''))
314
+ return { index, line: lines[index] || '' };
315
+ }
316
+ return null;
317
+ }
318
+ const re = new RegExp(`^\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=`);
319
+ const index = lines.findIndex((line) => re.test(line));
320
+ return index >= 0 ? { index, line: lines[index] || '' } : null;
321
+ }
322
+ function insertionIndexForKey(lines, key) {
323
+ const sectionKey = sectionKeyFor(key);
324
+ if (!sectionKey)
325
+ return Math.max(0, lines.length - (lines[lines.length - 1] === '' ? 1 : 0));
326
+ const range = sectionRange(lines, sectionKey.section);
327
+ return range ? range.end : Math.max(0, lines.length - (lines[lines.length - 1] === '' ? 1 : 0));
328
+ }
329
+ function sectionKeyFor(key) {
330
+ if (!key.includes('.'))
331
+ return null;
332
+ const parts = key.split('.');
333
+ const leaf = parts.pop();
334
+ const section = parts.join('.');
335
+ return leaf && section ? { section, key: leaf } : null;
336
+ }
337
+ function sectionRange(lines, section) {
338
+ const header = new RegExp(`^\\s*\\[${escapeRegExp(section)}\\]\\s*(?:#.*)?$`);
339
+ const start = lines.findIndex((line) => header.test(line));
340
+ if (start < 0)
341
+ return null;
342
+ let end = lines.length;
343
+ for (let index = start + 1; index < lines.length; index += 1) {
344
+ if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(lines[index] || '')) {
345
+ end = index;
346
+ break;
347
+ }
255
348
  }
349
+ return { start, end };
256
350
  }
257
351
  function sanitizeSegment(value) {
258
352
  return String(value || 'operation').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'operation';
@@ -274,4 +368,7 @@ function sanitizeErrorMessage(err) {
274
368
  const message = err instanceof Error ? err.message : String(err);
275
369
  return message.replace(/([A-Za-z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Za-z0-9_]*=)[^\s,;]+/gi, '$1<redacted>');
276
370
  }
371
+ function escapeRegExp(value) {
372
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
373
+ }
277
374
  //# sourceMappingURL=secret-preservation.js.map
@@ -1,5 +1,6 @@
1
1
  export const PROTECTED_SUPABASE_ENV_KEYS = [
2
2
  'SUPABASE_URL',
3
+ 'SUPABASE_ACCESS_TOKEN',
3
4
  'SUPABASE_ANON_KEY',
4
5
  'SUPABASE_SERVICE_ROLE_KEY',
5
6
  'NEXT_PUBLIC_SUPABASE_URL',
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import { nowIso, writeJsonAtomic } from '../fsx.js';
3
+ import { repairAgentRoleConfigs } from '../agents/agent-role-config.js';
4
+ import { repairAgentConfigFileReferences } from '../codex/agent-config-file-repair.js';
5
+ import { postcheckCodexStartupConfig } from '../codex/codex-startup-config-postcheck.js';
6
+ export async function repairCodexStartupConfig(input) {
7
+ const root = path.resolve(input.root);
8
+ const roleRepair = await repairAgentRoleConfigs({
9
+ root,
10
+ apply: input.apply === true,
11
+ reportPath: path.join(root, '.sneakoscope', 'reports', 'agent-role-config-repair.json')
12
+ });
13
+ const fileRepair = await repairAgentConfigFileReferences({
14
+ root,
15
+ apply: input.apply === true,
16
+ reportPath: path.join(root, '.sneakoscope', 'reports', 'agent-config-file-repair.json')
17
+ });
18
+ const postcheck = await postcheckCodexStartupConfig({
19
+ root,
20
+ reportPath: path.join(root, '.sneakoscope', 'reports', 'codex-startup-config-postcheck.json')
21
+ });
22
+ const report = {
23
+ schema: 'sks.codex-startup-config-repair.v1',
24
+ generated_at: nowIso(),
25
+ ok: roleRepair.ok && fileRepair.ok && postcheck.ok,
26
+ apply: input.apply === true,
27
+ role_repair: roleRepair,
28
+ config_file_repair: fileRepair,
29
+ postcheck,
30
+ blockers: [
31
+ ...(roleRepair.blockers || []),
32
+ ...fileRepair.blockers,
33
+ ...postcheck.blockers
34
+ ]
35
+ };
36
+ if (input.reportPath !== null)
37
+ await writeJsonAtomic(input.reportPath || path.join(root, '.sneakoscope', 'reports', 'codex-startup-config-repair.json'), report).catch(() => undefined);
38
+ return report;
39
+ }
40
+ //# sourceMappingURL=codex-startup-config-repair.js.map
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+ import { ensureDir, nowIso, writeJsonAtomic, writeTextAtomic } from '../fsx.js';
3
+ import { CONTEXT7_REMOTE_MCP_URL, mcpServerBlock, mcpServerExplicitlyDisabled, readProjectCodexConfig, replaceOrAppendMcpServerBlock } from '../mcp/mcp-config-preservation.js';
4
+ import { guardedWriteFile, guardContextForRoute } from '../safety/mutation-guard.js';
5
+ import { createRequestedScopeContract } from '../safety/requested-scope-contract.js';
6
+ export async function repairContext7Mcp(input) {
7
+ const root = path.resolve(input.root);
8
+ const config = await readProjectCodexConfig(root);
9
+ const beforeTransport = classifyContext7Transport(config.text);
10
+ const disabledPreserved = beforeTransport === 'disabled';
11
+ let afterText = config.text;
12
+ let repaired = false;
13
+ if (beforeTransport === 'stdio') {
14
+ afterText = replaceOrAppendMcpServerBlock(config.text, 'context7', [
15
+ '[mcp_servers.context7]',
16
+ `url = "${CONTEXT7_REMOTE_MCP_URL}"`,
17
+ ''
18
+ ].join('\n'));
19
+ repaired = afterText !== config.text;
20
+ }
21
+ if (input.apply && repaired) {
22
+ await ensureDir(path.dirname(config.path));
23
+ const backupPath = `${config.path}.context7-mcp-repair-${Date.now().toString(36)}.bak`;
24
+ const contract = createRequestedScopeContract({
25
+ route: '$Team',
26
+ userRequest: 'Write a scoped project backup before doctor Context7 MCP repair.',
27
+ projectRoot: root
28
+ });
29
+ await guardedWriteFile(guardContextForRoute(root, contract, 'doctor Context7 MCP repair backup'), backupPath, config.text).catch(() => undefined);
30
+ await writeTextAtomic(config.path, afterText);
31
+ }
32
+ const after = input.apply && repaired ? await readProjectCodexConfig(root) : { text: afterText };
33
+ const afterTransport = classifyContext7Transport(after.text);
34
+ const remoteProbeStatus = afterTransport === 'remote' && process.env.SKS_CONTEXT7_REMOTE_PROBE === '1'
35
+ ? await probeRemoteContext7()
36
+ : 'skipped';
37
+ const report = {
38
+ schema: 'sks.doctor-context7-mcp-repair.v1',
39
+ generated_at: nowIso(),
40
+ ok: afterTransport === 'remote' || afterTransport === 'disabled' || beforeTransport === 'missing',
41
+ apply: input.apply === true,
42
+ config_path: config.path,
43
+ before_transport: beforeTransport,
44
+ after_transport: afterTransport,
45
+ disabled_preserved: disabledPreserved && afterTransport === 'disabled',
46
+ remote_probe_status: remoteProbeStatus,
47
+ repaired: input.apply === true && repaired,
48
+ manual_required: false,
49
+ blockers: afterTransport === 'stdio' ? ['context7_mcp_still_stdio'] : [],
50
+ warnings: beforeTransport === 'missing' ? ['context7_mcp_not_configured'] : []
51
+ };
52
+ if (input.reportPath !== null)
53
+ await writeJsonAtomic(input.reportPath || path.join(root, '.sneakoscope', 'reports', 'doctor-context7-mcp-repair.json'), report).catch(() => undefined);
54
+ return report;
55
+ }
56
+ async function probeRemoteContext7() {
57
+ try {
58
+ const response = await fetch(CONTEXT7_REMOTE_MCP_URL, { method: 'HEAD' });
59
+ return response.status < 500 ? 'ok' : 'failed';
60
+ }
61
+ catch {
62
+ return 'failed';
63
+ }
64
+ }
65
+ export function classifyContext7Transport(text) {
66
+ if (mcpServerExplicitlyDisabled(text, 'context7'))
67
+ return 'disabled';
68
+ const block = mcpServerBlock(text, 'context7');
69
+ if (!block)
70
+ return 'missing';
71
+ if (/^\s*url\s*=/m.test(block))
72
+ return 'remote';
73
+ if (/^\s*command\s*=|stdio|npx|context7/i.test(block))
74
+ return 'stdio';
75
+ return 'unknown';
76
+ }
77
+ //# sourceMappingURL=context7-mcp-repair.js.map
@@ -32,7 +32,7 @@ export async function runDoctorCodexStartupRepair(input) {
32
32
  { scope: 'project', path: path.join(root, '.codex', 'config.toml'), agentDir: path.join(root, '.codex', 'agents') },
33
33
  { scope: 'global', path: path.join(codexHome, 'config.toml'), agentDir: path.join(codexHome, 'agents') }
34
34
  ]) {
35
- configs.push(await inspectOrRepairConfig(candidate, input.fix));
35
+ configs.push(await inspectOrRepairConfig(candidate, input.fix, input.nodeReplCommandCandidates || [], input.includeDefaultNodeReplCandidates !== false));
36
36
  }
37
37
  const blockers = [...roleFiles.blockers, ...configs.flatMap((entry) => entry.blockers.map((item) => `${entry.scope}:${item}`))];
38
38
  const warnings = configs.flatMap((entry) => entry.warnings.map((item) => `${entry.scope}:${item}`));
@@ -41,6 +41,7 @@ export async function runDoctorCodexStartupRepair(input) {
41
41
  ...roleFiles.created.map((file) => `created missing SKS agent role config ${file}`),
42
42
  ...configs.flatMap((entry) => [
43
43
  ...entry.agent_config_files_repaired.map((file) => `${entry.scope} agent config_file now points at ${file}`),
44
+ ...(entry.mcp_blocks_repaired || []).map((server) => `${entry.scope} MCP block repaired: ${server}`),
44
45
  ...entry.stale_mcp_blocks_removed.map((server) => `${entry.scope} stale MCP block removed: ${server}`)
45
46
  ])
46
47
  ];
@@ -69,7 +70,7 @@ export async function runDoctorCodexStartupRepair(input) {
69
70
  await writeJsonAtomic(reportPath, report);
70
71
  return report;
71
72
  }
72
- async function inspectOrRepairConfig(candidate, fix) {
73
+ async function inspectOrRepairConfig(candidate, fix, nodeReplCommandCandidates, includeDefaultNodeReplCandidates) {
73
74
  const text = await readText(candidate.path, null);
74
75
  if (text == null) {
75
76
  return {
@@ -80,6 +81,7 @@ async function inspectOrRepairConfig(candidate, fix) {
80
81
  backup_path: null,
81
82
  agent_config_files_repaired: [],
82
83
  stale_mcp_blocks_removed: [],
84
+ mcp_blocks_repaired: [],
83
85
  optional_mcp_blocks_ignored: [],
84
86
  blockers: [],
85
87
  warnings: candidate.scope === 'global' ? ['codex_home_config_missing_optional'] : []
@@ -88,6 +90,7 @@ async function inspectOrRepairConfig(candidate, fix) {
88
90
  let next = text;
89
91
  const agentConfigFilesRepaired = [];
90
92
  const staleMcpBlocksRemoved = [];
93
+ const mcpBlocksRepaired = [];
91
94
  const optionalMcpBlocksIgnored = [];
92
95
  const blockers = [];
93
96
  const warnings = [];
@@ -111,19 +114,11 @@ async function inspectOrRepairConfig(candidate, fix) {
111
114
  next = replaceOrInsertKey(next, table, 'config_file', `"${escapeToml(target)}"`);
112
115
  agentConfigFilesRepaired.push(target);
113
116
  }
114
- for (const server of ['node_repl']) {
115
- const table = tomlBlock(next, `mcp_servers.${server}`);
116
- if (!table)
117
- continue;
118
- const command = stringValue(table.text, 'command');
119
- if (!command || await commandExists(command))
120
- continue;
121
- warnings.push(`stale_mcp_command_missing:${server}`);
122
- if (fix) {
123
- next = removeTomlBlock(next, table);
124
- staleMcpBlocksRemoved.push(server);
125
- }
126
- }
117
+ const nodeReplRepair = await inspectOrRepairNodeRepl(next, fix, nodeReplCommandCandidates, includeDefaultNodeReplCandidates);
118
+ next = nodeReplRepair.text;
119
+ warnings.push(...nodeReplRepair.warnings);
120
+ staleMcpBlocksRemoved.push(...nodeReplRepair.removed);
121
+ mcpBlocksRepaired.push(...nodeReplRepair.repaired);
127
122
  for (const server of ['supabase_sauron']) {
128
123
  if (tomlBlock(next, `mcp_servers.${server}`))
129
124
  optionalMcpBlocksIgnored.push(server);
@@ -147,11 +142,60 @@ async function inspectOrRepairConfig(candidate, fix) {
147
142
  backup_path: backupPath,
148
143
  agent_config_files_repaired: agentConfigFilesRepaired,
149
144
  stale_mcp_blocks_removed: staleMcpBlocksRemoved,
145
+ mcp_blocks_repaired: mcpBlocksRepaired,
150
146
  optional_mcp_blocks_ignored: optionalMcpBlocksIgnored,
151
147
  blockers,
152
148
  warnings
153
149
  };
154
150
  }
151
+ async function inspectOrRepairNodeRepl(text, fix, extraCandidates, includeDefaultCandidates) {
152
+ const server = 'node_repl';
153
+ const table = tomlBlock(text, `mcp_servers.${server}`);
154
+ const fullTable = tomlBlockWithChildren(text, `mcp_servers.${server}`);
155
+ const childBlocks = tomlChildBlocks(text, `mcp_servers.${server}`);
156
+ if (!table && childBlocks.length === 0)
157
+ return { text, warnings: [], removed: [], repaired: [] };
158
+ const command = table ? stringValue(table.text, 'command') : null;
159
+ if (command && await commandExists(command)) {
160
+ return { text, warnings: [], removed: [], repaired: [] };
161
+ }
162
+ const warnings = [table ? `stale_mcp_command_missing:${server}` : `stale_mcp_orphan_children:${server}`];
163
+ if (!fix)
164
+ return { text, warnings, removed: [], repaired: [] };
165
+ const replacement = await firstExistingNodeReplCommand(text, extraCandidates, includeDefaultCandidates);
166
+ if (replacement) {
167
+ if (table) {
168
+ return {
169
+ text: replaceOrInsertKey(text, table, 'command', `"${escapeToml(replacement)}"`),
170
+ warnings,
171
+ removed: [],
172
+ repaired: [server]
173
+ };
174
+ }
175
+ if (childBlocks.length) {
176
+ const firstChild = childBlocks[0];
177
+ if (!firstChild)
178
+ return { text, warnings, removed: [], repaired: [] };
179
+ const mainBlock = `[mcp_servers.${server}]\ncommand = "${escapeToml(replacement)}"\nargs = []\n\n`;
180
+ return {
181
+ text: `${text.slice(0, firstChild.start).trimEnd()}${firstChild.start > 0 ? '\n\n' : ''}${mainBlock}${text.slice(firstChild.start).replace(/^\n+/, '')}`,
182
+ warnings,
183
+ removed: [],
184
+ repaired: [server]
185
+ };
186
+ }
187
+ }
188
+ const removalBlocks = [
189
+ ...(fullTable ? [fullTable] : table ? [table] : []),
190
+ ...childBlocks.filter((block) => !fullTable || block.start < fullTable.start || block.end > fullTable.end)
191
+ ];
192
+ return {
193
+ text: removeBlocks(text, removalBlocks),
194
+ warnings,
195
+ removed: [server],
196
+ repaired: []
197
+ };
198
+ }
155
199
  async function inspectAgentRoleFiles(root, codexHome) {
156
200
  const dirs = [path.join(root, '.codex', 'agents'), path.join(codexHome, 'agents')];
157
201
  const sanitized = [];
@@ -206,9 +250,38 @@ function tomlBlock(text, table) {
206
250
  const end = nextHeader >= 0 ? header.lastIndex + nextHeader : text.length;
207
251
  return { start, end, text: text.slice(start, end) };
208
252
  }
253
+ function tomlBlockWithChildren(text, table) {
254
+ const header = new RegExp(`(^|\\n)\\s*\\[${escapeRegExp(table)}\\]\\s*(?:#.*)?(?:\\n|$)`, 'g');
255
+ const match = header.exec(text);
256
+ if (!match)
257
+ return null;
258
+ const start = match.index + (match[1] ? 1 : 0);
259
+ const rest = text.slice(header.lastIndex);
260
+ const nextHeader = rest.search(new RegExp(`\\n\\s*\\[(?!${escapeRegExp(table)}(?:\\.|\\]))[^\\]]+\\]\\s*(?:#.*)?(?:\\n|$)`));
261
+ const end = nextHeader >= 0 ? header.lastIndex + nextHeader : text.length;
262
+ return { start, end, text: text.slice(start, end) };
263
+ }
264
+ function tomlChildBlocks(text, table) {
265
+ const blocks = [];
266
+ const header = new RegExp(`(^|\\n)\\s*\\[${escapeRegExp(table)}\\.[^\\]]+\\]\\s*(?:#.*)?(?:\\n|$)`, 'g');
267
+ let match;
268
+ while ((match = header.exec(text))) {
269
+ const start = match.index + (match[1] ? 1 : 0);
270
+ const rest = text.slice(header.lastIndex);
271
+ const nextHeader = rest.search(new RegExp(`\\n\\s*\\[(?!${escapeRegExp(table)}\\.)[^\\]]+\\]\\s*(?:#.*)?(?:\\n|$)`));
272
+ const end = nextHeader >= 0 ? header.lastIndex + nextHeader : text.length;
273
+ blocks.push({ start, end, text: text.slice(start, end) });
274
+ }
275
+ return blocks;
276
+ }
209
277
  function removeTomlBlock(text, block) {
210
278
  return `${text.slice(0, block.start).trimEnd()}${block.start > 0 ? '\n\n' : ''}${text.slice(block.end).replace(/^\n+/, '')}`;
211
279
  }
280
+ function removeBlocks(text, blocks) {
281
+ return [...blocks]
282
+ .sort((a, b) => b.start - a.start)
283
+ .reduce((current, block) => removeTomlBlock(current, block), text);
284
+ }
212
285
  function replaceOrInsertKey(text, block, key, encodedValue) {
213
286
  const lines = block.text.replace(/\s*$/, '').split('\n');
214
287
  const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
@@ -233,6 +306,45 @@ async function commandExists(command) {
233
306
  return true;
234
307
  return false;
235
308
  }
309
+ async function firstExistingNodeReplCommand(configText, extraCandidates, includeDefaultCandidates) {
310
+ const candidates = [
311
+ ...extraCandidates,
312
+ ...(includeDefaultCandidates ? [
313
+ process.env.SKS_NODE_REPL_COMMAND,
314
+ process.env.NODE_REPL_COMMAND,
315
+ ...nodeReplCandidatesFromNodePaths([
316
+ ...stringValues(configText, 'NODE_REPL_NODE_PATH'),
317
+ process.env.NODE_REPL_NODE_PATH
318
+ ]),
319
+ '/Applications/Codex.app/Contents/Resources/cua_node/bin/node_repl',
320
+ '/Applications/Codex.app/Contents/Resources/node_repl'
321
+ ] : [])
322
+ ]
323
+ .map((item) => String(item || '').trim())
324
+ .filter(Boolean);
325
+ for (const candidate of [...new Set(candidates)]) {
326
+ if (await commandExists(candidate))
327
+ return candidate;
328
+ }
329
+ return null;
330
+ }
331
+ function nodeReplCandidatesFromNodePaths(values) {
332
+ const out = [];
333
+ for (const value of values) {
334
+ const nodePath = String(value || '').trim();
335
+ if (!nodePath)
336
+ continue;
337
+ const dir = path.dirname(nodePath);
338
+ out.push(path.join(dir, 'node_repl'));
339
+ const resources = path.basename(dir) === 'bin' ? path.dirname(path.dirname(dir)) : dir;
340
+ out.push(path.join(resources, 'cua_node', 'bin', 'node_repl'));
341
+ }
342
+ return out;
343
+ }
344
+ function stringValues(text, key) {
345
+ const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*"([^"]*)"`, 'gm');
346
+ return [...text.matchAll(re)].map((match) => String(match[1] || '')).filter(Boolean);
347
+ }
236
348
  async function backupConfig(configPath, text, label) {
237
349
  try {
238
350
  const backupPath = `${configPath}.sks-${label}-${Date.now().toString(36)}.bak`;
@@ -49,6 +49,26 @@ async function inspectOrRepairContext7Config(candidate, fix) {
49
49
  if (!block)
50
50
  return baseConfig(candidate, { present: true, status: 'missing' });
51
51
  if (/\burl\s*=\s*["']https:\/\/mcp\.context7\.com\/mcp["']/.test(block.text)) {
52
+ const childBlocks = context7ChildBlocks(text);
53
+ if (childBlocks.length) {
54
+ if (!fix) {
55
+ return baseConfig(candidate, {
56
+ present: true,
57
+ status: 'remote_child_env_detected',
58
+ warnings: ['remote_context7_child_env_unsupported_by_streamable_http']
59
+ });
60
+ }
61
+ const next = removeBlocks(text, childBlocks).replace(/\s*$/, '\n');
62
+ const backupPath = await backupConfig(candidate.path, text);
63
+ await writeTextAtomic(candidate.path, next);
64
+ return baseConfig(candidate, {
65
+ present: true,
66
+ status: 'repaired_to_remote',
67
+ changed: true,
68
+ backup_path: backupPath,
69
+ warnings: ['remote_context7_child_env_removed']
70
+ });
71
+ }
52
72
  return baseConfig(candidate, { present: true, status: 'already_remote' });
53
73
  }
54
74
  const localStdio = /@upstash\/context7-mcp|context7-mcp|command\s*=\s*["']npx(?:\s|["'])/i.test(block.text);
@@ -68,7 +88,8 @@ async function inspectOrRepairContext7Config(candidate, fix) {
68
88
  });
69
89
  }
70
90
  const remoteBlock = `[mcp_servers.context7]\nurl = "${CONTEXT7_REMOTE_URL}"\n`;
71
- const next = `${text.slice(0, block.start).trimEnd()}${block.start > 0 ? '\n\n' : ''}${remoteBlock}${text.slice(block.end).replace(/^\n+/, '\n')}`.replace(/\s*$/, '\n');
91
+ const withRemote = `${text.slice(0, block.start).trimEnd()}${block.start > 0 ? '\n\n' : ''}${remoteBlock}${text.slice(block.end).replace(/^\n+/, '\n')}`.replace(/\s*$/, '\n');
92
+ const next = removeBlocks(withRemote, context7ChildBlocks(withRemote)).replace(/\s*$/, '\n');
72
93
  const backupPath = await backupConfig(candidate.path, text);
73
94
  await writeTextAtomic(candidate.path, next);
74
95
  return baseConfig(candidate, {
@@ -79,6 +100,24 @@ async function inspectOrRepairContext7Config(candidate, fix) {
79
100
  warnings: ['local_stdio_context7_replaced_with_remote_mcp']
80
101
  });
81
102
  }
103
+ function context7ChildBlocks(text) {
104
+ const blocks = [];
105
+ const header = /(^|\n)\s*\[mcp_servers\.context7\.[^\]]+\]\s*(?:#.*)?(?:\n|$)/g;
106
+ let match;
107
+ while ((match = header.exec(text))) {
108
+ const start = match.index + (match[1] ? 1 : 0);
109
+ const rest = text.slice(header.lastIndex);
110
+ const nextHeader = rest.search(/\n\s*\[[^\]]+\]\s*(?:#.*)?(?:\n|$)/);
111
+ const end = nextHeader >= 0 ? header.lastIndex + nextHeader : text.length;
112
+ blocks.push({ start, end, text: text.slice(start, end) });
113
+ }
114
+ return blocks;
115
+ }
116
+ function removeBlocks(text, blocks) {
117
+ return [...blocks]
118
+ .sort((a, b) => b.start - a.start)
119
+ .reduce((current, block) => `${current.slice(0, block.start).trimEnd()}${block.start > 0 ? '\n\n' : ''}${current.slice(block.end).replace(/^\n+/, '')}`, text);
120
+ }
82
121
  function context7Block(text) {
83
122
  const header = /(^|\n)\s*\[mcp_servers\.context7\]\s*(?:#.*)?(?:\n|$)/g;
84
123
  const match = header.exec(text);
@@ -0,0 +1,17 @@
1
+ import {} from './doctor-transaction.js';
2
+ export function doctorRepairPostcheck(transaction) {
3
+ const phases = transaction?.phases || [];
4
+ const requiredBlockers = phases
5
+ .filter((phase) => phase.required_for_ready !== false && phase.ok !== true)
6
+ .flatMap((phase) => phase.blockers.length ? phase.blockers : [`required_phase_not_ready:${phase.id}`]);
7
+ return {
8
+ schema: 'sks.doctor-repair-postcheck.v1',
9
+ ok: transaction?.postcheck_ok === true && requiredBlockers.length === 0,
10
+ transaction_ok: transaction?.ok === true,
11
+ required_ready: requiredBlockers.length === 0,
12
+ manual_required: phases.filter((phase) => phase.manual_required).map((phase) => phase.id),
13
+ optional_manual_required: phases.filter((phase) => phase.manual_required && phase.required_for_ready === false).map((phase) => phase.id),
14
+ blockers: [...new Set([...requiredBlockers, ...phases.flatMap((phase) => phase.blockers)])]
15
+ };
16
+ }
17
+ //# sourceMappingURL=doctor-repair-postcheck.js.map