arkgate 3.6.0 → 3.7.0

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 (40) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +4 -5
  3. package/bin/ark-check.mjs +3 -3
  4. package/bin/ark-layer-match.mjs +2 -1
  5. package/bin/ark.mjs +2 -2
  6. package/bin/lib/agent-gates.mjs +2 -0
  7. package/bin/lib/analysis-engine.mjs +6 -6
  8. package/bin/lib/architecture-scan.mjs +66 -13
  9. package/bin/lib/ci-and-commands.mjs +5 -8
  10. package/bin/lib/codex-home.mjs +21 -6
  11. package/bin/lib/design-smells.mjs +67 -14
  12. package/bin/lib/doctor-advisories.mjs +20 -5
  13. package/bin/lib/doctor-plan.mjs +5 -7
  14. package/bin/lib/gate-files.mjs +1 -1
  15. package/bin/lib/hook-templates.mjs +21 -1
  16. package/bin/lib/html-report-advisories.mjs +59 -0
  17. package/bin/lib/install-migrate.mjs +26 -11
  18. package/bin/lib/mcp-adoption.mjs +21 -4
  19. package/bin/lib/parse-health.mjs +74 -0
  20. package/bin/lib/reshape-decisions.mjs +284 -0
  21. package/bin/lib/ts-resolve.mjs +3 -2
  22. package/bin/lib/write-path-capabilities.mjs +6 -0
  23. package/dist/eslint/index.cjs +2 -2
  24. package/dist/eslint/index.js +2 -2
  25. package/dist/index.cjs +7 -7
  26. package/dist/index.d.cts +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -6
  29. package/docs/agent-guide.md +34 -3
  30. package/docs/ai-gates.md +21 -18
  31. package/docs/configuration.md +6 -0
  32. package/docs/enthusiast/how-to-agent-gates.md +1 -1
  33. package/docs/package-surface.md +6 -5
  34. package/docs/typescript-support.md +9 -0
  35. package/package.json +2 -1
  36. package/server.json +2 -2
  37. package/templates/skills/ark-autopilot.md +12 -0
  38. package/templates/skills/ark-explore.md +8 -1
  39. package/templates/skills/ark-fix.md +11 -1
  40. package/templates/skills/ark-loop.md +14 -1
@@ -47,6 +47,7 @@ export function isTempOrUpgradeRoot(p) {
47
47
  /\/tmp\//i.test(n) ||
48
48
  /\/Temp\//i.test(n) ||
49
49
  /ark-upgrade/i.test(n) ||
50
+ /\/(?:\.claude|\.codex|\.grok)\/worktrees\//i.test(n) ||
50
51
  /\/T\/(?:ark-|grok-)/i.test(n) ||
51
52
  /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
52
53
  );
@@ -144,6 +145,23 @@ export function codexScopedTableForRoot(tomlText, absRoot) {
144
145
  return null;
145
146
  }
146
147
 
