arkgate 3.5.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,68 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.6.1 — 2026-07-17
8
+
9
+ Codex project-scoped MCP fix. **No breaking** CLI, MCP tool, or `ark.config.json` changes.
10
+ **No gate weaken.**
11
+
12
+ ### Fixed
13
+
14
+ - **Codex MCP follows the active repository:** `--install-agent-gates --tools codex` now writes
15
+ `.codex/config.toml` with relative project paths. Each repository owns its primary `ark`
16
+ binding, so `ark upgrade` no longer leaves Codex resolving `ark://manifest` through another
17
+ permanent project registered in the global home config.
18
+ - **Doctor effective-config honesty:** Codex advisory-MCP capability and adoption checks prefer
19
+ a valid project `.codex/config.toml`; an unrelated `$CODEX_HOME` primary no longer produces a
20
+ false `write-path-none` or `codex-home-multi-project` warning.
21
+ - **Ephemeral worktree cleanup:** roots under `.claude/worktrees`, `.codex/worktrees`, or
22
+ `.grok/worktrees` are treated as temporary instead of permanent global MCP owners.
23
+
24
+ ### Changed
25
+
26
+ - `$CODEX_HOME/config.toml` remains an explicit compatibility fallback via `--codex-home`.
27
+ Normal `--tools codex` installs no longer mutate the global MCP registry, and skills-only
28
+ home refreshes cannot accidentally rebind its primary server.
29
+
30
+ ## 3.6.0 — 2026-07-17
31
+
32
+ Phase X closes: the doctor learns to see **physical shape** and agents get a governed way to
33
+ execute reorganizations, plus three field warm-ups from the 3.5.0 the field adopter validation.
34
+ Everything remains **advisory** — no verdict, exit-code, `designFitness`, or `patternBets`
35
+ change. **No breaking** CLI or `ark.config.json` changes. **No gate weaken. No apply path.**
36
+
37
+ ### Added
38
+
39
+ - **Physical cohesion sensor (X04, ADR 0010):** `doctor.physicalCohesion` reports domain
40
+ concepts exploded across mirrored directory clusters — concentration, not volume (dispersed
41
+ `use-*` hooks never fire). Deterministic path/name tokenization (framework filenames take the
42
+ topmost meaningful path segment; monorepo scaffold roots are never a concept); fixed
43
+ corpus-calibrated thresholds (`maxCluster ≥ 40` OR ≥2 anchors ≥ 20); findings ranked and
44
+ capped honestly; anchors under `app/`/`pages/` are `fixedByConvention`. `notAScore` — facts,
45
+ never a score or gate input.
46
+ - **Reshape pilot (X04):** `physicalCohesion.reshapePilot.nextPilot` is a **proposed, never
47
+ applied** card — one at a time, smallest convention-free anchor, `moveSample`/`movesTotal`,
48
+ `successSignal`, `killSwitch`, hard `doNot[]`. Real moves run only through the write gate and
49
+ atomic preflight via `/ark-loop`; merges are `/ark-architect` **merge cards** (domain
50
+ modeling, **never a codemod**); `/ark-fix` never folds reshapes into a fix batch. The
51
+ consolidation target subtree is never re-proposed as a source — the loop converges (validated
52
+ end to end: pilot → gate → kill switch → judgment → convergence).
53
+ - **Stale acknowledgments (X05):** ack entries matching no detected edge (orphaned, unknown id,
54
+ typo) land in `contractHealth.ackLifecycle` as `staleCount` + `stale[]` (sorted, capped);
55
+ doctor and report name the exact entries to fix or delete, even at zero visible smells.
56
+
57
+ ### Changed
58
+
59
+ - **Mid-name families (X06):** the family-infra carve-out matches the target's family token
60
+ against ANY source token (`HoursPersistenceAdapters -> PersistenceInfrastructure` goes
61
+ quiet); generic role words (`adapter(s)`/`gateway(s)`) never count as a family, so
62
+ `AdaptersCore` is not every `*Adapters` layer's base.
63
+ - **Report evidence overflow (X07):** per-finding evidence lists announce their 6-item cap with
64
+ an honest `(+N more)` marker; expired/stale lifecycle notes carry the same honesty.
65
+
7
66
  ## 3.5.0 — 2026-07-16
