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.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -5
- package/bin/ark-check.mjs +3 -3
- package/bin/ark-layer-match.mjs +2 -1
- package/bin/ark.mjs +2 -2
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +66 -13
- package/bin/lib/ci-and-commands.mjs +5 -8
- package/bin/lib/codex-home.mjs +21 -6
- package/bin/lib/design-smells.mjs +67 -14
- package/bin/lib/doctor-advisories.mjs +20 -5
- package/bin/lib/doctor-plan.mjs +5 -7
- package/bin/lib/gate-files.mjs +1 -1
- package/bin/lib/hook-templates.mjs +21 -1
- package/bin/lib/html-report-advisories.mjs +59 -0
- package/bin/lib/install-migrate.mjs +26 -11
- package/bin/lib/mcp-adoption.mjs +21 -4
- package/bin/lib/parse-health.mjs +74 -0
- package/bin/lib/reshape-decisions.mjs +284 -0
- package/bin/lib/ts-resolve.mjs +3 -2
- package/bin/lib/write-path-capabilities.mjs +6 -0
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +7 -7
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -6
- package/docs/agent-guide.md +34 -3
- package/docs/ai-gates.md +21 -18
- package/docs/configuration.md +6 -0
- package/docs/enthusiast/how-to-agent-gates.md +1 -1
- package/docs/package-surface.md +6 -5
- package/docs/typescript-support.md +9 -0
- package/package.json +2 -1
- package/server.json +2 -2
- package/templates/skills/ark-autopilot.md +12 -0
- package/templates/skills/ark-explore.md +8 -1
- package/templates/skills/ark-fix.md +11 -1
- package/templates/skills/ark-loop.md +14 -1
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* state
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
466
|
-
|
|
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(
|
|
648
|
+
printDoctorAdvisories(doctorAdvisories, { line, warn, color }); // advisory sections
|
|
651
649
|
|
|
652
650
|
console.log('');
|
|
653
651
|
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',
|
|
@@ -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
|
-
|
|
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) {
|
package/bin/lib/mcp-adoption.mjs
CHANGED
|
@@ -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 {
|
|
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: [
|
|
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
|
-
|
|
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
|
+
}
|