148
+ /** True when project TOML owns the primary Ark MCP binding for that project. */
149
+ export function codexProjectMcpIsValid(tomlText, projectRoot) {
150
+ const resolvedRoot = path.resolve(projectRoot);
151
+ const primary = codexPrimaryTable(tomlText);
152
+ if (!primary?.root || !/\b(ark|arkgate)-mcp\b/.test(primary.block)) return false;
153
+ const config = primary.block.match(/"--config"\s*,\s*"([^"]+)"/)?.[1];
154
+ if (!config) return false;
155
+ try {
156
+ return (
157
+ path.resolve(resolvedRoot, primary.root) === resolvedRoot &&
158
+ path.resolve(resolvedRoot, config) === path.join(resolvedRoot, 'ark.config.json')
159
+ );
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
147
165
  /** Extract --root from primary [mcp_servers.ark]. */
148
166
  export function extractCodexArkRootFromToml(tomlText) {
149
167
  return codexPrimaryTable(tomlText)?.root ?? null;
@@ -236,14 +254,11 @@ export function assessCodexHomeMcp(tomlText, absRoot) {
236
254
  message: scopedTable
237
255
  ? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
238
256
  `this project is registered as [mcp_servers.${scopedTable}]. ` +
239
- `Codex may still prefer the primary binding for ark://manifest rebind if this repo should own it.`
257
+ `Install the project-scoped binding so this repo owns ark://manifest when active.`
240
258
  : `Codex home primary MCP --root is another permanent project ` +
241
259
  `(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
242
- `Install without --force adds a scoped [mcp_servers.ark_<slug>] table and leaves primary unchanged; ` +
243
- `--force rebinds primary to this project.`,
244
- fixArgs: scopedTable
245
- ? '--install-agent-gates --tools codex --force'
246
- : '--install-agent-gates --tools codex',
260
+ `Install the project-scoped binding for this repo; the global primary can remain unchanged.`,
261
+ fixArgs: '--install-agent-gates --tools codex',
247
262
  };
248
263
  }
249
264
 
@@ -29,7 +29,7 @@ export const DESIGN_SMELL_OUTCOMES = Object.freeze({
29
29
  'io-under-application':
30
30
  'Business/application code reaches the database or external APIs directly — the AI will keep pasting I/O into the wrong place. Put data access behind a port/adapter.',
31
31
  'handler-in-persistence':
32
- 'HTTP handlers live under data/repository folders — names look like “storage” but they are routes. Move handlers to the API/UI layer so the AI stops mixing transport and storage.',
32
+ 'Static framework HTTP imports or route definitions live under data/repository folders — names look like “storage” but the code still owns transport. Move HTTP handling to the API/UI layer so the AI stops mixing transport and storage.',
33
33
  'god-module':
34
34
  'A few huge files own too many responsibilities — the AI cannot safely edit one concern without breaking others. Split the pilot file by job (one export surface per concern).',
35
35
  'domain-logic-in-ui':
@@ -70,6 +70,12 @@ const IO_IMPORT_RE =
70
70
  /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|better-sqlite3|ioredis|redis)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm)/;
71
71
  const HANDLER_CONTENT_RE =
72
72
  /\b(?:@Controller|@Get|@Post|@Put|@Delete|Router\(\)|createRouter|express\.Router|fastify\.(?:get|post)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b|export\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=)/;
73
+ const FRAMEWORK_HTTP_IMPORT_RE =
74
+ /(?:^|[;\n])\s*(?:import\s+(?:type\s+)?(?:[^;]{0,512}?\s+from\s+)?|export\s+(?:type\s+)?[^;]{0,512}?\s+from\s+)['"]next\/server(?:\.js)?['"]/;
75
+ const ROUTE_DEFINITION_CALL_RE =
76
+ /\bdefineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/;
77
+ const ROUTE_DEFINITION_DECLARATION_RE =
78
+ /\b(?:export\s+)?(?:declare\s+)?(?:async\s+)?function\s+defineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/g;
73
79
  const DOMAIN_LOGIC_UI_RE =
74
80
  /\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should)[A-Z]\w*|\b(?:export\s+)?const\s+(?:can|calculate|compute|should)[A-Z]\w*\s*=/;
75
81
  const EXPORT_RE =
@@ -139,7 +145,33 @@ function isPresentationLayer(name) {
139
145
  function isPersistenceLayer(name) {
140
146
  return (
141
147
  typeof name === 'string' &&
142
- (/persist|repository|infra|data.?access/i.test(name) || name === 'PersistenceAdapters')
148
+ /persist|repository|data.?access/i.test(name)
149
+ );
150
+ }
151
+
152
+ function stripObviousCommentsAndTemplates(source) {
153
+ return source
154
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
155
+ .replace(/\/\/[^\n]*/g, ' ')
156
+ .replace(/`(?:\\[\s\S]|[^\\`])*`/g, ' ');
157
+ }
158
+
159
+ function hasFrameworkHttpImport(source) {
160
+ return FRAMEWORK_HTTP_IMPORT_RE.test(stripObviousCommentsAndTemplates(source));
161
+ }
162
+
163
+ function hasRouteDefinitionCall(source) {
164
+ const code = stripObviousCommentsAndTemplates(source)
165
+ .replace(/(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/g, ' ')
166
+ .replace(ROUTE_DEFINITION_DECLARATION_RE, 'function __ark_defineRoute_declaration__(');
167
+ return ROUTE_DEFINITION_CALL_RE.test(code);
168
+ }
169
+
170
+ function hasHollowPersistenceShape(source) {
171
+ return (
172
+ hasFrameworkHttpImport(source) ||
173
+ hasRouteDefinitionCall(source) ||
174
+ HANDLER_CONTENT_RE.test(source)
143
175
  );
144
176
  }
145
177
 
@@ -172,6 +204,24 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
172
204
  if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
173
205
  relFiles.push(rel);
174
206
  }
207
+ // Y02: the general scan cap must not hide Persistence candidates in large
208
+ // trees. Filter by the stable role/path heuristics first, then bound reads.
209
+ const persistenceUniverse = [];
210
+ for (const f of files) {
211
+ const rel = normalizeRel(resolvedRoot, f);
212
+ if (!rel || rel.startsWith('..')) continue;
213
+ if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
214
+ if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
215
+ persistenceUniverse.push(rel);
216
+ }
217
+ const allPersistenceCandidates = [...new Set(persistenceUniverse)].sort().filter(
218
+ (rel) =>
219
+ PERSISTENCE_PATH_RE.test(rel) ||
220
+ isPersistenceLayer(layerNameFor(resolvedRoot, rel, config))
221
+ );
222
+ const persistenceCandidates = allPersistenceCandidates.slice(0, MAX_SCAN_FILES);
223
+ const persistenceCandidatesTruncated =
224
+ allPersistenceCandidates.length - persistenceCandidates.length;
175
225
 
176
226
  // soft-contract: layers with files but no rule edges
177
227
  const withoutRules = Array.isArray(coverage?.layersWithoutRules)
@@ -220,8 +270,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
220
270
  }
221
271
  if (/\/(?:domain|application|infrastructure|adapters)\//.test(rel)) hasHexPorts = true;
222
272
 
223
- const abs = path.join(resolvedRoot, rel);
224
- const source = readTextLimited(abs);
273
+ const source = readTextLimited(path.join(resolvedRoot, rel));
225
274
  if (source == null) continue;
226
275
 
227
276
  const layer = layerNameFor(resolvedRoot, rel, config);
@@ -232,13 +281,6 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
232
281
  godEvidence.push(rel);
233
282
  }
234
283
 
235
- if (
236
- (PERSISTENCE_PATH_RE.test(rel) || isPersistenceLayer(layer)) &&
237
- HANDLER_CONTENT_RE.test(source)
238
- ) {
239
- handlerInPersist.push(rel);
240
- }
241
-
242
284
  if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && DOMAIN_LOGIC_UI_RE.test(source)) {
243
285
  domainInUi.push(rel);
244
286
  }
@@ -257,6 +299,13 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
257
299
  }
