arkgate 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/README.md +15 -4
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/lib/agent-gates.mjs +1 -0
  6. package/bin/lib/ci-and-commands.mjs +16 -7
  7. package/bin/lib/codex-home.mjs +90 -8
  8. package/bin/lib/design-smells.mjs +71 -9
  9. package/bin/lib/doctor-plan.mjs +36 -36
  10. package/bin/lib/effective-contract-load.mjs +73 -9
  11. package/bin/lib/enforcement-state.mjs +1 -1
  12. package/bin/lib/gate-files.mjs +441 -9
  13. package/bin/lib/github-enforcement.mjs +16 -3
  14. package/bin/lib/hook-templates.mjs +12 -11
  15. package/bin/lib/html-report-evolution.mjs +114 -0
  16. package/bin/lib/html-report.mjs +11 -89
  17. package/bin/lib/import-resolve.mjs +33 -11
  18. package/bin/lib/install-activation.mjs +87 -0
  19. package/bin/lib/install-migrate.mjs +66 -50
  20. package/bin/lib/managed-upgrade.mjs +10 -41
  21. package/bin/lib/mcp-adoption.mjs +15 -5
  22. package/bin/lib/pilot-loop.mjs +25 -8
  23. package/bin/lib/project-identity.mjs +103 -0
  24. package/bin/lib/report-snapshot-context.mjs +28 -0
  25. package/bin/lib/resident-hook.mjs +33 -9
  26. package/bin/lib/rules-inventory.mjs +100 -8
  27. package/bin/lib/skill-install.mjs +272 -22
  28. package/bin/lib/skill-write.mjs +899 -0
  29. package/bin/lib/start-preview.mjs +84 -1
  30. package/bin/lib/upgrade-command.mjs +2 -5
  31. package/dist/index.cjs +13 -13
  32. package/dist/index.d.ts +194 -2
  33. package/dist/index.js +13 -13
  34. package/docs/README.md +5 -3
  35. package/docs/agent-guide.md +110 -14
  36. package/docs/ai-gates.md +103 -18
  37. package/docs/assets/ark-write-gate.svg +2 -2
  38. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  39. package/docs/package-surface.md +14 -9
  40. package/docs/product-voice.md +13 -1
  41. package/package.json +3 -1
  42. package/schemas/ark.project-identity.schema.json +116 -0
  43. package/server.json +2 -2
  44. package/templates/skills/ark-adopt.md +9 -0
  45. package/templates/skills/ark-architect.md +12 -2
  46. package/templates/skills/ark-autopilot.md +9 -0
  47. package/templates/skills/ark-contract.md +11 -1
  48. package/templates/skills/ark-coverage.md +9 -0
  49. package/templates/skills/ark-explain.md +13 -1
  50. package/templates/skills/ark-explore.md +9 -0
  51. package/templates/skills/ark-fix.md +10 -1
  52. package/templates/skills/ark-loop.md +11 -2
  53. package/templates/skills/ark-place.md +17 -6
  54. package/templates/skills/ark-runtime.md +8 -0
  55. package/templates/skills/ark-think.md +14 -2
  56. package/templates/skills/ark-upgrade.md +9 -0