8
67
 
9
- Field-feedback release (Phase X, from the amarilla adoption session): the HTML report reaches
68
+ Field-feedback release (Phase X, from an internal field-adoption session): the HTML report reaches
10
69
  parity with the doctor and stays there by an executable rule, contract-smell acknowledgments gain
11
70
  a lifecycle so migration acks cannot fossilize, and the lateral-adapter smell stops firing on a
12
71
  family's own infrastructure base. Everything remains **advisory** — no verdict, `designFitness`,
package/README.md CHANGED
@@ -16,10 +16,9 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 3.4.0** is current stable: opt-in capability walls (`pure: true` or
20
- > `capabilities.deny` per layer, enforced on every adapter), the advisory ambient-state sensor,
21
- > and the measured pre-tool path — completing 3.3's evidence slice.
22
- > [Release notes](docs/releases/3.4.0.md).
19
+ > **ArkGate 3.6.1** is current stable: Codex MCP configuration is project-scoped, doctor
20
+ > recognizes the effective project binding, and stale agent worktrees no longer become
21
+ > permanent global MCP owners. [Release notes](docs/releases/3.6.1.md).
23
22
 
24
23
  ---
25
24
 
@@ -383,7 +382,7 @@ for real systems. Details: [production-hardening.md](https://github.com/pedrokni
383
382
  | Security reporting | [SECURITY.md](SECURITY.md) |
384
383
  | Demos | [docs/demos/](docs/demos/) |
385
384
  | Examples | [examples/](examples/README.md) |
386
- | Latest release (3.1.0) | [release notes](docs/releases/3.1.0.md) · [3.0.0 baseline](docs/releases/3.0.0.md) |
385
+ | Latest release (3.6.1) | [release notes](docs/releases/3.6.1.md) · [3.0.0 baseline](docs/releases/3.0.0.md) |
387
386
  | Roadmap and decisions | [ROADMAP.md](ROADMAP.md) · [ADRs](docs/adr/) · [Changelog](CHANGELOG.md) |
388
387
 
389
388
  ---
package/bin/ark.mjs CHANGED
@@ -227,8 +227,8 @@ async function upgrade(args) {
227
227
  if (status !== 0) return status;
228
228
 
229
229
  // Codex home skill catalog is $CODEX_HOME/skills/<name>/SKILL.md (repo uses .agents/skills/).
230
- // Refresh home when a Codex home exists. --force rewrites temp/upgrade MCP roots to this
231
- // project + arkgate-mcp. Non-fatal: a permission error (e.g. sandbox) shouldn't fail upgrade.
230
+ // Refresh home skills when a Codex home exists. Project MCP is installed above in
231
+ // .codex/config.toml. Non-fatal: a permission error (e.g. sandbox) shouldn't fail upgrade.
232
232
  const codexHomeBase = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
233
233
  if (fs.existsSync(codexHomeBase)) {
234
234
  console.log(`\n Refreshing Codex home (${codexHomeBase})…`);
@@ -8,6 +8,7 @@ export {
8
8
  codexArkBlockNeedsRewrite,
9
9
  codexConfigPath,
10
10
  codexPrimaryTable,
11
+ codexProjectMcpIsValid,
11
12
  codexProjectSlug,
12
13
  codexPromptsDir,
13
14
  codexSkillsDir,
@@ -23,6 +24,7 @@ export {
23
24
  export {
24
25
  PREFERRED_MCP_BIN,
25
26
  claudeSettings,
27
+ codexProjectConfig,
26
28
  grokHooks,
27
29
  grokProjectConfig,
28
30
  } from './hook-templates.mjs';
@@ -238,10 +238,8 @@ export function mcpJson(root) {
238
238
  }, null, 2)}\n`;
239
239
  }
240
240
 
241
- // Sample for docs/ `ark-check --install-agent-gates --tools codex` auto-merges the real
242
- // block (with absolute paths) into ~/.codex/config.toml. This copy is a reference only, so
243
- // it flags the two gotchas of hand-editing the global config: absolute paths (config.toml is
244
- // loaded without the project as cwd) and the required restart.
241
+ // Optional home fallback reference. Normal Codex installs write the project-scoped
242
+ // `.codex/config.toml`; `--codex-home` is for older clients or an explicit global binding.
245
243
  export function codexTomlSnippet(root) {
246
244
  const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
247
245
  '--root',
@@ -250,10 +248,9 @@ export function codexTomlSnippet(root) {
250
248
  '/absolute/path/to/project/ark.config.json',
251
249
  ]);
252
250
  const argsToml = args.map((value) => `"${value}"`).join(', ');
253
- return `# Add to ~/.codex/config.toml (or $CODEX_HOME/config.toml), then RESTART Codex
254
- # it does not hot-load MCP servers. Use ABSOLUTE paths: config.toml is global, so
255
- # "." would resolve against Codex's launch dir, not this project. Prefer:
256
- # ark-check --install-agent-gates --tools codex (auto-merges the absolute paths)
251
+ return `# Optional global fallback for older Codex clients. Modern Codex uses the generated
252
+ # project-scoped .codex/config.toml instead. If you install this fallback manually, restart
253
+ # Codex and keep ABSOLUTE paths because $CODEX_HOME/config.toml is global.
257
254
  [mcp_servers.ark]
258
255
  command = "${command}"
259
256
  args = [${argsToml}]
@@ -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
 
@@ -63,19 +63,26 @@ function nameTokens(name) {
63
63
  }
64
64
 
65
65
  /**
66
- * X03 — an adapter reaching its OWN family's infrastructure base is not a
67
- * lateral peer: same leading family token and EVERY remaining target token
68
- * reads as an infra base (Infra/Base/Core/Shared/…) `PaymentsCoreAdapters`
69
- * is still a sibling, not a base. Field origin: amarilla, where
70
- * `<Family>Adapters -> <Family>Infra` fired as adapter-to-adapter.
66
+ * X03/X06 — an adapter reaching its OWN family's infrastructure base is not a
67
+ * lateral peer: the target reads as `<Family><InfraWords…>` and the source
68
+ * carries the family token ANYWHERE in its name (X06, field corpus names
69
+ * domain-scoped adapters `HoursPersistenceAdapters` over
70
+ * `PersistenceInfrastructure` the family sits mid-name). EVERY remaining
71
+ * target token must be an infra word (Infra/Base/Core/Shared/…) —
72
+ * `PaymentsCoreAdapters` is still a sibling, not a base. The reverse
73
+ * direction (base → member) never matches: the target must BE the base.
71
74
  * Name heuristic like the role regexes above — a miss costs a warning line.
72
75
  */
73
76
  function isFamilyInfrastructureEdge(from, to) {
74
77
  const fromTokens = nameTokens(from);
75
78
  const toTokens = nameTokens(to);
76
79
  if (fromTokens.length === 0 || toTokens.length < 2) return false;
77
- const family = toTokens[0];
78
- if (family.length < 2 || family.toLowerCase() !== fromTokens[0].toLowerCase()) return false;
80
+ const family = toTokens[0].toLowerCase();
81
+ // A generic role word is not a family: `AdaptersCore` must not read as the
82
+ // "Adapters family" base for every *Adapters layer — that would silently
83
+ // quiet genuine cross-family edges. (`Persistence` stays a valid family.)
84
+ if (/^(adapters?|gateways?)$/.test(family)) return false;
85
+ if (family.length < 2 || !fromTokens.some((t) => t.toLowerCase() === family)) return false;
79
86
  return toTokens.slice(1).every((t) => FAMILY_INFRA_RE.test(t));
80
87
  }
81
88
 
@@ -290,9 +297,28 @@ export function analyzeContractSmells(
290
297
  }
291
298
  }
292
299
 
300
+ // X05 — an ack that matches no detected edge is stale: orphaned by a fixed
301
+ // contract, a quieted heuristic, or a typo. Detected BEFORE ack filtering.
302
+ const detectedEdges = new Map();
303
+ for (const [id, entries] of Object.entries(findings)) {
304
+ detectedEdges.set(id, new Set(entries.map((e) => e.edge).filter((e) => e != null)));
305
+ }
306
+ const staleEdges = [];
307
+ if (ackState && !ackState.invalid && Array.isArray(ackState.acks)) {
308
+ for (const a of ackState.acks) {
309
+ const canonical = normalizeAckEdge(a.id, a.edge);
310
+ if (canonical != null && detectedEdges.get(a.id)?.has(canonical)) continue;
311
+ staleEdges.push({ id: a.id, edge: a.edge });
312
+ }
313
+ // Stable under sidecar reordering, like every other output here.
314
+ staleEdges.sort((a, b) =>
315
+ a.id === b.id ? (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0) : a.id < b.id ? -1 : 1
316
+ );
317
+ }
318
+
293
319
  const smells = [];
294
320
  let matchedAcks = 0;
295
- const ackLifecycle = { undated: 0, malformed: 0, expired: [] };
321
+ const ackLifecycle = { undated: 0, malformed: 0, expired: [], stale: staleEdges };
296
322
  for (const id of CONTRACT_SMELL_IDS) {
297
323
  const entries = findings[id];
298
324
  if (!entries || entries.length === 0) continue;
@@ -545,9 +571,9 @@ export function formatContractHealthLines(smells, health) {
545
571
  const gw = health?.governanceWeight;
546
572
  const weightNoteworthy = gw?.weight === 'heavy' || gw?.weight === 'light';
547
573
  const lc = health?.ackLifecycle;
548
- // Undated acks must surface even when every smell is suppressed — that is
549
- // exactly the fossilization case X02 exists to catch.
550
- const lifecycleNoteworthy = (lc?.undated ?? 0) > 0;
574
+ // Undated and stale acks must surface even when every smell is suppressed —
575
+ // fossilization (X02) and orphaned entries (X05) hide exactly there.
576
+ const lifecycleNoteworthy = (lc?.undated ?? 0) > 0 || (lc?.staleCount ?? 0) > 0;
551
577
  if (list.length === 0 && !health?.ackFile?.invalid && !weightNoteworthy && !lifecycleNoteworthy) {
552
578
  return rows;
553
579
  }
@@ -591,6 +617,14 @@ export function formatContractHealthLines(smells, health) {
591
617
  text: `${lc.undated} applied acknowledgment(s) have no review-by date — add one so migration acks cannot fossilize.`,
592
618
  });
593
619
  }
620
+ if ((lc?.staleCount ?? 0) > 0) {
621
+ const shown = (lc.stale ?? []).slice(0, 4).map((s) => s.edge);
622
+ const more = lc.staleCount > shown.length ? ` …(+${lc.staleCount - shown.length} more)` : '';
623
+ rows.push({
624
+ mark: 'dim',
625
+ text: `${lc.staleCount} acknowledgment(s) match no detected edge — stale; fix the edge string or delete the entry: ${shown.join(', ')}${more}`,
626
+ });
627
+ }
594
628
  if (weightNoteworthy) {
595
629
  rows.push({
596
630
  mark: 'warn',
@@ -610,20 +644,23 @@ export function formatContractHealthLines(smells, health) {
610
644
  * `acknowledged` counts ack entries that MATCHED a detected edge (stale acks count 0).
611
645
  * X02 — `ackLifecycle` reports how applied acks age: `undated` applied without
612
646
  * a review-by, `expired` past it (no longer applied), `malformed` bad dates.
647
+ * X05 — `stale` counts ack entries matching NO detected edge (orphaned or
648
+ * typo'd); they suppress nothing and should be fixed or deleted.
613
649
  *
614
650
  * @param {ReturnType<typeof detectContractSmells>} smells
615
651
  * @param {ReturnType<typeof loadContractSmellAcks>} ackState
616
652
  * @param {number} [matchedAcks]
617
- * @param {{ undated: number, malformed: number, expired: Array<{id: string, edge: string, reviewBy: string}> }} [ackLifecycle]
653
+ * @param {{ undated: number, malformed: number, expired: Array<{id: string, edge: string, reviewBy: string}>, stale: Array<{id: string, edge: string}> }} [ackLifecycle]
618
654
  */
619
655
  export function summarizeContractHealth(
620
656
  smells,
621
657
  ackState = { exists: false, acks: [] },
622
658
  matchedAcks = 0,
623
- ackLifecycle = { undated: 0, malformed: 0, expired: [] }
659
+ ackLifecycle = { undated: 0, malformed: 0, expired: [], stale: [] }
624
660
  ) {
625
661
  const list = Array.isArray(smells) ? smells : [];
626
662
  const expired = Array.isArray(ackLifecycle?.expired) ? ackLifecycle.expired : [];
663
+ const stale = Array.isArray(ackLifecycle?.stale) ? ackLifecycle.stale : [];
627
664
  return {
628
665
  status: list.length > 0 ? 'contract-smells' : 'ok',
629
666
  smellCount: list.length,
@@ -634,6 +671,8 @@ export function summarizeContractHealth(
634
671
  malformed: ackLifecycle?.malformed ?? 0,
635
672
  expiredCount: expired.length,
636
673
  expired: expired.slice(0, MAX_EVIDENCE),
674
+ staleCount: stale.length,
675
+ stale: stale.slice(0, MAX_EVIDENCE),
637
676
  },
638
677
  advisory: true,
639
678
  label:
@@ -1,20 +1,33 @@
1
1
  /**
2
2
  * Doctor's advisory sensors, aggregated (W01 contract health + U05 ambient
3
- * state). Advisory only: nothing here feeds a verdict, designFitness, or an
4
- * exit code. One seam keeps doctor-plan.mjs inside its module budget as new
5
- * advisory surfaces land.
3
+ * state + X04 physical cohesion). Advisory only: nothing here feeds a
4
+ * verdict, designFitness, or an exit code. One seam keeps doctor-plan.mjs
5
+ * inside its module budget as new advisory surfaces land.
6
6
  */
7
7
  import { computeAmbientState, printAmbientStateSection } from './ambient-state.mjs';
8
8
  import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
9
+ import {
10
+ computePhysicalCohesion,
11
+ computeReshapePilot,
12
+ printPhysicalCohesionSection,
13
+ } from './physical-cohesion.mjs';
9
14
 
10
15
  export function computeDoctorAdvisories(root, config, cov, rules, files, ts) {
16
+ const physicalCohesion = computePhysicalCohesion(root, files);
17
+ physicalCohesion.reshapePilot = computeReshapePilot(physicalCohesion, files, root);
11
18
  return {
12
19
  contractHealth: computeContractHealth(root, config, cov, rules),
13
20
  ambientState: computeAmbientState(ts, root, config, files),
21
+ physicalCohesion,
14
22
  };
15
23
  }
16
24
 
17
25
  export function printDoctorAdvisories(advisories, io) {
18
26
  printContractHealthSection(advisories.contractHealth, io);
19
27
  printAmbientStateSection(advisories.ambientState, io);
28
+ printPhysicalCohesionSection(
29
+ advisories.physicalCohesion,
30
+ advisories.physicalCohesion?.reshapePilot,
31
+ io
32
+ );
20
33
  }
@@ -55,7 +55,6 @@ function normalize(value) {
55
55
  }
56
56
 
57
57
 
58
-
59
58
  export function computeCoverage(root, config, files, rules) {
60
59
  const layers = config.layers ?? [];
61
60
  const counts = new Map(layers.map((layer) => [layer.name, 0]));
@@ -425,7 +424,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
425
424
  patternBets: patternBetsForLoop,
426
425
  designSmells,
427
426
  });
428
- const { contractHealth, ambientState } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05 advisories — never a verdict
427
+ const { contractHealth, ambientState, physicalCohesion } = computeDoctorAdvisories(root, config, cov, rules, files, options.ts); // W01+U05+X04 advisories — never a verdict
429
428
 
430
429
  if (asJson) {
431
430
  console.log(
@@ -462,10 +461,11 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
462
461
  goldenPattern,
463
462
  // Q04: one-pilot loop (extraction card → re-doctor).
464
463
  pilotLoop,
465
- // W01: contract-health meta-lint (advisory; verdict unchanged).
464
+ // Advisories, never a verdict: W01 contract health, U05 ambient
465
+ // state (opt-in), X04 physical cohesion + proposed reshape pilot.
466
466
  contractHealth,
467
- // U05: ambient-state sensor (advisory; opt-in; verdict unchanged).
468
467
  ambientState,
468
+ physicalCohesion,
469
469
  governed: cov.governed,
470
470
  emptyLayers: cov.emptyLayers,
471
471
  layersWithoutRules: cov.layersWithoutRules,
@@ -647,7 +647,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
647
647
  );
648
648
  }
649
649
 
650
- printDoctorAdvisories({ contractHealth, ambientState }, { line, warn, color }); // advisory sections
650
+ printDoctorAdvisories({ contractHealth, ambientState, physicalCohesion }, { line, warn, color }); // advisory sections
651
651
 
652
652
  console.log('');
653
653
  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',
@@ -52,11 +52,13 @@ function ackLifecycleHtml(lc) {
52
52
  if (!lc) return '';
53
53
  const rows = [];
54
54
  if ((lc.expiredCount ?? 0) > 0) {
55
- const edges = (lc.expired ?? [])
55
+ const list = lc.expired ?? [];
56
+ const edges = list
56
57
  .map((e) => `<code>${esc(e.edge)}</code> (review-by ${esc(e.reviewBy)})`)
57
58
  .join(' · ');
59
+ const more = lc.expiredCount > list.length ? ` …(+${lc.expiredCount - list.length} more)` : '';
58
60
  rows.push(
59
- `<p><span class="tag warn">expired</span> ${lc.expiredCount} acknowledgment(s) past review-by — no longer applied, the smell is active again: ${edges}</p>`
61
+ `<p><span class="tag warn">expired</span> ${lc.expiredCount} acknowledgment(s) past review-by — no longer applied, the smell is active again: ${edges}${more}</p>`
60
62
  );
61
63
  }
62
64
  if ((lc.malformed ?? 0) > 0) {
@@ -69,6 +71,15 @@ function ackLifecycleHtml(lc) {
69
71
  `<p class="muted">${lc.undated} applied acknowledgment(s) have no review-by date — add one so migration acks cannot fossilize.</p>`
70
72
  );
71
73
  }
74
+ if ((lc.staleCount ?? 0) > 0) {
75
+ // Plain "+N more": doctor JSON caps its own list, so pointing there for
76
+ // the remainder would over-promise (cross-model review finding).
77
+ const edges = (lc.stale ?? []).slice(0, 4).map((s) => `<code>${esc(s.edge)}</code>`).join(' · ');
78
+ const more = lc.staleCount > 4 ? ` …(+${lc.staleCount - 4} more)` : '';
79
+ rows.push(
80
+ `<p class="muted">${lc.staleCount} acknowledgment(s) match no detected edge — stale; fix the edge string or delete the entry: ${edges}${more}</p>`
81
+ );
82
+ }
72
83
  return rows.join('\n');
73
84
  }
74
85
 
@@ -85,15 +96,21 @@ function contractHealthHtml(health) {
85
96
  const body = smells.length === 0
86
97
  ? `<p class="muted">No contract smells detected — no explicitly bidirectional allows, peripheral-into-core allows, lateral adapter allows, or dead rules beyond what is acknowledged.</p>`
87
98
  : smells
88
- .map(
89
- (s) => `
99
+ .map((s) => {
100
+ const evidence = Array.isArray(s.evidence) ? s.evidence : [];
101
+ // X07 — the cap must announce itself: a 12-edge smell showing 6
102
+ // codes with no marker reads as the whole story.
103
+ const more = evidence.length > 6
104
+ ? ` <span class="muted">…(+${evidence.length - 6} more in doctor JSON)</span>`
105
+ : '';
106
+ return `
90
107
  <div class="finding">
91
108
  <p><span class="tag warn">${esc(s.id)}</span> ${esc(s.outcome ?? s.message ?? '')}</p>
92
109
  <p class="muted">${esc(s.message ?? '')}</p>
93
- <p class="muted">evidence: <code>${(s.evidence ?? []).slice(0, 6).map(esc).join('</code> · <code>')}</code></p>
110
+ <p class="muted">evidence: <code>${evidence.slice(0, 6).map(esc).join('</code> · <code>')}</code>${more}</p>
94
111
  <p class="muted">fix: ${esc(s.fix ?? '')}</p>
95
- </div>`
96
- )
112
+ </div>`;
113
+ })
97
114
  .join('\n');
98
115
  return `
99
116
  <section data-advisory="contractHealth">
@@ -135,6 +152,31 @@ function ambientStateHtml(state) {
135
152
  </section>`;
136
153
  }
137
154
 
155
+ function physicalCohesionHtml(pc) {
156
+ if (!pc) return '';
157
+ const findings = Array.isArray(pc.findings) ? pc.findings : [];
158
+ const body = findings.length === 0
159
+ ? '<p class="muted">No mirrored concept explosion detected — no concept clusters over the calibrated thresholds (ADR 0010).</p>'
160
+ : findings
161
+ .map((f) => {
162
+ const anchors = (f.anchors ?? [])
163
+ .map((a) => `<code>${esc(a.path)}</code> (${a.files}${a.fixedByConvention ? ', fixed by convention' : ''})`)
164
+ .join(' · ');
165
+ return `<p><span class="tag warn">${esc(f.concept)}</span> ${f.files} file(s) across ${f.anchorCount} anchor(s)${f.mirrored ? ' — mirrored' : ''}: ${anchors}</p>`;
166
+ })
167
+ .join('\n') +
168
+ (pc.truncated > 0 ? `<p class="muted">…(+${pc.truncated} more concept(s) in doctor JSON)</p>` : '');
169
+ const pilot = pc.reshapePilot?.nextPilot
170
+ ? `<p class="muted">next pilot (proposed, never applied): ${esc(pc.reshapePilot.nextPilot.pilotTarget)} — one pilot at a time via /ark-loop; merges are judgment cards only.</p>`
171
+ : '';
172
+ return `
173
+ <section data-advisory="physicalCohesion">
174
+ <h2>Physical cohesion <span class="muted">(advisory — facts, not a score; the verdict is unchanged)</span></h2>
175
+ ${body}
176
+ ${pilot}
177
+ </section>`;
178
+ }
179
+
138
180
  /**
139
181
  * Render every doctor advisory as report sections. Keys must cover everything
140
182
  * `computeDoctorAdvisories` returns — the parity guard enforces it.
@@ -143,7 +185,11 @@ function ambientStateHtml(state) {
143
185
  export function renderAdvisorySections(advisories, escape) {
144
186
  if (!advisories || typeof advisories !== 'object') return '';
145
187
  if (typeof escape === 'function') esc = escape;
146
- return [contractHealthHtml(advisories.contractHealth), ambientStateHtml(advisories.ambientState)]
188
+ return [
189
+ contractHealthHtml(advisories.contractHealth),
190
+ ambientStateHtml(advisories.ambientState),
191
+ physicalCohesionHtml(advisories.physicalCohesion),
192
+ ]
147
193
  .filter(Boolean)
148
194
  .join('\n');
149
195
  }
@@ -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) {