258
300
  }
259
301
 
302
+ for (const rel of persistenceCandidates) {
303
+ const source = readTextLimited(path.join(resolvedRoot, rel));
304
+ if (source != null && hasHollowPersistenceShape(source)) {
305
+ handlerInPersist.push(rel);
306
+ }
307
+ }
308
+
260
309
  if (!falseGreen?.risk && ioUnderAppFiles.length > 0) {
261
310
  smells.push(
262
311
  makeDesignSmell({
@@ -270,13 +319,17 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
270
319
  }
271
320
 
272
321
  if (handlerInPersist.length > 0) {
322
+ const capNote =
323
+ persistenceCandidatesTruncated > 0
324
+ ? `; ${persistenceCandidatesTruncated} more Persistence candidate(s) were not inspected by the bounded scan`
325
+ : '';
273
326
  smells.push(
274
327
  makeDesignSmell({
275
328
  id: 'handler-in-persistence',
276
329
  severity: 'warn',
277
- message: `HTTP/route handler shape found under persistence/repository paths (${handlerInPersist.length} file(s)) — semantic false-green risk.`,
330
+ message: `Static framework HTTP import or route-definition/handler shape found in Persistence-role modules (${handlerInPersist.length} file(s)${capNote}) — semantic false-green risk.`,
278
331
  evidence: handlerInPersist.slice(0, 12),
279
- fix: 'Move handlers to Presentation/API; keep Persistence as data access only (/ark-explore shape-focus).',
332
+ fix: 'Move HTTP imports and route definitions to Presentation/API; keep Persistence as data access only (/ark-explore shape-focus).',
280
333
  })
281
334
  );
282
335
  }
@@ -414,7 +467,7 @@ function successSignalFor(id) {
414
467
  case 'io-under-application':
415
468
  return '0 Application-layer files import prisma/supabase/drizzle/pg clients; I/O behind ports';
416
469
  case 'handler-in-persistence':
417
- return '0 HTTP handler shapes under persistence/repository globs';
470
+ return '0 static framework HTTP imports or route-definition/handler shapes in Persistence-role modules';
418
471
  case 'god-module':
419
472
  return 'Pilot god module split; fan-in and export surface reduced without new edge violations';
420
473
  case 'domain-logic-in-ui':
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Doctor's advisory sensors, aggregated (W01 contract health + U05 ambient
3
- * state + X04 physical cohesion). Advisory only: nothing here feeds a
2
+ * Doctor's advisory sensors, aggregated (W01 contract health, U05 ambient
3
+ * state, X04 physical cohesion, Y03 parse health). Nothing here feeds a
4
4
  * verdict, designFitness, or an exit code. One seam keeps doctor-plan.mjs
5
5
  * inside its module budget as new advisory surfaces land.
6
6
  */
@@ -8,17 +8,30 @@ import { computeAmbientState, printAmbientStateSection } from './ambient-state.m
8
8
  import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
9
9
  import {
10
10
  computePhysicalCohesion,
11
- computeReshapePilot,
12
11
  printPhysicalCohesionSection,
13
12
  } from './physical-cohesion.mjs';
13
+ import {
14
+ computeDecisionAwareReshapePilot,
15
+ computeReshapeDecisionMemory,
16
+ printReshapeDecisionsSection,
17
+ } from './reshape-decisions.mjs';
18
+ import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
14
19
 
15
- export function computeDoctorAdvisories(root, config, cov, rules, files, ts) {
20
+ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth) {
16
21
  const physicalCohesion = computePhysicalCohesion(root, files);
17
- physicalCohesion.reshapePilot = computeReshapePilot(physicalCohesion, files, root);
22
+ const decisionMemory = computeReshapeDecisionMemory(root, files);
23
+ physicalCohesion.reshapeDecisions = decisionMemory.summary;
24
+ physicalCohesion.reshapePilot = computeDecisionAwareReshapePilot(
25
+ physicalCohesion,
26
+ files,
27
+ root,
28
+ decisionMemory
29
+ );
18
30
  return {
19
31
  contractHealth: computeContractHealth(root, config, cov, rules),
20
32
  ambientState: computeAmbientState(ts, root, config, files),
21
33
  physicalCohesion,
34
+ parseHealth: parseHealth ?? summarizeParseHealth(),
22
35
  };
23
36
  }
24
37
 
@@ -30,4 +43,6 @@ export function printDoctorAdvisories(advisories, io) {
30
43
  advisories.physicalCohesion?.reshapePilot,
31
44
  io
32
45
  );
46
+ printReshapeDecisionsSection(advisories.physicalCohesion?.reshapeDecisions, io);
47
+ printParseHealthSection(advisories.parseHealth, io);
33
48
  }
@@ -424,7 +424,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
424
424
  patternBets: patternBetsForLoop,
425
425
  designSmells,
426
426
  });
