arkgate 4.1.1 → 4.2.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +107 -3
  2. package/README.md +16 -4
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/ark-shared.mjs +16 -4
  6. package/bin/lib/agent-gates.mjs +1 -0
  7. package/bin/lib/ci-and-commands.mjs +16 -7
  8. package/bin/lib/codex-home.mjs +90 -8
  9. package/bin/lib/design-smells.mjs +71 -9
  10. package/bin/lib/doctor-plan.mjs +36 -36
  11. package/bin/lib/effective-contract-load.mjs +73 -9
  12. package/bin/lib/enforcement-state.mjs +1 -1
  13. package/bin/lib/gate-files.mjs +441 -9
  14. package/bin/lib/github-enforcement.mjs +16 -3
  15. package/bin/lib/hook-templates.mjs +12 -11
  16. package/bin/lib/html-report-evolution.mjs +114 -0
  17. package/bin/lib/html-report.mjs +11 -89
  18. package/bin/lib/import-resolve.mjs +33 -11
  19. package/bin/lib/install-activation.mjs +87 -0
  20. package/bin/lib/install-migrate.mjs +66 -50
  21. package/bin/lib/managed-upgrade.mjs +10 -41
  22. package/bin/lib/mcp-adoption.mjs +15 -5
  23. package/bin/lib/physical-cohesion.mjs +2 -1
  24. package/bin/lib/pilot-loop.mjs +25 -8
  25. package/bin/lib/project-identity.mjs +103 -0
  26. package/bin/lib/report-snapshot-context.mjs +28 -0
  27. package/bin/lib/resident-hook.mjs +33 -9
  28. package/bin/lib/rules-inventory.mjs +100 -8
  29. package/bin/lib/skill-install.mjs +272 -22
  30. package/bin/lib/skill-write.mjs +899 -0
  31. package/bin/lib/start-preview.mjs +84 -1
  32. package/bin/lib/upgrade-command.mjs +2 -5
  33. package/dist/index.cjs +13 -13
  34. package/dist/index.d.ts +194 -2
  35. package/dist/index.js +13 -13
  36. package/docs/README.md +5 -3
  37. package/docs/agent-guide.md +110 -14
  38. package/docs/ai-gates.md +103 -18
  39. package/docs/assets/ark-write-gate.svg +2 -2
  40. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  41. package/docs/package-surface.md +15 -9
  42. package/docs/product-voice.md +13 -1
  43. package/package.json +7 -1
  44. package/schemas/ark.project-identity.schema.json +116 -0
  45. package/server.json +2 -2
  46. package/templates/skills/ark-adopt.md +9 -0
  47. package/templates/skills/ark-architect.md +12 -2
  48. package/templates/skills/ark-autopilot.md +9 -0
  49. package/templates/skills/ark-contract.md +11 -1
  50. package/templates/skills/ark-coverage.md +9 -0
  51. package/templates/skills/ark-explain.md +13 -1
  52. package/templates/skills/ark-explore.md +9 -0
  53. package/templates/skills/ark-fix.md +10 -1
  54. package/templates/skills/ark-loop.md +11 -2
  55. package/templates/skills/ark-place.md +17 -6
  56. package/templates/skills/ark-runtime.md +8 -0
  57. package/templates/skills/ark-think.md +14 -2
  58. package/templates/skills/ark-upgrade.md +9 -0
@@ -63,12 +63,11 @@ import {
63
63
  SKILL_TOOL_TARGETS,
64
64
  skillTemplates,
65
65
  stampSkill,
66
- installedSkillVersion,
67
- isVersionOlder,
68
66
  detectSkillGaps,
69
67
  arkPackageVersion,
70
68
  verifyHostSkillCatalog,
71
69
  } from './skill-install.mjs';
70
+ import { installRepoSkillFile, installSkillCatalog, skillInstallLine, skillInstallNote } from './skill-write.mjs';
72
71
  import { detectDeployPathQuality } from './deploy-path.mjs';
