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 +60 -1
- package/README.md +4 -5
- package/bin/ark.mjs +2 -2
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/ci-and-commands.mjs +5 -8
- package/bin/lib/codex-home.mjs +21 -6
- package/bin/lib/contract-smells.mjs +52 -13
- package/bin/lib/doctor-advisories.mjs +16 -3
- package/bin/lib/doctor-plan.mjs +5 -5
- package/bin/lib/gate-files.mjs +1 -1
- package/bin/lib/hook-templates.mjs +21 -1
- package/bin/lib/html-report-advisories.mjs +54 -8
- package/bin/lib/install-migrate.mjs +26 -11
- package/bin/lib/mcp-adoption.mjs +21 -4
- package/bin/lib/physical-cohesion.mjs +230 -0
- package/bin/lib/write-path-capabilities.mjs +6 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/agent-guide.md +12 -2
- package/docs/ai-gates.md +18 -16
- package/docs/enthusiast/how-to-agent-gates.md +1 -1
- package/docs/package-surface.md +3 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/templates/skills/ark-architect.md +9 -0
- package/templates/skills/ark-fix.md +7 -0
- package/templates/skills/ark-loop.md +16 -0
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
|
|
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.
|
|
20
|
-
>
|
|
21
|
-
>
|
|
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
|
|
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.
|
|
231
|
-
//
|
|
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})…`);
|
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -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
|
-
//
|
|
242
|
-
//
|
|
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 `#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
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}]
|
package/bin/lib/codex-home.mjs
CHANGED
|
@@ -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
|
-
`
|
|
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
|
|
243
|
-
|
|
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:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
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 —
|
|
549
|
-
//
|
|
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
|
|
4
|
-
* exit code. One seam keeps doctor-plan.mjs
|
|
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
|
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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'));
|
package/bin/lib/gate-files.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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>${
|
|
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 [
|
|
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
|
-
|
|
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
|
-
//
|
|
431
|
-
//
|
|
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 &&
|
|
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 --
|
|
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) {
|