427
- const { contractHealth, ambientState, physicalCohesion } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05+X04 advisories — never a verdict
427
+ const doctorAdvisories = computeDoctorAdvisories(root, config, cov, rules, files, options.ts, options.parseHealth); // advisory only — never a verdict
428
428
 
429
429
  if (asJson) {
430
430
  console.log(
@@ -461,11 +461,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
461
461
  goldenPattern,
462
462
  // Q04: one-pilot loop (extraction card → re-doctor).
463
463
  pilotLoop,
464
- // Advisories, never a verdict: W01 contract health, U05 ambient
465
- // state (opt-in), X04 physical cohesion + proposed reshape pilot.
466
- contractHealth,
467
- ambientState,
468
- physicalCohesion,
464
+ // Advisories, never a verdict: W01 contract health, U05 ambient state,
465
+ // X04 physical cohesion/reshape pilot, Y03 parse health.
466
+ ...doctorAdvisories,
469
467
  governed: cov.governed,
470
468
  emptyLayers: cov.emptyLayers,
471
469
  layersWithoutRules: cov.layersWithoutRules,
@@ -647,7 +645,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
647
645
  );
648
646
  }
649
647
 
650
- printDoctorAdvisories({ contractHealth, ambientState, physicalCohesion }, { line, warn, color }); // advisory sections
648
+ printDoctorAdvisories(doctorAdvisories, { line, warn, color }); // advisory sections
651
649
 
652
650
  console.log('');
653
651
  console.log(color.bold('Coverage'));