@@ -103,6 +103,7 @@ export {
103
103
  assessCodexSkillParity,
104
104
  assessSkillCatalogParity,
105
105
  detectSkillGaps,
106
+ skillGapsForActiveHost,
106
107
  agentsMdSkillRefs,
107
108
  verifyHostSkillCatalog,
108
109
  printSkillAndCodexGapHints,
@@ -183,9 +183,11 @@ export function ensureCheckArchitectureScript(root, opts = {}) {
183
183
  // source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
184
184
  // is a builder because the check command's runner prefix varies with the package manager.
185
185
  const AGENT_CONTRACT = {
186
- manifestResource: 'ark://manifest',
186
+ manifestTool: 'ark_manifest',
187
+ compatibilityManifestResource: 'ark://manifest',
187
188
  steps: (checkCommand) => [
188
- `Read the Ark contract from \`ark://manifest\` when the MCP server is available.`,
189
+ `Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\` set to the exact project root's absolute path. Reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only when the matching project id is also supplied. If the tool is missing, the binding is not \`matched\`, or the reported root differs, restart the host and use the local CLI until identity matches.`,
190
+ `Read the authoritative Ark contract with \`ark_manifest\` using the same project expectation. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative.`,
189
191
  `Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
190
192
  `Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
191
193
  `After edits, run \`${checkCommand}\`.`,
@@ -336,7 +338,7 @@ advisory MCP plus CI. The experimental runtime is not required.
336
338
 
337
339
  /**
338
340
  * Compact onboarding uses one project router instead of copied slash-command
339
- * skills. The package and ark MCP resources remain the canonical capability
341
+ * skills. The package and ark MCP tools remain the canonical capability
340
342
  * source; the marker makes the selected host verifiable by the strict gate.
341
343
  */
342
344
  export function compactAgentInstructions(root, host = null) {
@@ -358,8 +360,9 @@ export function compactAgentInstructions(root, host = null) {
358
360
  **Primary path (do this):**
359
361
 
360
362
  1. Status anytime: \`${doctorCmd}\` — one status light, one next action (control plane).
361
- 2. Day to day: read \`ark://manifest\` when MCP is available; place new files with \`ark_place\`; validate after edits; run \`${checkCmd}\`. On a gate deny, fix the architecture do not weaken the contract.
362
- 3. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`.
363
+ 2. Before trusting MCP evidence: call \`ark_identity\` with \`project.expectedRoot\` set to this project's exact absolute root, then reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only with that matching id. Missing tool, non-\`matched\` binding, or wrong root means the process is stale: restart the host and use the local CLI meanwhile.
364
+ 3. Day to day: call \`ark_manifest\` with the same project expectation; place new files with \`ark_place\`; validate after edits; run \`${checkCmd}\`. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative. On a gate deny, fix the architecture — do not weaken the contract.
365
+ 4. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`.
363
366
 
364
367
  The selected host is \`${selectedHost}\`. Host registration and CI are installed with this file.
365
368
  This compact router is enough for normal feature work.
@@ -431,8 +434,14 @@ description: Ark architecture contract
431
434
  alwaysApply: true
432
435
  ---
433
436
 
434
- Before writing or editing TypeScript or JavaScript source files, read the
435
- \`${AGENT_CONTRACT.manifestResource}\` resource from the \`ark\` MCP server when available.
437
+ Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\`
438
+ set to the exact project root's absolute path. Reuse that root plus the returned
439
+ \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only
440
+ when that matching id is also supplied. If the tool is missing, the binding is not \`matched\`,
441
+ or the root differs, restart the host and use the local CLI until identity matches. Then call
442
+ \`${AGENT_CONTRACT.manifestTool}\` with the same project expectation. The
443
+ \`${AGENT_CONTRACT.compatibilityManifestResource}\` resource is compatibility-only and always
444
+ unverified/non-authoritative.
436
445
 
437
446
  ${AGENT_CONTRACT.cursorValidateStep} After edits, run:
438
447
 
@@ -165,22 +165,104 @@ export function codexScopedTableForRoot(tomlText, absRoot) {
165
165
  return null;
166
166
  }
167
167
 
168
+ function extractCodexCommandFromBlock(block) {
169
+ const matches = [
170
+ ...String(block || '').matchAll(
171
+ /^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/gm
172
+ ),
173
+ ];
174
+ if (matches.length !== 1) return null;
175
+ try {
176
+ return matches[0][1].startsWith('"')
177
+ ? JSON.parse(matches[0][1])
178
+ : matches[0][1].slice(1, -1);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function executableName(value) {
185
+ if (typeof value !== 'string' || value !== value.trim() || value.length === 0) return '';
186
+ return path.posix
187
+ .basename(value.replace(/\\/g, '/'))
188
+ .replace(/\.(?:cmd|exe)$/i, '')
189
+ .toLowerCase();
190
+ }
191
+
192
+ function isArkMcpToken(value) {
193
+ return /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
194
+ }
195
+
196
+ function codexArkMcpInvocation(command, args) {
197
+ if (
198
+ !command ||
199
+ !Array.isArray(args) ||
200
+ [command, ...args].filter(isArkMcpToken).length !== 1
201
+ ) {
202
+ return false;
203
+ }
204
+ const argv = [command, ...args];
205
+ if (isArkMcpToken(command)) return { binArgs: argv.slice(1) };
206
+ const runner = executableName(command);
207
+ if ((runner === 'npx' || runner === 'yarn') && isArkMcpToken(args[0])) {
208
+ return { binArgs: argv.slice(2) };
209
+ }
210
+ if (runner === 'node') {
211
+ const script = args[0]?.replace(/\\/g, '/');
212
+ return isArkMcpToken(script) && /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script)
213
+ ? { binArgs: argv.slice(2) }
214
+ : false;
215
+ }
216
+ if (runner !== 'pnpm') return false;
217
+ const binIndex =
218
+ args[0] === 'exec'
219
+ ? 1
220
+ : args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
221
+ ? 2
222
+ : -1;
223
+ return binIndex >= 0 && isArkMcpToken(args[binIndex])
224
+ ? { binArgs: argv.slice(binIndex + 2) }
225
+ : false;
226
+ }
227
+
228
+ function singleOptionValue(args, option) {
229
+ const indexes = args.flatMap((value, index) => (value === option ? [index] : []));
230
+ return indexes.length === 1 ? args[indexes[0] + 1] ?? null : null;
231
+ }
232
+
233
+ function projectPathFlavor(projectRoot) {
234
+ const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(projectRoot);
235
+ return {
236
+ api: windows ? path.win32 : path,
237
+ comparable: (value) => (windows ? value.toLowerCase() : value),
238
+ };
239
+ }
240
+
241
+ function blockHasWorkingDirectoryOverride(block) {
242
+ return /^[ \t]*(?:cwd|"cwd"|'cwd')[ \t]*=/m.test(String(block || ''));
243
+ }
244
+
168
245
  /** True when project TOML owns the primary Ark MCP binding for that project. */
169
246
  export function codexProjectMcpIsValid(tomlText, projectRoot) {
170
- const resolvedRoot = path.resolve(projectRoot);
171
247
  if (listCodexArkServerTables(tomlText).filter((entry) => entry.table === 'ark').length !== 1) {
172
248
  return false;
173
249
  }
174
250
  const primary = codexPrimaryTable(tomlText);
175
251
  const args = extractCodexArgsFromBlock(primary?.block);
176
- if (!primary?.root || !args?.some((value) => /^(ark|arkgate)-mcp$/.test(value))) return false;
177
- const configIndex = args.indexOf('--config');
178
- const config = configIndex >= 0 ? args[configIndex + 1] : null;
179
- if (!config) return false;
252
+ const command = extractCodexCommandFromBlock(primary?.block);
253
+ const invocation = args && codexArkMcpInvocation(command, args);
254
+ if (!invocation || blockHasWorkingDirectoryOverride(primary?.block)) return false;
255
+ const rootArg = singleOptionValue(invocation.binArgs, '--root');
256
+ const configArg = singleOptionValue(invocation.binArgs, '--config');
257
+ if (!rootArg || !configArg || invocation.binArgs.length !== 4) return false;
180
258
  try {
259
+ const { api, comparable } = projectPathFlavor(projectRoot);
260
+ const resolvedRoot = api.resolve(projectRoot);
261
+ const requestedRoot = api.resolve(resolvedRoot, rootArg);
262
+ const requestedConfig = api.resolve(resolvedRoot, configArg);
181
263
  return (
182
- path.resolve(resolvedRoot, primary.root) === resolvedRoot &&
183
- path.resolve(resolvedRoot, config) === path.join(resolvedRoot, 'ark.config.json')
264
+ comparable(requestedRoot) === comparable(resolvedRoot) &&
265
+ comparable(requestedConfig) === comparable(api.join(resolvedRoot, 'ark.config.json'))
184
266
  );
185
267
  } catch {
186
268
  return false;
@@ -281,7 +363,7 @@ export function assessCodexHomeMcp(tomlText, absRoot) {
281
363
  message: scopedTable
282
364
  ? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
283
365
  `this project is registered as [mcp_servers.${scopedTable}]. ` +
284
- `Install the project-scoped binding so this repo owns ark://manifest when active.`
366
+ `Install the project-scoped binding so ark_identity and ark_manifest match this repo when active.`
285
367
  : `Codex home primary MCP --root is another permanent project ` +
286
368
  `(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
287
369
  `Install the project-scoped binding for this repo; the global primary can remain unchanged.`,
@@ -33,7 +33,7 @@ export const DESIGN_SMELL_OUTCOMES = Object.freeze({
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':
36
- 'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move pure rules into Domain (or a pure domain module) and import from the UI.',
36
+ 'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move the pure rule into Domain, expose it through Application, and keep UI imports on that Application boundary.',
37
37
  'facade-sql-in-routes':
38
38
  'Routes/controllers import the ORM or SQL client — the AI will keep growing “smart controllers.” Keep queries in a repository/adapter; routes only call that port.',
39
39
  'mixed-pattern-cluster':
@@ -76,8 +76,13 @@ const ROUTE_DEFINITION_CALL_RE =
76
76
  /\bdefineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/;
77
77
  const ROUTE_DEFINITION_DECLARATION_RE =
78
78
  /\b(?:export\s+)?(?:declare\s+)?(?:async\s+)?function\s+defineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/g;
79
- const DOMAIN_LOGIC_UI_RE =
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*=/;
79
+ const DOMAIN_LOGIC_UI_DECL_RE =
80
+ /\b(?:export\s+)?(?:(?:async\s+)?function\s+((?:can|calculate|compute|should)[A-Z]\w*)|const\s+((?:can|calculate|compute|should)[A-Z]\w*)\s*=)/g;
81
+ const UI_PERMISSION_OR_LOCAL_STATE_RE =
82
+ /\b(?:permissions?|roles?|acl|session|currentUser|isOwner|readOnly|useState|useMemo|useContext|localState|uiState|selected(?:Id|Row|Tab)?|is(?:Open|Closed|Expanded|Collapsed|Selected|Loading|Pending|Hovered|Focused|Disabled))\b(?:\s*\.\s*\w+|\s*\[[^\]]+\])*/gi;
83
+ const STRING_LITERAL_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
84
+ const BUSINESS_RULE_CONTEXT_RE =
85
+ /\b(?:amount|total|price|tax|discount|balance|quantity|inventory|order|cart|invoice|credit|policy|threshold|limit|status)\b/i;
81
86
  const EXPORT_RE =
82
87
  /\bexport\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum|default)\b|\bexport\s*\{/g;
83
88
 
@@ -93,6 +98,19 @@ const MAX_SCAN_FILES = 800;
93
98
  const GOD_LOC = 400;
94
99
  const GOD_EXPORTS = 12;
95
100
 
101
+ /** Files that may be intentionally broad or generated are not extraction pilots. */
102
+ export function isNonProductionPilotPath(filePath) {
103
+ const rel = String(filePath || '').replace(/\\/g, '/');
104
+ return (
105
+ /(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|demos?|generated|codegen)(?:\/|$)/i.test(
106
+ rel
107
+ ) ||
108
+ /(?:^|\/)(?:(?:fixture|seed|seeder|migration|demo|generated)|[^/]*(?:\.generated|\.gen|[-_.](?:fixture|seed|seeder|migration|demo|generated)))\.(?:ts|tsx|js|jsx|mts|cts)$/i.test(
109
+ rel
110
+ )
111
+ );
112
+ }
113
+
96
114
  function normalizeRel(root, filePath) {
97
115
  const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
98
116
  let rel = path.relative(root, abs).split(path.sep).join('/');
@@ -175,6 +193,38 @@ function hasHollowPersistenceShape(source) {
175
193
  );
176
194
  }
177
195
 
196
+ function hasGeneratedSourceBanner(source) {
197
+ return /(?:@generated|GENERATED FILE|generated by|do not edit)/i.test(source.slice(0, 400));
198
+ }
199
+
200
+ function hasDomainLogicInUi(source) {
201
+ for (const match of source.matchAll(DOMAIN_LOGIC_UI_DECL_RE)) {
202
+ const name = match[1] || match[2] || '';
203
+ const start = match.index ?? 0;
204
+ const statementEnd = source.indexOf(';', start);
205
+ const end =
206
+ statementEnd >= start && statementEnd <= start + 800
207
+ ? statementEnd + 1
208
+ : Math.min(source.length, start + 800);
209
+ const declaration = source.slice(start, end);
210
+ const uiDecisionName =
211
+ /^(?:can(?:Edit|Delete|View|Manage|Select|Open|Close)|should(?:Show|Render|Display|Open|Close|Disable|Enable))/i.test(
212
+ name
213
+ );
214
+ // canEdit/shouldShow are common local UI or permission decisions. Require
215
+ // business evidence outside the helper name before treating them as domain logic.
216
+ const ruleContext = declaration
217
+ .replace(name, '')
218
+ .replace(STRING_LITERAL_RE, '')
219
+ .replace(UI_PERMISSION_OR_LOCAL_STATE_RE, '');
220
+ if (uiDecisionName && !BUSINESS_RULE_CONTEXT_RE.test(ruleContext)) {
221
+ continue;
222
+ }
223
+ return true;
224
+ }
225
+ return false;
226
+ }
227
+
178
228
  /**
179
229
  * @typedef {object} DesignSmell
180
230
  * @property {string} id
@@ -277,11 +327,16 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
277
327
  const loc = countLines(source);
278
328
  const exportsCount = countExports(source);
279
329
 
280
- if (loc >= GOD_LOC && exportsCount >= GOD_EXPORTS) {
330
+ if (
331
+ loc >= GOD_LOC &&
332
+ exportsCount >= GOD_EXPORTS &&
333
+ !isNonProductionPilotPath(rel) &&
334
+ !hasGeneratedSourceBanner(source)
335
+ ) {
281
336
  godEvidence.push(rel);
282
337
  }
283
338
 
284
- if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && DOMAIN_LOGIC_UI_RE.test(source)) {
339
+ if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && hasDomainLogicInUi(source)) {
285
340
  domainInUi.push(rel);
286
341
  }
287
342
 
@@ -353,7 +408,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
353
408
  severity: 'warn',
354
409
  message: `Business-style can*/calculate*/compute* helpers live under UI/presentation paths (${domainInUi.length} file(s)).`,
355
410
  evidence: domainInUi.slice(0, 12),
356
- fix: 'Move pure rules into Domain (or shared pure module under Domain globs) and import from UI.',
411
+ fix: 'Move the pure rule into Domain, expose it through Application, and have UI import the Application boundary (never Presentation → Domain directly).',
357
412
  })
358
413
  );
359
414
  }
@@ -399,7 +454,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
399
454
  }
400
455
 
401
456
  /**
402
- * Whether edge-clean ENFORCE should still report design-weak residual.
457
+ * Whether edge-clean analysis should still report design-weak residual.
403
458
  *
404
459
  * @param {DesignSmell[]} smells
405
460
  * @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null }} ctx
@@ -416,16 +471,23 @@ export function isDesignWeak(smells, ctx = {}) {
416
471
 
417
472
  /**
418
473
  * Design fitness summary for doctor JSON / human.
474
+ * @param {DesignSmell[]} smells
475
+ * @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null, operatingMode?: string }} ctx
419
476
  */
420
477
  export function summarizeDesignFitness(smells, ctx = {}) {
421
478
  const designWeak = isDesignWeak(smells, ctx);
479
+ const mode =
480
+ typeof ctx.operatingMode === 'string' &&
481
+ /^(?:suggest|adapt|enforce)$/.test(ctx.operatingMode)
482
+ ? ctx.operatingMode.toUpperCase()
483
+ : null;
422
484
  return {
423
485
  status: designWeak ? 'design-weak' : smells.length > 0 ? 'smells-with-open-edges' : 'ok',
424
486
  designWeak,
425
487
  smellCount: Array.isArray(smells) ? smells.length : 0,
426
488
  ids: (smells || []).map((s) => s.id),
427
489
  label: designWeak
428
- ? 'ENFORCE · design-weak — edges clean; Shape residual remains (see designSmells / plan B)'
490
+ ? `${mode ? `${mode} · ` : ''}design-weak — edges clean; Shape residual remains (see designSmells / plan B)`
429
491
  : smells.length > 0
430
492
  ? 'Design smells present alongside open edge debt'
431
493
  : 'No deterministic design smells detected',
@@ -471,7 +533,7 @@ function successSignalFor(id) {
471
533
  case 'god-module':
472
534
  return 'Pilot god module split; fan-in and export surface reduced without new edge violations';
473
535
  case 'domain-logic-in-ui':
474
- return 'can*/calculate* pure rules live under Domain; UI imports them only';
536
+ return 'can*/calculate* pure rules live under Domain; Application exposes them; UI imports Application only';
475
537
  case 'facade-sql-in-routes':
476
538
  return '0 route/controller files import ORM/SQL clients; queries in adapters';
477
539
  case 'mixed-pattern-cluster':
@@ -22,6 +22,7 @@ const matchingLayersForRelativePath =
22
22
  import {
23
23
  collectAdoptionGaps,
24
24
  detectSkillGaps,
25
+ skillGapsForActiveHost,
25
26
  detectCodexHomeGap,
26
27
  codexConcernIsActive,
27
28
  detectWritePathCapabilities,
@@ -331,7 +332,7 @@ export function buildRemediationPlan(
331
332
  : 'No active violations — the architecture already meets its contract.';
332
333
  if (designWeak) {
333
334
  statement =
334
- 'No active edge violations — contract edges are clean, but design smells remain (ENFORCE · design-weak). Shape residual is plan B only; not healthy finished.';
335
+ 'No active edge violations — contract edges are clean, but design smells remain (design-weak). Shape residual is plan B only; not healthy finished.';
335
336
  }
336
337
  if (completeness !== ANALYSIS_COMPLETENESS.complete) statement = analysisIncompleteStatement(completeness);
337
338
 
@@ -361,7 +362,7 @@ export function buildRemediationPlan(
361
362
  ...(designWeak
362
363
  ? {
363
364
  designWeakLabel:
364
- 'ENFORCE · design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
365
+ 'Design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
365
366
  ...DESIGN_WEAK_HONESTY_FLAGS,
366
367
  }
367
368
  : {}),
@@ -422,7 +423,7 @@ export function runPlan(
422
423
  if (plan.goal.designWeak) {
423
424
  console.log(
424
425
  color.yellow(
425
- ` ENFORCE · design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
426
+ ` Design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
426
427
  )
427
428
  );
428
429
  }
@@ -536,11 +537,32 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
536
537
  if (!baseline.exists) return true;
537
538
  return !baseline.keys.has(occurrenceKeys[index]);
538
539
  }).length;
540
+ const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
541
+ const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
542
+ const totalFilesEarly = cov.governed.totalFiles || 0;
543
+ const operatingMode = resolveOperatingMode({
544
+ governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
545
+ // planMet uses blocking only; type-only placement debt alone must not force ADAPT.
546
+ planMet:
547
+ analysisComplete &&
548
+ blockingActive === 0 &&
549
+ !emptyScopeEarly &&
550
+ cov.governed.percent >= 50,
551
+ mature: cov.governed.totalFiles >= 150,
552
+ totalFiles: cov.governed.totalFiles,
553
+ emptyLayers: cov.emptyLayers,
554
+ coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
555
+ presentationShare:
556
+ totalFilesEarly > 0 && presentationRowEarly
557
+ ? presentationRowEarly.files / totalFilesEarly
558
+ : null,
559
+ });
539
560
  const designSmells = detectDesignSmells(root, config, files, cov);
540
561
  const observedDesignFitness = summarizeDesignFitness(designSmells, {
541
562
  activeViolations: blockingActive,
542
563
  governedPercent: cov.governed.percent,
543
564
  totalFiles: cov.governed.totalFiles,
565
+ operatingMode,
544
566
  });
545
567
  const designFitness = analysisComplete ? observedDesignFitness : {
546
568
  ...observedDesignFitness, status: 'analysis-incomplete', designWeak: false, label: 'Design fitness not verified — analysis is incomplete; observed smells remain advisory.',
@@ -576,27 +598,6 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
576
598
  );
577
599
  // Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
578
600
  const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
579
- const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
580
- const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
581
- const totalFilesEarly = cov.governed.totalFiles || 0;
582
- const operatingMode = resolveOperatingMode({
583
- governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
584
- // planMet uses blocking (failsStrict !== false) only — type-only placement debt alone
585
- // must not force adapt via unmet plan (parity with merge/exit and productHonesty).
586
- planMet:
587
- analysisComplete &&
588
- blockingActive === 0 &&
589
- !emptyScopeEarly &&
590
- cov.governed.percent >= 50,
591
- mature: cov.governed.totalFiles >= 150,
592
- totalFiles: cov.governed.totalFiles,
593
- emptyLayers: cov.emptyLayers,
594
- coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
595
- presentationShare:
596
- totalFilesEarly > 0 && presentationRowEarly
597
- ? presentationRowEarly.files / totalFilesEarly
598
- : null,
599
- });
600
601
  // Evidence-backed hard only (never capabilities-from-hook-files alone).
601
602
  const hardWriteActive = writePath.enforcementState?.localWrite?.hard === true;
602
603
  const packageInstalled = writePath.enforcementState?.localWrite?.installed === true;
@@ -796,7 +797,6 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
796
797
  mode === 'enforce' && !designFitness.designWeak
797
798
  ? ok
798
799
  : warn;
799
- // Status lights are detected states, not user-picked settings (see docs/product-voice.md).
800
800
  // modeTitle alone names the light — bodies must not re-prefix Suggest/Adapt/Enforce.
801
801
  const modeHelp = {
802
802
  suggest:
@@ -807,8 +807,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
807
807
  'honest coverage and clean checked edges. You arrived here; you never turn Enforce on. Next: keep the host write path and CI check; only NEW violations should fail.',
808
808
  };
809
809
  const modeTitle =
810
- mode === 'enforce' && designFitness.designWeak
811
- ? 'ENFORCE · design-weak'
810
+ designFitness.designWeak
811
+ ? `${mode.toUpperCase()} · design-weak`
812
812
  : mode.toUpperCase();
813
813
  line(
814
814
  modeMark,
@@ -999,7 +999,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
999
999
  'No active violations — coverage is still thin, so green is not yet honest enforcement'
1000
1000
  );
1001
1001
  } else if (designFitness.designWeak) {
1002
- line(warn, 'None on checked edges — edges match the contract; design residual remains (ENFORCE · design-weak). Not healthy finished.');
1002
+ line(warn, `None on checked edges — edges match the contract; design residual remains (${modeTitle}). Not healthy finished.`);
1003
1003
  } else {
1004
1004
  line(ok, 'None — the code matches the contract on checked edges');
1005
1005
  }
@@ -1067,17 +1067,17 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
1067
1067
 
1068
1068
  console.log('');
1069
1069
  console.log(color.bold('Gates & skills'));
1070
- if (gatesMissing.length === 0) line(ok, 'Shared gate files present (AGENTS.md, .mcp.json, CI)');
1070
+ if (gatesMissing.length === 0) line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
1071
1071
  else {
1072
1072
  line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
1073
1073
  actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
1074
1074
  }
1075
- // Report Codex legacy prompts and other-host missing/stale independently (never exclusive).
1076
- const legacyCodex = skillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
1077
- const codexLegacySafeDelete = skillGaps.some(
1075
+ const humanSkillGaps = skillGapsForActiveHost(skillGaps);
1076
+ const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
1077
+ const codexLegacySafeDelete = humanSkillGaps.some(
1078
1078
  (g) => g.tool === 'codex' && g.legacyAdvisory && g.catalogComplete
1079
1079
  );
1080
- const remainingGaps = skillGaps.filter(
1080
+ const remainingGaps = humanSkillGaps.filter(
1081
1081
  (g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
1082
1082
  );
1083
1083
  const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
@@ -1107,7 +1107,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
1107
1107
  const parts = [
1108
1108
  codexHomeGap.legacyPromptsOnly ? 'legacy-prompts-only' : null,
1109
1109
  codexHomeGap.missing > 0 ? `${codexHomeGap.missing} missing` : null,
1110
- codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null,
1110
+ codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null, codexHomeGap.catalogStateReason,
1111
1111
  ].filter(Boolean);
1112
1112
  const deferred = !codexConcernIsActive();
1113
1113
  // Deferred home debt is dim/info (not warn) so non-Codex sessions are not "incomplete".
@@ -1115,7 +1115,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
1115
1115
  line(color.dim('·'), color.dim(`Codex home skills ${parts.join(', ')} (deferred — not on Codex session)`));
1116
1116
  } else {
1117
1117
  line(warn, `Codex home skills ${parts.join(', ')}`);
1118
- actions.push('refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
1118
+ actions.push(codexHomeGap.catalogMetadataInvalid ? 'repair invalid Codex home catalog metadata after verifying the newest installed version' : 'refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
1119
1119
  }
1120
1120
  }
1121
1121
 
@@ -1229,7 +1229,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
1229
1229
  if (postGreenPath) {
1230
1230
  console.log(
1231
1231
  color.dim(
1232
- ' Shape residual is the primary door under ENFORCE · design-weak — do not skill-shop explore vs coverage vs think.'
1232
+ ` Shape residual is the primary door under ${modeTitle} — do not skill-shop explore vs coverage vs think.`
1233
1233
  )
1234
1234
  );
1235
1235
  } else {
@@ -10,6 +10,35 @@ import {
10
10
  loadArkRulesContract,
11
11
  } from './arkrules-contract.mjs';
12
12
 
13
+ function normalizeProjectRelativePath(value) {
14
+ const normalized = value.replace(/\\/g, '/');
15
+ if (
16
+ !normalized ||
17
+ normalized.startsWith('/') ||
18
+ /^[A-Za-z]:/.test(normalized) ||
19
+ normalized.includes('\0')
20
+ ) {
21
+ return undefined;
22
+ }
23
+ const segments = [];
24
+ for (const segment of normalized.split('/')) {
25
+ if (!segment || segment === '.') continue;
26
+ if (segment === '..') return undefined;
27
+ segments.push(segment);
28
+ }
29
+ return segments.length > 0 ? segments.join('/') : undefined;
30
+ }
31
+
32
+ function isWithinRoot(root, candidate) {
33
+ const relative = path.relative(root, candidate);
34
+ return (
35
+ relative === '' ||
36
+ (!relative.startsWith(`..${path.sep}`) &&
37
+ relative !== '..' &&
38
+ !path.isAbsolute(relative))
39
+ );
40
+ }
41
+
13
42
  /**
14
43
  * @param {string} root
15
44
  * @param {Record<string, unknown>} config loaded ark.config.json object
@@ -29,6 +58,7 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
29
58
  const warnings = [];
30
59
  const parts = [];
31
60
  const referenced = new Set();
61
+ const canonicalRoot = fs.realpathSync(root);
32
62
 
33
63
  for (const layer of Object.keys(refs).sort()) {
34
64
  const relRaw = refs[layer];
@@ -37,10 +67,12 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
37
67
  errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
38
68
  continue;
39
69
  }
40
- if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
70
+ const rel = normalizeProjectRelativePath(relRaw);
71
+ if (!rel) {
41
72
  errors.push({
42
73
  path: pathKey,
43
- message: 'must be a project-relative path (absolute paths are not allowed)',
74
+ message:
75
+ 'must be a project-relative path without absolute roots or parent-directory traversal',
44
76
  });
45
77
  continue;
46
78
  }
@@ -52,17 +84,42 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
52
84
  continue;
53
85
  }
54
86
 
55
- const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
56
87
  referenced.add(rel);
57
- const absolute = path.resolve(root, rel);
58
- opts.observeInput?.(absolute, 'arkrules');
59
- if (!fs.existsSync(absolute)) {
88
+ const lexicalTarget = path.resolve(canonicalRoot, ...rel.split('/'));
89
+ if (!isWithinRoot(canonicalRoot, lexicalTarget)) {
90
+ errors.push({
91
+ path: pathKey,
92
+ message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
93
+ });
94
+ continue;
95
+ }
96
+ if (!fs.existsSync(lexicalTarget)) {
60
97
  errors.push({
61
98
  path: pathKey,
62
99
  message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
63
100
  });
64
101
  continue;
65
102
  }
103
+ let absolute;
104
+ try {
105
+ absolute = fs.realpathSync(lexicalTarget);
106
+ } catch (error) {
107
+ errors.push({
108
+ path: pathKey,
109
+ message: `referenced ArkRules file ${JSON.stringify(rel)} could not be resolved: ${
110
+ error instanceof Error ? error.message : String(error)
111
+ }`,
112
+ });
113
+ continue;
114
+ }
115
+ if (!isWithinRoot(canonicalRoot, absolute)) {
116
+ errors.push({
117
+ path: pathKey,
118
+ message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
119
+ });
120
+ continue;
121
+ }
122
+ opts.observeInput?.(absolute, 'arkrules');
66
123
  let content;
67
124
  try {
68
125
  content = fs.readFileSync(absolute, 'utf8');
@@ -90,9 +147,16 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
90
147
  }
91
148
 
92
149
  // Drift: unreferenced files under arkrules/
93
- const arkrulesDir = path.join(root, 'arkrules');
94
- if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
95
- for (const name of fs.readdirSync(arkrulesDir).sort()) {
150
+ const arkrulesDir = path.join(canonicalRoot, 'arkrules');
151
+ const resolvedArkRulesDir = fs.existsSync(arkrulesDir)
152
+ ? fs.realpathSync(arkrulesDir)
153
+ : undefined;
154
+ if (
155
+ resolvedArkRulesDir &&
156
+ isWithinRoot(canonicalRoot, resolvedArkRulesDir) &&
157
+ fs.statSync(resolvedArkRulesDir).isDirectory()
158
+ ) {
159
+ for (const name of fs.readdirSync(resolvedArkRulesDir).sort()) {
96
160
  if (!name.endsWith('.json')) continue;
97
161
  const rel = `arkrules/${name}`;
98
162
  if (!referenced.has(rel)) {