73
72
  import {
74
73
  stripMcpServerArgs,
@@ -78,6 +77,7 @@ import {
78
77
  PREFERRED_CHECK_BIN,
79
78
  RUNNER_BEFORE_ARK,
80
79
  } from './mcp-adoption.mjs';
80
+ import { inspectCodexInstallActivation, printCodexActivationHandoff, reportPartialInstall } from './install-activation.mjs';
81
81
  import {
82
82
  hasHardWriteHook,
83
83
  validateHardWriteRequest,
@@ -181,7 +181,7 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
181
181
  compact ? compactAgentInstructions(root, compactHost) : agentInstructions(root)
182
182
  );
183
183
  // Always write project MCP registration — compact hosts other than Claude still need
184
- // ark://manifest for agents (field: compact grok start left doctor reporting Missing .mcp.json
184
+ // project-bound ark_identity/ark_manifest (field: compact Grok start left doctor reporting Missing .mcp.json
185
185
  // when AGENTS lost compact markers or hosts were mixed).
186
186
  add('.mcp.json', mcpJson(root));
187
187
  const deploy = detectDeployPathQuality(root);
@@ -416,9 +416,11 @@ export function runInstallAgentGates(args) {
416
416
  // settings.json, CI workflow, rules) are the ones users customize, so a plain
417
417
  // `--force` clobbers them — this is the safe way to pick up new skill versions.
418
418
  // Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
419
+ const earlyWritten = new Set();
419
420
  if (!args.skillsOnly) {
420
421
  // S4.4: always write check:architecture on gate install / start (including compact).
421
422
  const checkBootstrap = ensureCheckArchitectureScript(root, { write: true });
423
+ if (checkBootstrap.changed) earlyWritten.add('package.json');
422
424
  if (checkBootstrap.changed && !args.json) {
423
425
  console.log(
424
426
  `Added package.json script "check:architecture": "${checkBootstrap.script}" (local/CI parity).`
@@ -426,6 +428,7 @@ export function runInstallAgentGates(args) {
426
428
  }
427
429
  // Bootstrap typecheck before CI template so generated workflow includes the step.
428
430
  const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
431
+ if (typecheckBootstrap.changed && !args.compact) earlyWritten.add('package.json');
429
432
  if (typecheckBootstrap.changed && !args.compact && !args.json) {
430
433
  console.log(
431
434
  `Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
@@ -438,7 +441,7 @@ export function runInstallAgentGates(args) {
438
441
  compact: args.compact,
439
442
  skillsOnly: args.skillsOnly,
440
443
  });
441
- const { skills, skillPaths, version } = catalog;
444
+ const { skills, version } = catalog;
442
445
  const assetKindByPath = new Map(
443
446
  catalog.assets.map((asset) => [asset.relativePath, asset.kind ?? 'gate'])
444
447
  );
@@ -450,7 +453,10 @@ export function runInstallAgentGates(args) {
450
453
  if (args.compact && priorCompactHost === 'none' && [...tools][0]) {
451
454
  const genericMcp = path.join(root, '.mcp.json');
452
455
  try {
453
- if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) fs.rmSync(genericMcp);
456
+ if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) {
457
+ fs.rmSync(genericMcp);
458
+ earlyWritten.add('.mcp.json');
459
+ }
454
460
  } catch {
455
461
  // Missing or customized user MCP configuration is deliberately retained.
456
462
  }
@@ -459,6 +465,9 @@ export function runInstallAgentGates(args) {
459
465
  const results = templates.map(([relativePath, content]) => {
460
466
  // S4.1: preserve content-identity customized/conflicted managed assets even under --force.
461
467
  const kind = assetKindByPath.get(relativePath) ?? 'gate';
468
+ if (kind === 'skill') {
469
+ return installRepoSkillFile(root, relativePath, content, version, args.force);
470
+ }
462
471
  if (
463
472
  args.force &&
464
473
  managedPresent &&
@@ -477,7 +486,14 @@ export function runInstallAgentGates(args) {
477
486
  }
478
487
  const tableStart = content.indexOf('[mcp_servers.ark]');
479
488
  const generatedPrelude = tableStart > 0 ? content.slice(0, tableStart) : '';
480
- const mergeBase = generatedPrelude ? existing.replace(generatedPrelude, '') : existing;
489
+ const mergeBase = generatedPrelude
490
+ ? existing
491
+ .replace(generatedPrelude, '')
492
+ .replace(
493
+ /^# Generated by ark-check --install-agent-gates \(Codex project scope\)\.\n# Restart Codex after changes; MCP servers are loaded when the project session starts\.\n/m,
494
+ ''
495
+ )
496
+ : existing;
481
497
  const merged = upsertCodexMcpTable(mergeBase, 'ark', content);
482
498
  if (merged === existing) return { relativePath, status: 'skipped' };
483
499
  return writeTemplate(root, relativePath, merged, true);
@@ -527,7 +543,9 @@ export function runInstallAgentGates(args) {
527
543
  );
528
544
  });
529
545
 
530
- console.log('Ark agent gate templates:');
546
+ console.log(
547
+ `Ark agent gate templates (scope=repo; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}):`
548
+ );
531
549
  let staleSkipped = 0;
532
550
  let preservedCustomized = 0;
533
551
  for (const result of results) {
@@ -549,13 +567,10 @@ export function runInstallAgentGates(args) {
549
567
  if (result.status === 'skipped-customized') {
550
568
  preservedCustomized += 1;
551
569
  note = ' (customized — content-identity preserved)';
552
- } else if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
553
- const installed = installedSkillVersion(path.join(root, result.relativePath));
554
- if (installed === null || isVersionOlder(installed, version)) {
570
+ } else if (result.status === 'skipped' && result.skillPlan) {
571
+ note = ` (${skillInstallNote(result.skillPlan)})`;
572
+ if (result.skillPlan.reason === 'existing-preserved') {
555
573
  staleSkipped += 1;
556
- note = ` (stale: ${installed ?? 'no stamp'} < ${version})`;
557
- } else {
558
- note = ' (up to date)';
559
574
  }
560
575
  }
561
576
  console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
@@ -581,36 +596,29 @@ export function runInstallAgentGates(args) {
581
596
  if (args.codexHome) {
582
597
  const dir = codexSkillsDir();
583
598
  console.log('');
584
- console.log(`Codex home skills (${dir}/<name>/SKILL.md):`);
599
+ console.log(
600
+ `Codex home skills (scope=home-shared; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}; target=${dir}/<name>/SKILL.md):`
601
+ );
602
+ console.log(
603
+ ' Compatibility: monotonic downgrade protection requires every shared-catalog writer ' +
604
+ 'to use ArkGate 4.2.0+; pre-4.2 --codex-home ignores this catalog. Upgrade legacy repos first.'
605
+ );
585
606
  try {
586
607
  fs.mkdirSync(dir, { recursive: true });
587
608
  } catch (error) {
588
609
  console.error(` FAILED to create ${dir} (${error.message})`);
589
- homeResults.push({ status: 'failed' });
610
+ homeResults.push({ relativePath: dir, status: 'failed' });
590
611
  }
591
612
  if (homeResults.length === 0) {
592
- for (const [name, content] of skills) {
593
- const skillDir = path.join(dir, name);
594
- const file = path.join(skillDir, 'SKILL.md');
595
- if (fs.existsSync(file) && !args.force) {
596
- const installed = installedSkillVersion(file);
597
- const behind = installed === null || (version && isVersionOlder(installed, version));
598
- const note = behind
599
- ? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
600
- : ' (up to date)';
601
- console.log(` ${'skipped'.padEnd(7)} ${name}/SKILL.md${note}`);
602
- homeResults.push({ status: 'skipped' });
603
- continue;
604
- }
605
- try {
606
- fs.mkdirSync(skillDir, { recursive: true });
607
- fs.writeFileSync(file, content);
608
- console.log(` ${'wrote'.padEnd(7)} ${name}/SKILL.md`);
609
- homeResults.push({ status: 'written' });
610
- } catch (error) {
611
- console.log(` ${'FAILED'.padEnd(7)} ${name}/SKILL.md (${error.message})`);
612
- homeResults.push({ status: 'failed' });
613
- }
613
+ for (const result of installSkillCatalog({
614
+ directory: dir,
615
+ skills,
616
+ packageVersion: version,
617
+ force: args.force,
618
+ scope: 'home',
619
+ })) {
620
+ console.log(skillInstallLine(result));
621
+ homeResults.push(result);
614
622
  }
615
623
  }
616
624
  }
@@ -643,26 +651,28 @@ export function runInstallAgentGates(args) {
643
651
  const verb = codexMcp.status === 'updated' ? 'updated' : 'wrote';
644
652
  console.log(` ${verb.padEnd(7)} [mcp_servers.ark] with absolute paths`);
645
653
  console.log(' RESTART Codex — it does not hot-load MCP servers.');
646
- console.log(' Then expect: resource ark://manifest + tools validate_code, ark_check, ark_coverage, ark_place.');
654
+ console.log(
655
+ ' Then expect: tools ark_identity, ark_manifest, validate_code, ark_check, ark_coverage, ark_place.'
656
+ );
647
657
  }
648
658
  } else if (skipHomeWire) {
649
659
  codexMcp = { status: 'skipped', file: codexConfigPath(), reason: 'temp-root' };
650
660
  }
651
661
 
652
- // Repo templates + explicit --codex-home skill writes are hard failures.
653
- // Home MCP wire is best-effort: unreadable ~/.codex (sandbox, permissions) must not
654
- // mark an otherwise successful repo gate install as failed.
655
- const hardFailed = [...results, ...homeResults].filter((result) => result.status === 'failed');
656
- if (hardFailed.length > 0) {
657
- console.error(`\nFailed to write ${hardFailed.length} template(s).`);
662
+ const { codexProjectConfigured, runtimeActivation } =
663
+ inspectCodexInstallActivation(root, tools.has('codex') && !args.skillsOnly);
664
+ if (reportPartialInstall({
665
+ root,
666
+ tools,
667
+ results,
668
+ homeResults,
669
+ earlyWritten,
670
+ codexMcp,
671
+ runtimeActivation,
672
+ })) {
658
673
  process.exitCode = 1;
659
674
  return;
660
675
  }
661
- if (codexMcp?.status === 'failed') {
662
- console.error(
663
- `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --codex-home --force.`
664
- );
665
- }
666
676
  if (writeRequest.host) {
667
677
  if (!hasHardWriteHook(root, writeRequest.host)) {
668
678
  console.error(`\nFailed to verify the ${writeRequest.host} hard-write hook after install.`);
@@ -731,8 +741,14 @@ export function runInstallAgentGates(args) {
731
741
  }
732
742
  if ((tools.has('codex') || args.codexHome)) {
733
743
  console.log('');
744
+ if (tools.has('codex') && !args.skillsOnly) {
745
+ printCodexActivationHandoff(root, codexProjectConfigured, runtimeActivation);
746
+ }
734
747
  if (codexMcp && codexMcp.status !== 'failed') {
735
- console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
748
+ console.log(
749
+ ` Codex: ark MCP registered in ${codexMcp.file} — restart Codex, then bind with ` +
750
+ '`ark_identity` and read the contract with `ark_manifest`.'
751
+ );
736
752
  }
737
753
  if (tools.has('codex') && !args.compact) {
738
754
  console.log(' Codex write path (honest):');
@@ -371,9 +371,9 @@ function summaryFor(assets, manifestChanged) {
371
371
  const states = {};
372
372
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
373
373
  const applying = assets.filter((asset) => asset.willApply);
374
- // Content writes (stale/missing/conflicted accepted) — not version-stamp metadata-only.
375
- const wouldWrite = applying.filter((asset) => asset.action !== 'refresh-metadata').length;
376
- const metadataRefresh = applying.filter((asset) => asset.action === 'refresh-metadata').length;
374
+ const wouldWrite = applying.length;
375
+ // Public-summary compatibility: stamp-only writes are no longer scheduled.
376
+ const metadataRefresh = 0;
377
377
  const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
378
378
  const fileChanges = applying.length;
379
379
  return {
@@ -385,7 +385,6 @@ function summaryFor(assets, manifestChanged) {
385
385
  customizedPreserved,
386
386
  fileChanges,
387
387
  manifestChanged,
388
- // Full apply count still includes optional stamp refresh + manifest bookkeeping.
389
388
  changed: fileChanges + (manifestChanged ? 1 : 0),
390
389
  blocked: assets.filter((asset) => asset.blocked).length,
391
390
  };
@@ -447,12 +446,7 @@ export function planManagedUpgrade(root, options = {}) {
447
446
  kind: catalogAsset.kind,
448
447
  });
449
448
  const accepted = options.acceptConflicts === true;
450
- const refreshMetadata =
451
- classified.state === 'current' &&
452
- catalogAsset.kind === 'skill' &&
453
- currentScoped !== desiredScoped;
454
449
  const canApply =
455
- refreshMetadata ||
456
450
  classified.state === 'stale' ||
457
451
  (classified.state === 'missing' && (!recorded || accepted)) ||
458
452
  (classified.state === 'conflicted' && accepted);
@@ -465,7 +459,7 @@ export function planManagedUpgrade(root, options = {}) {
465
459
  scope: catalogAsset.scope,
466
460
  ...classified,
467
461
  ...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
468
- action: refreshMetadata ? 'refresh-metadata' : canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
462
+ action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
469
463
  willApply: canApply,
470
464
  blocked,
471
465
  beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
@@ -615,9 +609,7 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
615
609
  if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
616
610
  if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
617
611
  const wouldWrite = plan.summary.wouldWrite ?? 0;
618
- const metadataRefresh = plan.summary.metadataRefresh ?? 0;
619
612
  // Content already matches: unbound --apply is a no-op (exit success), not a digest error.
620
- // Optional stamp-only refresh still requires the preview's exact --plan-digest.
621
613
  if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
622
614
  if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {
623
615
  return publicPlan(plan, {
@@ -625,7 +617,6 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
625
617
  applied: false,
626
618
  blocked: false,
627
619
  nothingToApply: true,
628
- optionalStampRefresh: metadataRefresh,
629
620
  });
630
621
  }
631
622
  throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
@@ -741,31 +732,18 @@ export function renderManagedUpgrade(plan, options = {}) {
741
732
  const summary = plan.summary;
742
733
  const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
743
734
  const wouldWrite = summary.wouldWrite ?? 0;
744
- const metadataRefresh = summary.metadataRefresh ?? 0;
745
735
  const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
746
736
  const blocked = summary.blocked ?? 0;
747
737
  console.log(
748
738
  `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
749
- `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}` +
750
- (metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
751
- '.'
739
+ `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
752
740
  );
753
741
  if (plan.applied) {
754
- // Distinguish content writes from optional stamp/metadata bookkeeping.
755
- if (wouldWrite === 0 && metadataRefresh > 0) {
756
- console.log(
757
- `Refreshed ${metadataRefresh} version stamp(s)` +
758
- (summary.manifestChanged ? ' and managed manifest' : '') +
759
- ' (no content body changes).'
760
- );
761
- } else {
762
- console.log(
763
- `Applied ${wouldWrite} content write(s)` +
764
- (metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
765
- (summary.manifestChanged ? ', managed manifest' : '') +
766
- '.'
767
- );
768
- }
742
+ console.log(
743
+ `Applied ${wouldWrite} content write(s)` +
744
+ (summary.manifestChanged ? ', managed manifest' : '') +
745
+ '.'
746
+ );
769
747
  return;
770
748
  }
771
749
  // Content already matches package templates — do not urge --apply as the primary next step.
@@ -775,15 +753,6 @@ export function renderManagedUpgrade(plan, options = {}) {
775
753
  console.log(
776
754
  `Nothing to apply — managed content matches ${verLabel} (${customizedPreserved} customized preserved).`
777
755
  );
778
- if (metadataRefresh > 0) {
779
- console.log(
780
- `Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
781
- );
782
- const stampCmd = options.optionalStampApply ?? options.next;
783
- if (stampCmd) {
784
- console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
785
- }
786
- }
787
756
  return;
788
757
  }
789
758
  console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
@@ -16,7 +16,7 @@ import { detectWritePathCapabilities } from './write-path-detect.mjs';
16
16
  import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
17
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
18
18
  import { collectWeakestLinkGaps } from './weakest-link.mjs';
19
- import { withCiProviderEvidence } from './enforcement-state.mjs';
19
+ import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';
20
20
 
21
21
  export { detectDeployPathQuality };
22
22
 
@@ -120,7 +120,7 @@ export function brokenMcpGateFiles(root) {
120
120
 
121
121
  /**
122
122
  * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
123
- * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
123
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object, runtimeActivation: object }}
124
124
  */
125
125
  export function collectAdoptionGaps(root, config, coverage) {
126
126
  const gaps = [];
@@ -259,7 +259,11 @@ export function collectAdoptionGaps(root, config, coverage) {
259
259
  return false;
260
260
  }
261
261
  })();
262
- if (adopted && !isProducer && !codexProjectMcp) {
262
+ const runtimeActivation = codexRuntimeActivation({
263
+ configuredOnDisk: codexProjectMcp,
264
+ restartRequired: codexProjectMcp,
265
+ });
266
+ if (adopted && !isProducer) {
263
267
  const codexFile = codexConfigPath();
264
268
  let toml = '';
265
269
  try {
@@ -278,6 +282,8 @@ export function collectAdoptionGaps(root, config, coverage) {
278
282
  needsRewrite: assessed.needsRewrite,
279
283
  multiProject: assessed.multiProject,
280
284
  scopedTable: assessed.scopedTable,
285
+ projectConfiguredOnDisk: codexProjectMcp,
286
+ runtimeIdentityVerified: false,
281
287
  };
282
288
  if (assessed.gap) {
283
289
  // Temp/upgrade MCP roots stay urgent (fail-closed). Non-temp Codex-home debt
@@ -288,9 +294,12 @@ export function collectAdoptionGaps(root, config, coverage) {
288
294
  const deferred =
289
295
  !tempUrgent && activeHost != null && activeHost !== 'codex';
290
296
  const severity = deferred ? 'info' : assessed.gap.severity;
297
+ const localRisk = codexProjectMcp
298
+ ? 'Project config exists on disk, but the active runtime identity is unverified. '
299
+ : '';
291
300
  const message = deferred
292
- ? `Deferred (fix when using Codex): ${assessed.gap.message}`
293
- : assessed.gap.message;
301
+ ? `Deferred (fix when using Codex): ${localRisk}${assessed.gap.message}`
302
+ : `${localRisk}${assessed.gap.message}`;
294
303
  gaps.push({
295
304
  id: assessed.gap.id,
296
305
  severity,
@@ -526,6 +535,7 @@ export function collectAdoptionGaps(root, config, coverage) {
526
535
  deployPath,
527
536
  contractFalseGreen,
528
537
  writePath,
538
+ runtimeActivation,
529
539
  enforcement: {
530
540
  ci: weakest.ci,
531
541
  preCommit: weakest.preCommit,
@@ -24,7 +24,8 @@ const MAX_FINDINGS = 5;
24
24
  const MAX_ANCHORS = 4;
25
25
  const MAX_MOVE_SAMPLE = 5;
26
26
 
27
- const FRAMEWORK_FILES = /^(route|page|layout|index|loading|error|template|default|not-found|middleware|actions?|handler)$/i;
27
+ // `proxy` = Next.js 16 network-boundary rename of middleware (still framework-owned).
28
+ const FRAMEWORK_FILES = /^(route|page|layout|index|loading|error|template|default|not-found|middleware|proxy|actions?|handler)$/i;
28
29
  const SKIP_SEGMENT = /^(\[.*\]|\(.*\)|src|app|apps|api|lib|libs|pages|packages|modules|components|utils|helpers|hooks|server|client|shared|common|__tests__|tests?|e2e|examples?|dist|build)$/i;
29
30
  const NOISE_TOKEN = /^(use|api|get|set|app|lib|the|new)$/;
30
31
  /** ADR 0010 D7 — framework-owned anchors never move. */
@@ -5,7 +5,10 @@
5
5
  * extraction-card payload, and compares residual after re-doctor on pilot paths.
6
6
  * Judgment only — never mechanical-safe; never multi-pilot batch apply.
7
7
  */
8
- import { buildPatternBetsFromSmells } from './design-smells.mjs';
8
+ import {
9
+ buildPatternBetsFromSmells,
10
+ isNonProductionPilotPath,
11
+ } from './design-smells.mjs';
9
12
 
10
13
  /** Stable product id for JSON / tests. */
11
14
  export const PILOT_LOOP_ID = 'one-pilot-redoctor';
@@ -44,14 +47,28 @@ export function fileEvidencePaths(evidence = []) {
44
47
  );
45
48
  }
46
49
 
50
+ function pilotFilesForBet(bet, preferredFiles) {
51
+ const rawFiles = preferredFiles?.length
52
+ ? preferredFiles
53
+ : fileEvidencePaths(bet?.evidence);
54
+ if (bet?.smellId !== 'god-module') return rawFiles;
55
+ const files = rawFiles.filter((file) => !isNonProductionPilotPath(file));
56
+ const excludedPilot =
57
+ rawFiles.length === 0 &&
58
+ typeof bet.pilot === 'string' &&
59
+ isNonProductionPilotPath(bet.pilot);
60
+ return (rawFiles.length > 0 && files.length === 0) || excludedPilot
61
+ ? null
62
+ : files;
63
+ }
64
+
47
65
  /**
48
66
  * Score a pattern bet for "do this pilot first".
49
67
  * Prefers concrete src/ files and higher-impact smell ids.
50
68
  * @param {object} bet
51
69
  * @param {number} index
52
70
  */
53
- function scoreBet(bet, index) {
54
- const files = fileEvidencePaths(bet?.evidence);
71
+ function scoreBet(bet, index, files = fileEvidencePaths(bet?.evidence)) {
55
72
  const smellPri = SMELL_PRIORITY[bet?.smellId] ?? 50;
56
73
  // Higher score wins; concrete files dominate; then smell priority; stable by index.
57
74
  return files.length * 100 - smellPri * 10 - index;
@@ -64,9 +81,8 @@ function scoreBet(bet, index) {
64
81
  */
65
82
  export function extractionCardFromBet(bet, preferredFiles) {
66
83
  if (!bet || typeof bet !== 'object') return null;
67
- const files = preferredFiles?.length
68
- ? preferredFiles
69
- : fileEvidencePaths(bet.evidence);
84
+ const files = pilotFilesForBet(bet, preferredFiles);
85
+ if (files === null) return null;
70
86
  const evidence = files.length ? files : (bet.evidence || []).slice(0, 8);
71
87
  const pilotTarget =
72
88
  files[0] ||
@@ -123,8 +139,9 @@ export function selectNextPilot(patternBets, options = {}) {
123
139
  if (!bet || bet.neverMechanicalSafe === false) continue;
124
140
  // Skip anything that claims mechanical-safe (honesty).
125
141
  if (bet.class === 'mechanical-safe') continue;
126
- const files = fileEvidencePaths(bet.evidence);
127
- const sc = scoreBet(bet, i);
142
+ const files = pilotFilesForBet(bet);
143
+ if (files === null) continue;
144
+ const sc = scoreBet(bet, i, files);
128
145
  if (sc > bestScore) {
129
146
  bestScore = sc;
130
147
  best = { bet, files };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/projectIdentity.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/project-identity.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_PROJECT_IDENTITY_SCHEMA_VERSION = '1.0';
12
+ export const ARK_PROJECT_IDENTITY_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.project-identity.schema.json';
13
+ const sha256Pattern = '^sha256:[a-f0-9]{64}$';
14
+ export const PROJECT_EXPECTATION_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ properties: {
18
+ expectedRoot: {
19
+ type: 'string',
20
+ minLength: 1,
21
+ description: 'Absolute expected workspace/project directory. The initial authoritative handshake ' +
22
+ 'requires the exact project root; descendant calls also require expectedProjectId.',
23
+ },
24
+ expectedProjectId: {
25
+ type: 'string',
26
+ pattern: sha256Pattern,
27
+ description: 'Project id previously returned by ark_identity or ark_manifest.',
28
+ },
29
+ },
30
+ };
31
+ export const PROJECT_BINDING_SCHEMA = {
32
+ type: 'object',
33
+ additionalProperties: false,
34
+ required: ['status', 'authoritative'],
35
+ properties: {
36
+ status: { enum: ['matched', 'unverified', 'mismatch'] },
37
+ authoritative: { type: 'boolean' },
38
+ expectedRoot: { type: 'string', minLength: 1 },
39
+ expectedProjectId: { type: 'string', pattern: sha256Pattern },
40
+ code: {
41
+ enum: [
42
+ 'PROJECT_ROOT_MISMATCH',
43
+ 'PROJECT_ID_MISMATCH',
44
+ 'INVALID_PROJECT_EXPECTATION',
45
+ ],
46
+ },
47
+ message: { type: 'string', minLength: 1 },
48
+ },
49
+ };
50
+ export const ARK_PROJECT_IDENTITY_SCHEMA = {
51
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
52
+ $id: ARK_PROJECT_IDENTITY_SCHEMA_URL,
53
+ title: 'ArkGate MCP project identity',
54
+ description: 'Stable project binding plus separate runtime and architecture-contract evidence.',
55
+ type: 'object',
56
+ additionalProperties: false,
57
+ required: [
58
+ 'schemaVersion',
59
+ 'projectId',
60
+ 'resolvedRoot',
61
+ 'resolvedConfigPath',
62
+ 'arkgateVersion',
63
+ 'contractHash',
64
+ 'contractSource',
65
+ 'runtimeId',
66
+ 'processStartedAt',
67
+ ],
68
+ properties: {
69
+ schemaVersion: { const: ARK_PROJECT_IDENTITY_SCHEMA_VERSION },
70
+ projectId: { type: 'string', pattern: sha256Pattern },
71
+ resolvedRoot: { type: 'string', minLength: 1 },
72
+ resolvedConfigPath: { type: 'string', minLength: 1 },
73
+ arkgateVersion: { type: 'string', minLength: 1 },
74
+ contractHash: { type: 'string', pattern: sha256Pattern },
75
+ contractSource: { enum: ['project', 'default-profile', 'manifest'] },
76
+ runtimeId: { type: 'string', minLength: 1 },
77
+ processStartedAt: { type: 'string', format: 'date-time' },
78
+ },
79
+ $defs: {
80
+ expectation: PROJECT_EXPECTATION_SCHEMA,
81
+ binding: PROJECT_BINDING_SCHEMA,
82
+ },
83
+ };
84
+ /**
85
+ * Stable identity: contract edits and MCP restarts must not change which project
86
+ * this is. Callers must pass canonical real paths and a SHA-256 hex function.
87
+ */
88
+ export function createProjectId(resolvedRoot, resolvedConfigPath, sha256Hex) {
89
+ if (!resolvedRoot || !resolvedConfigPath) {
90
+ throw new Error('Project identity requires resolvedRoot and resolvedConfigPath.');
91
+ }
92
+ const digest = sha256Hex(JSON.stringify({ resolvedRoot, resolvedConfigPath })).toLowerCase();
93
+ if (!/^[a-f0-9]{64}$/.test(digest)) {
94
+ throw new Error('Project identity hash adapter must return 64 hexadecimal SHA-256 characters.');
95
+ }
96
+ return `sha256:${digest}`;
97
+ }
98
+ export function createProjectIdentity(input) {
99
+ return {
100
+ schemaVersion: ARK_PROJECT_IDENTITY_SCHEMA_VERSION,
101
+ ...input,
102
+ };
103
+ }
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ /** Best-effort, shell-free Git/worktree evidence for report snapshots. */
4
+ export function captureGitSnapshot(root) {
5
+ const run = (args) =>
6
+ execFileSync('git', ['-C', root, ...args], {
7
+ encoding: 'utf8',
8
+ stdio: ['ignore', 'pipe', 'ignore'],
9
+ }).trim();
10
+ try {
11
+ const headSha = run(['rev-parse', '--verify', 'HEAD']);
12
+ let branch = null;
13
+ try {
14
+ branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || null;
15
+ } catch {
16
+ // Detached HEAD is valid release/report evidence.
17
+ }
18
+ let dirty = null;
19
+ try {
20
+ dirty = run(['status', '--porcelain=v1', '--untracked-files=normal']).length > 0;
21
+ } catch {
22
+ // Keep the commit identity even when worktree state is unavailable.
23
+ }
24
+ return { available: true, headSha, branch, dirty };
25
+ } catch {
26
+ return { available: false, headSha: null, branch: null, dirty: null };
27
+ }
28
+ }