@@ -125,7 +125,7 @@ const COMPACT_HOST_FILES = {
125
125
  claude: ['.claude/settings.json'],
126
126
  grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json'],
127
127
  cursor: ['.cursor/mcp.json'],
128
- codex: ['.codex/hooks.json'],
128
+ codex: ['.codex/hooks.json', '.codex/config.toml'],
129
129
  windsurf: ['.windsurf/rules/ark.md'],
130
130
  cline: ['.clinerules/ark.md'],
131
131
  copilot: ['.github/copilot-instructions.md'],
@@ -73,8 +73,28 @@ export function codexHooks(root) {
73
73
  }, null, 2)}\n`;
74
74
  }
75
75
 
76
+ // Codex project config: modern Codex resolves .codex/config.toml from the active
77
+ // project, so the primary `ark` binding can stay local instead of competing in
78
+ // the user's global $CODEX_HOME across every adopted repository.
79
+ export function codexProjectConfig(root) {
80
+ const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
81
+ '--root',
82
+ '.',
83
+ '--config',
84
+ 'ark.config.json',
85
+ ]);
86
+ const esc = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
87
+ const argsToml = args.map((value) => `"${esc(value)}"`).join(', ');
88
+ return `# Generated by ark-check --install-agent-gates (Codex project scope).
89
+ # Restart Codex after changes; MCP servers are loaded when the project session starts.
90
+ [mcp_servers.ark]
91
+ command = "${esc(command)}"
92
+ args = [${argsToml}]
93
+ `;
94
+ }
95
+
76
96
  // Grok Build project config: MCP registration (commit-friendly relative paths — unlike
77
- // Codex's global config.toml, Grok loads .grok/config.toml from the project).
97
+ // the optional Codex home fallback, Grok loads .grok/config.toml from the project).
78
98
  export function grokProjectConfig(root) {
79
99
  const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
80
100
  '--root',
@@ -152,6 +152,46 @@ function ambientStateHtml(state) {
152
152
  </section>`;
153
153
  }
154
154
 
155
+ function reshapeDecisionsHtml(memory) {
156
+ if (!memory) return '';
157
+ const lifecycle = memory.lifecycle ?? {};
158
+ const rows = [];
159
+ if (memory.decisionFile?.invalid) {
160
+ rows.push(
161
+ `<p><span class="tag warn">invalid</span> ${esc(memory.decisionFile.path)} is ignored; no reshape decision suppresses a pilot.</p>`
162
+ );
163
+ }
164
+ for (const decision of memory.current ?? []) {
165
+ const tag = decision.verdict === 'accepted' ? 'ok' : 'warn';
166
+ const review = decision.reviewBy ? ` · review-by ${esc(decision.reviewBy)}` : '';
167
+ rows.push(
168
+ `<p><span class="tag ${tag}">${esc(decision.verdict)}</span> <b>${esc(decision.concept)}</b>${review} — ${esc(decision.reason)}${decision.suppressesPilot ? ' Pilot pressure suppressed; mirror facts remain visible.' : ' Pilot remains available.'}</p>`
169
+ );
170
+ }
171
+ if ((memory.currentCount ?? 0) > (memory.current?.length ?? 0)) {
172
+ rows.push(`<p class="muted">…(+${memory.currentCount - memory.current.length} more current decision(s))</p>`);
173
+ }
174
+ if ((lifecycle.expiredCount ?? 0) > 0) {
175
+ rows.push(`<p><span class="tag warn">expired</span> ${lifecycle.expiredCount} decision(s) no longer apply; pilot pressure is active again.</p>`);
176
+ }
177
+ if ((lifecycle.malformedCount ?? 0) > 0) {
178
+ rows.push(`<p><span class="tag warn">malformed</span> ${lifecycle.malformedCount} review-by date(s) are invalid; those decisions are ignored.</p>`);
179
+ }
180
+ if ((lifecycle.staleCount ?? 0) > 0) {
181
+ const stale = (lifecycle.stale ?? [])
182
+ .slice(0, 4)
183
+ .map((decision) => `<code>${esc(decision.concept)}</code>`)
184
+ .join(' · ');
185
+ const more = lifecycle.staleCount > 4 ? ` …(+${lifecycle.staleCount - 4} more)` : '';
186
+ rows.push(`<p><span class="tag warn">stale</span> ${lifecycle.staleCount} decision(s) have a changed anchor set and no longer apply: ${stale}${more}</p>`);
187
+ }
188
+ if ((lifecycle.undated ?? 0) > 0) {
189
+ rows.push(`<p class="muted">${lifecycle.undated} current decision(s) have no review-by date.</p>`);
190
+ }
191
+ if (rows.length === 0) return '';
192
+ return `<div data-advisory="reshapeDecisions"><h3>Reshape decisions <span class="muted">(explicit; pilot pressure only)</span></h3>${rows.join('\n')}</div>`;
193
+ }
194
+
155
195
  function physicalCohesionHtml(pc) {
156
196
  if (!pc) return '';
157
197
  const findings = Array.isArray(pc.findings) ? pc.findings : [];
@@ -173,10 +213,28 @@ function physicalCohesionHtml(pc) {
173
213
  <section data-advisory="physicalCohesion">
174
214
  <h2>Physical cohesion <span class="muted">(advisory — facts, not a score; the verdict is unchanged)</span></h2>
175
215
  ${body}
216
+ ${reshapeDecisionsHtml(pc.reshapeDecisions)}
176
217
  ${pilot}
177
218
  </section>`;
178
219
  }
179
220
 
221
+ function parseHealthHtml(health) {
222
+ if (!health) return '';
223
+ const files = Array.isArray(health.files) ? health.files : [];
224
+ const body = health.available === false
225
+ ? '<p class="muted">Parse health was not available for this rendering — no clean claim is made.</p>'
226
+ : health.affectedFiles === 0
227
+ ? `<p class="muted">No parse diagnostics found across ${health.scannedFiles ?? 0} governed file(s) scanned.</p>`
228
+ : `<p><span class="tag warn">${health.affectedFiles} affected</span> ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} governed file(s).</p>` +
229
+ `<ul>${files.map((f) => `<li><code>${esc(f.file)}</code> — ${f.diagnosticCount} parse diagnostic(s)</li>`).join('')}</ul>` +
230
+ (health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
231
+ return `
232
+ <section data-advisory="parseHealth">
233
+ <h2>Parse health <span class="muted">(advisory — unreadable syntax is never silently called clean)</span></h2>
234
+ ${body}
235
+ </section>`;
236
+ }
237
+
180
238
  /**
181
239
  * Render every doctor advisory as report sections. Keys must cover everything
182
240
  * `computeDoctorAdvisories` returns — the parity guard enforces it.
@@ -189,6 +247,7 @@ export function renderAdvisorySections(advisories, escape) {
189
247
  contractHealthHtml(advisories.contractHealth),
190
248
  ambientStateHtml(advisories.ambientState),
191
249
  physicalCohesionHtml(advisories.physicalCohesion),
250
+ parseHealthHtml(advisories.parseHealth),
192
251
  ]
193
252
  .filter(Boolean)
194
253
  .join('\n');
@@ -15,6 +15,7 @@ import {
15
15
  codexSkillsDir,
16
16
  codexConfigPath,
17
17
  isTempOrUpgradeRoot,
18
+ upsertCodexMcpTable,
18
19
  usesDefaultCodexHome,
19
20
  wireCodexMcp,
20
21
  } from './codex-home.mjs';
@@ -22,6 +23,7 @@ import {
22
23
  PREFERRED_MCP_BIN,
23
24
  claudeSettings,
24
25
  codexHooks,
26
+ codexProjectConfig,
25
27
  grokHooks,
26
28
  grokProjectConfig,
27
29
  } from './hook-templates.mjs';
@@ -279,6 +281,7 @@ export function runInstallAgentGates(args) {
279
281
  }
280
282
  if (tools.has('codex')) {
281
283
  templates.push(['.codex/hooks.json', codexHooks(root)]);
284
+ templates.push(['.codex/config.toml', codexProjectConfig(root)]);
282
285
  if (!args.compact) templates.push(['docs/ark-codex-config.toml', codexTomlSnippet(root)]);
283
286
  }
284
287
  if (tools.has('grok')) {
@@ -341,14 +344,29 @@ export function runInstallAgentGates(args) {
341
344
  }
342
345
  }
343
346
 
344
- const results = templates.map(([relativePath, content]) =>
345
- writeTemplate(
347
+ const results = templates.map(([relativePath, content]) => {
348
+ if (relativePath === '.codex/config.toml') {
349
+ const fullPath = path.join(root, relativePath);
350
+ let existing = '';
351
+ try {
352
+ existing = fs.readFileSync(fullPath, 'utf8');
353
+ } catch {
354
+ // A missing project config starts from the generated Ark table.
355
+ }
356
+ const tableStart = content.indexOf('[mcp_servers.ark]');
357
+ const generatedPrelude = tableStart > 0 ? content.slice(0, tableStart) : '';
358
+ const mergeBase = generatedPrelude ? existing.replace(generatedPrelude, '') : existing;
359
+ const merged = upsertCodexMcpTable(mergeBase, 'ark', content);
360
+ if (merged === existing) return { relativePath, status: 'skipped' };
361
+ return writeTemplate(root, relativePath, merged, true);
362
+ }
363
+ return writeTemplate(
346
364
  root,
347
365
  relativePath,
348
366
  content,
349
367
  args.force || (args.compact && relativePath === 'AGENTS.md' && priorCompactHost !== null)
350
- )
351
- );
368
+ );
369
+ });
352
370
 
353
371
  console.log('Ark agent gate templates:');
354
372
  let staleSkipped = 0;
@@ -427,11 +445,8 @@ export function runInstallAgentGates(args) {
427
445
  }
428
446
  }
429
447
 
430
- // Auto-wire the ark MCP server into Codex's home config.toml. Claude and Cursor get
431
- // machine-readable registrations (.claude/settings.json, .cursor/mcp.json) written as repo
432
- // templates above; Codex reads MCP servers only from ~/.codex/config.toml, so it needs a
433
- // home-dir merge instead. Fires whenever Codex is in play so `ark://manifest` is live
434
- // without a manual copy step.
448
+ // Optional legacy/home fallback. Normal Codex installs use the project-scoped
449
+ // .codex/config.toml above, avoiding cross-project primary binding conflicts.
435
450
  //
436
451
  // Skip home MCP mutation when the project root is a temp/upgrade scratch *and*
437
452
  // CODEX_HOME is the default (~/.codex). Fixtures and agent smokes must not rewrite
@@ -439,7 +454,7 @@ export function runInstallAgentGates(args) {
439
454
  // home *skills* below; MCP binding of a temp root into default home is never safe.
440
455
  // A redirected CODEX_HOME (tests/isolation) may still wire as requested.
441
456
  let codexMcp = null;
442
- const wantCodexWire = !args.compact && (tools.has('codex') || args.codexHome);
457
+ const wantCodexWire = !args.compact && !args.skillsOnly && args.codexHome;
443
458
  const skipHomeWire =
444
459
  wantCodexWire && isTempOrUpgradeRoot(root) && usesDefaultCodexHome();
445
460
  if (wantCodexWire && !skipHomeWire) {
@@ -475,7 +490,7 @@ export function runInstallAgentGates(args) {
475
490
  }
476
491
  if (codexMcp?.status === 'failed') {
477
492
  console.error(
478
- `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --tools codex --force.`
493
+ `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --codex-home --force.`
479
494
  );
480
495
  }
481
496
  if (writeRequest.host) {
@@ -7,7 +7,11 @@ import path from 'node:path';
7
7
  import { arkCommand } from '../ark-shared.mjs';
8
8
  import { CORE_LAYER_NAMES } from './core-layers.mjs';
9
9
  import { falseGreenAdoptionGap } from './field-install.mjs';
10
- import { assessCodexHomeMcp, codexConfigPath } from './codex-home.mjs';
10
+ import {
11
+ assessCodexHomeMcp,
12
+ codexConfigPath,
13
+ codexProjectMcpIsValid,
14
+ } from './codex-home.mjs';
11
15
  import { detectWritePathCapabilities } from './write-path-detect.mjs';
12
16
  import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
13
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
@@ -19,7 +23,7 @@ export const COMMAND_GATE_TEXT_FILES = [
19
23
  '.claude/settings.json', 'AGENTS.md', '.cursor/rules/ark.mdc', '.windsurf/rules/ark.md',
20
24
  '.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
21
25
  '.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
22
- '.grok/hooks/ark-write-gate.json', '.grok/config.toml',
26
+ '.grok/hooks/ark-write-gate.json', '.grok/config.toml', '.codex/config.toml',
23
27
  ];
24
28
  export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
25
29
  // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
@@ -148,7 +152,10 @@ export function collectAdoptionGaps(root, config, coverage) {
148
152
  dir: '.codex',
149
153
  // Official Codex REPO skill catalog (Agent Skills standard) — not .codex/prompts.
150
154
  skill: (n) => path.join(root, '.agents', 'skills', n, 'SKILL.md'),
151
- extras: [['.codex/hooks.json', 'hooks']],
155
+ extras: [
156
+ ['.codex/hooks.json', 'hooks'],
157
+ ['.codex/config.toml', 'project MCP config'],
158
+ ],
152
159
  toolsFlag: 'codex',
153
160
  },
154
161
  ];
@@ -183,7 +190,17 @@ export function collectAdoptionGaps(root, config, coverage) {
183
190
 
184
191
  // --- Codex home MCP (temp path / wrong root / multi-project) ---
185
192
  let codexHome = null;
186
- if (adopted && !isProducer) {
193
+ const codexProjectMcp = (() => {
194
+ try {
195
+ return codexProjectMcpIsValid(
196
+ fs.readFileSync(path.join(root, '.codex', 'config.toml'), 'utf8'),
197
+ root
198
+ );
199
+ } catch {
200
+ return false;
201
+ }
202
+ })();
203
+ if (adopted && !isProducer && !codexProjectMcp) {
187
204
  const codexFile = codexConfigPath();
188
205
  let toml = '';
189
206
  try {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Y03 — parse honesty over the ASTs already created by architecture-scan.
3
+ * Advisory only: counts are transported, never raw TypeScript diagnostics.
4
+ */
5
+
6
+ export const PARSE_HEALTH_FILE_CAP = 12;
7
+
8
+ function unavailableParseHealth(scannedFiles = 0) {
9
+ return {
10
+ advisory: true,
11
+ available: false,
12
+ status: 'unavailable',
13
+ scannedFiles,
14
+ affectedFiles: 0,
15
+ diagnosticCount: 0,
16
+ files: [],
17
+ truncated: 0,
18
+ overflow: false,
19
+ };
20
+ }
21
+
22
+ /** Aggregate cached/per-file parse counts into a deterministic doctor surface. */
23
+ export function summarizeParseHealth(scanned) {
24
+ if (!Array.isArray(scanned)) return unavailableParseHealth();
25
+ const rows = scanned
26
+ .map(({ relFile, entry }) => ({
27
+ file: relFile,
28
+ diagnosticCount: entry?.parseDiagnosticCount,
29
+ }));
30
+ if (rows.some(({ file, diagnosticCount }) =>
31
+ typeof file !== 'string' || file.length === 0 ||
32
+ !Number.isSafeInteger(diagnosticCount) || diagnosticCount < 0
33
+ )) return unavailableParseHealth(rows.length);
34
+ const affected = rows
35
+ .filter(({ diagnosticCount }) => diagnosticCount > 0)
36
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
37
+ let diagnosticCount = 0;
38
+ for (const row of affected) {
39
+ if (!Number.isSafeInteger(diagnosticCount + row.diagnosticCount)) {
40
+ return unavailableParseHealth(rows.length);
41
+ }
42
+ diagnosticCount += row.diagnosticCount;
43
+ }
44
+ const truncated = Math.max(0, affected.length - PARSE_HEALTH_FILE_CAP);
45
+ return {
46
+ advisory: true,
47
+ available: true,
48
+ status: affected.length > 0 ? 'parse-diagnostics' : 'ok',
49
+ scannedFiles: rows.length,
50
+ affectedFiles: affected.length,
51
+ diagnosticCount,
52
+ files: affected.slice(0, PARSE_HEALTH_FILE_CAP),
53
+ truncated,
54
+ overflow: truncated > 0,
55
+ };
56
+ }
57
+
58
+ /** Human doctor section; clean parse health stays quiet. */
59
+ export function printParseHealthSection(health, io) {
60
+ if (!health || health.affectedFiles === 0) return;
61
+ console.log('');
62
+ console.log(io.color.bold('Parse health (advisory)'));
63
+ io.line(
64
+ io.warn,
65
+ `${health.affectedFiles} governed file(s) carry ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} scanned file(s).`
66
+ );
67
+ for (const finding of health.files ?? []) {
68
+ io.line(io.warn, `${finding.file} — ${finding.diagnosticCount} parse diagnostic(s)`);
69
+ }
70
+ if (health.truncated > 0) {
71
+ io.line(' ', io.color.dim(`…(+${health.truncated} more affected file(s); list capped)`));
72
+ }
73
+ io.line(' ', io.color.dim('advisory only — the gate verdict, design fitness, and pattern bets are unchanged'));
74
+ }