arkgate 4.8.18 → 4.8.20

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 (74) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +5 -6
  3. package/bin/ark-check-runtime.mjs +23 -0
  4. package/bin/ark-layer-match.mjs +141 -9
  5. package/bin/ark-mcp-runtime.mjs +3 -3
  6. package/bin/ark-shared.mjs +187 -16
  7. package/bin/ark.mjs +23 -19
  8. package/bin/lib/analysis-engine.mjs +6 -6
  9. package/bin/lib/ci-and-commands.mjs +2 -2
  10. package/bin/lib/config-contract.mjs +1 -0
  11. package/bin/lib/doctor-advisories.mjs +2 -1
  12. package/bin/lib/doctor-human.mjs +2 -0
  13. package/bin/lib/doctor-next-actions.mjs +35 -0
  14. package/bin/lib/doctor-plan.mjs +8 -8
  15. package/bin/lib/enforcement-honesty.mjs +30 -2
  16. package/bin/lib/gate-files.mjs +22 -1
  17. package/bin/lib/host-support-matrix.mjs +1 -1
  18. package/bin/lib/html-report-depth.mjs +6 -2
  19. package/bin/lib/install-migrate.mjs +3 -4
  20. package/bin/lib/invariant-coverage.mjs +44 -13
  21. package/bin/lib/managed-upgrade.mjs +30 -5
  22. package/bin/lib/mcp-adoption.mjs +4 -1
  23. package/bin/lib/package-manager.mjs +5 -2
  24. package/bin/lib/physical-cohesion.mjs +68 -51
  25. package/bin/lib/presets.mjs +14 -14
  26. package/bin/lib/reshape-decisions.mjs +2 -2
  27. package/bin/lib/rules-under-contract.mjs +40 -4
  28. package/bin/lib/scan-files.mjs +3 -26
  29. package/bin/lib/skill-install.mjs +3 -2
  30. package/bin/lib/start-install-recovery.mjs +156 -0
  31. package/bin/lib/start-preview.mjs +17 -16
  32. package/bin/lib/write-path-detect.mjs +3 -2
  33. package/dist/{configTypes-VD0qcubY.d.ts → configTypes-VVknqcTJ.d.ts} +2 -0
  34. package/dist/{diagnosticCatalog-BNxKdcN4.d.ts → diagnosticCatalog-BPYWNyJR.d.ts} +9 -3
  35. package/dist/eslint/index.cjs +4 -4
  36. package/dist/eslint/index.d.ts +12 -4
  37. package/dist/eslint/index.js +4 -4
  38. package/dist/index.cjs +18 -18
  39. package/dist/index.d.ts +8 -4
  40. package/dist/index.js +20 -20
  41. package/dist/nestjs/index.cjs +5 -5
  42. package/dist/nestjs/index.d.ts +3 -3
  43. package/dist/nestjs/index.js +5 -5
  44. package/dist/runtime/index.cjs +10 -10
  45. package/dist/runtime/index.d.ts +6 -6
  46. package/dist/runtime/index.js +10 -10
  47. package/dist/{types-BSzRy2X1.d.ts → types-Cs-46DRu.d.ts} +1 -1
  48. package/dist/{types-D5GT5ZT8.d.ts → types-p_fCp6P-.d.ts} +1 -1
  49. package/docs/README.md +1 -1
  50. package/docs/agent-guide.md +3 -1
  51. package/docs/ai-gates.md +7 -3
  52. package/docs/configuration.md +20 -5
  53. package/docs/develop.md +1 -2
  54. package/docs/package-surface.md +2 -2
  55. package/docs/use.md +1 -2
  56. package/package.json +3 -3
  57. package/schemas/ark.config.schema.json +6 -0
  58. package/server.json +2 -2
  59. package/templates/agent-skills/README.md +1 -1
  60. package/templates/agent-skills/ark-adopt/SKILL.md +15 -1
  61. package/templates/agent-skills/ark-autopilot/SKILL.md +2 -0
  62. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  63. package/templates/agent-skills/ark-explain/SKILL.md +1 -1
  64. package/templates/agent-skills/ark-explore/SKILL.md +13 -1
  65. package/templates/agent-skills/ark-place/SKILL.md +2 -0
  66. package/templates/agent-skills/ark-upgrade/SKILL.md +5 -3
  67. package/templates/hooks/opencode-ark-write-gate.mjs +2 -2
  68. package/templates/skills/ark-adopt.md +15 -1
  69. package/templates/skills/ark-autopilot.md +2 -0
  70. package/templates/skills/ark-coverage.md +1 -1
  71. package/templates/skills/ark-explain.md +1 -1
  72. package/templates/skills/ark-explore.md +13 -1
  73. package/templates/skills/ark-place.md +2 -0
  74. package/templates/skills/ark-upgrade.md +5 -3
@@ -201,7 +201,7 @@ const AGENT_CONTRACT = {
201
201
  ],
202
202
  // Cursor-only guidance: the write-time validate_code tool is available in
203
203
  // Cursor's runtime but has no equivalent in a plain AGENTS.md read.
204
- cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
204
+ cursorValidateStep: `The write gate is the host pre-hook when \`.cursor/hooks.json\` is installed, trusted, and \`failClosed\`. Call \`ark_prepare_write\` / \`validate_code\` only as fallback if that hook is missing or fail-open.`,
205
205
  };
206
206
 
207
207
  /**
@@ -413,7 +413,7 @@ Contener · Guiar · Ordenar — contain the write, guide the next step, order l
413
413
  1. Status anytime: \`${doctorCmd}\` — what's wrong and what to do first.
414
414
  2. Read the **Improvement compass** section (not a score). Name residual lenses in plain language when present (SoC, DIP, domain, …). Out-of-scope lenses (performance, app security tooling, full resilience) stay honest — do not invent Ark enforcement for them.
415
415
  3. CLI-first: if the local CLI already resolved this project root, do not wait on MCP “still connecting”. Identity handshake is optional in that case. When you do trust 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 this is not proven to be the right project: restart the host and use the local CLI meanwhile.
416
- 4. Day to day: call \`ark_manifest\` with the same project expectation (or skip if CLI already bound the root); place new files with \`ark_place\` (filePath required — fail-closed); validate after edits; run \`${checkCmd}\`. The \`ark://manifest\` resource is compatibility-only and always unverified. On a deny, fix the illegal import — do not weaken \`ark.config.json\`.
416
+ 4. Day to day: call \`ark_manifest\` with the same project expectation (or skip if CLI already bound the root); place new files with \`ark_place\` (filePath required — fail-closed); run \`${checkCmd}\`. The host pre-hook is the write gate when installed; MCP \`ark_prepare_write\` is fallback if that hook is missing or fail-open. The \`ark://manifest\` resource is compatibility-only and always unverified. On a deny, fix the illegal import — do not weaken \`ark.config.json\`.
417
417
  5. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`. The host-readable projection is AGENTS.md and CLAUDE.md (same schema).
418
418
 
419
419
  **Single door when residual remains:**
@@ -228,6 +228,7 @@ export const ARK_CONFIG_SCHEMA = {
228
228
  },
229
229
  },
230
230
  },
231
+ sharedImportsSlice: { type: 'string', enum: ['deny'] },
231
232
  },
232
233
  },
233
234
  safety: {
@@ -200,7 +200,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
200
200
  physicalCohesion,
201
201
  files,
202
202
  root,
203
- decisionMemory
203
+ decisionMemory,
204
+ { layers: config?.layers, rules: rules ?? config?.rules }
204
205
  );
205
206
  // Prefer architecture facts paths when available; still union the doctor walk
206
207
  // so an empty-catalog residual can see Domain files the facts subset missed.
@@ -147,6 +147,8 @@ export function printDoctorCompactHuman(view) {
147
147
  if (skippableCi) line(warn, skippableCi);
148
148
  else if (writePathHonesty?.nativeFailClosed === false) {
149
149
  line(warn, writePathHonesty.message || 'Write hook is fail-open — if the checker cannot run, the write still lands.');
150
+ } else if (listedMissing.length === 0 && writePath.gap?.id === 'write-path-mcp-only') {
151
+ line(warn, writePath.gap.message || 'Fell back to MCP prepare; required CI is the merge boundary.');
150
152
  } else if (listedMissing.length === 0 && (writePath.gap || writePathHonesty?.softWriteHost)) {
151
153
  line(warn, 'Local writes are advisory; required CI is the merge boundary.');
152
154
  }
@@ -9,6 +9,36 @@ import { mergePostGreenTopActions } from './post-green-path.mjs';
9
9
  import { ADOPTED_NOT, NOT_ADOPTED_NEXT_ACTION } from './adoption-stance.mjs';
10
10
  import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
11
11
 
12
+ /** Package pin may exist; local resolve still failed. Lead with install, not CI. */
13
+ export const PACKAGE_UNRESOLVED_NEXT_ACTION =
14
+ 'Install arkgate in this project first (npx --package=arkgate, or pnpm add -D arkgate -w / yarn add -D arkgate -W at a workspace root), then re-run --doctor';
15
+
16
+ export function packageUnresolvedNextAction(ctx) {
17
+ if (ctx?.packageInstalled !== false || ctx?.selfHost === true) return null;
18
+ const code = ctx?.packageVersionTruth?.code;
19
+ if (code === 'PACKAGE_PIN_SELF_HOST' || code === 'PACKAGE_PIN_ABSENT') return null;
20
+ // Failed start --apply: pin is in package.json, node_modules did not resolve.
21
+ if (code === 'PACKAGE_PIN_MATCHES' || code === 'PACKAGE_PIN_BEHIND_CLI' || ctx?.packagePinned === true) {
22
+ return PACKAGE_UNRESOLVED_NEXT_ACTION;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /** Compact doctor JSON #1 — same rank as collectDoctorNextActions when package is missing. */
28
+ export function preferredDoctorPrimaryNextAction({
29
+ adopted,
30
+ packageInstalled,
31
+ selfHost,
32
+ packageVersionTruth,
33
+ postGreenPath,
34
+ dualTruthNext,
35
+ } = {}) {
36
+ return (
37
+ packageUnresolvedNextAction({ packageInstalled, selfHost, packageVersionTruth }) ||
38
+ (adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext ?? null)
39
+ );
40
+ }
41
+
12
42
  function missingGateFiles(ctx) {
13
43
  const list = Array.isArray(ctx.gatesMissing) ? ctx.gatesMissing : [];
14
44
  if (ctx.ciNotFailClosed) {
@@ -119,6 +149,8 @@ export function collectDoctorNextActions(ctx) {
119
149
  if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
120
150
  actions.push(ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION);
121
151
  }
152
+ const resolvePkg = packageUnresolvedNextAction(ctx);
153
+ if (resolvePkg) actions.push(resolvePkg);
122
154
  const nudge = ctx.stewardNudge;
123
155
  if (
124
156
  nudge &&
@@ -259,6 +291,9 @@ export function collectDoctorNextActions(ctx) {
259
291
  if (ctx.designFitness.designWeak && unique.length === 0 && ctx.postGreenPath) {
260
292
  unique.push(ctx.postGreenPath.action);
261
293
  }
294
+ if (resolvePkg) {
295
+ return [resolvePkg, ...unique.filter((a) => a !== resolvePkg)];
296
+ }
262
297
  if (overlapAction) {
263
298
  return [overlapAction, ...unique.filter((a) => a !== overlapAction)];
264
299
  }
@@ -13,7 +13,7 @@ import * as arkShared from '../ark-shared.mjs';
13
13
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
14
14
  import { describePackageVersionDualTruth } from './field-install.mjs';
15
15
  import { detectAgentHomeGaps } from './agent-homes.mjs';
16
- import { collectDoctorNextActions } from './doctor-next-actions.mjs';
16
+ import { collectDoctorNextActions, preferredDoctorPrimaryNextAction } from './doctor-next-actions.mjs';
17
17
  import { printDoctorCompactHuman, printDoctorDetailsHuman } from './doctor-human.mjs';
18
18
  import { collectLayerOwnerResidual, layerGuidanceLine, placementDescriptionFields } from './layer-description.mjs';
19
19
  import { collectAdrPresenceResidual, printAdrPresenceHint } from './adr-presence.mjs';
@@ -59,6 +59,7 @@ import {
59
59
  import {
60
60
  buildCoverageHonesty,
61
61
  computeDoctorEnforcementHonesty,
62
+ physicalCohesionResidualRemains,
62
63
  } from './enforcement-honesty.mjs';
63
64
  import {
64
65
  computePureLayerOptInNudge,
@@ -653,6 +654,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
653
654
  const statusTransitionCatalog = collectStatusTransitionCatalogResidual({ root, config, files, statesTransitions });
654
655
  const noDomainFrontend = collectNoDomainFrontendResidual({ config, coverage: cov, designSmells });
655
656
  const { invariantCoverageRoots, invariantTestsPath } = collectInvariantCoverageResiduals({ adopted: isAdopted(adopted) || options.requireGates === true, coverage: config?.coverage, config, root });
657
+ const selfHost = packageVersionTruth?.selfHost === true || packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST';
656
658
  const { coverageHonesty, baselineHonesty, writePathHonesty, productHonesty } =
657
659
  computeDoctorEnforcementHonesty({
658
660
  governedPercent: cov.governed.percent,
@@ -681,13 +683,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
681
683
  : rulesUnderContract?.active === true || arkRun?.active === true || doctorAdvisories.arkOrder?.active === true
682
684
  ? { active: true, extraMergeTeeth: arkRun?.extraMergeTeeth === true || doctorAdvisories.arkOrder?.extraMergeTeeth === true }
683
685
  : null,
684
- primaryNextAction:
685
- adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext,
686
+ primaryNextAction: preferredDoctorPrimaryNextAction({ adopted, packageInstalled, selfHost, packageVersionTruth, postGreenPath, dualTruthNext }),
686
687
  operatingMode,
687
688
  packageInstalled,
688
- selfHost:
689
- packageVersionTruth?.selfHost === true ||
690
- packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
689
+ selfHost,
691
690
  nativeFailClosed: writePath.nativeFailClosed,
692
691
  nativeFailClosedPolicy: writePath.nativeFailClosedPolicy,
693
692
  adopted,
@@ -695,6 +694,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
695
694
  github: githubForBoundary,
696
695
  adoptionStance: stanceFile,
697
696
  stewardNudge: doctorAdvisories.stewardNudge,
697
+ physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion),
698
698
  });
699
699
 
700
700
  // Improvement compass: projection only — never feeds ok/valid/goal.met.
@@ -752,7 +752,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
752
752
  adoptionStance: adopted,
753
753
  ...(adopted === 'not-adopted'
754
754
  ? {
755
- primaryNextAction: NOT_ADOPTED_NEXT_ACTION,
755
+ primaryNextAction: productHonesty.primaryNextAction || NOT_ADOPTED_NEXT_ACTION,
756
756
  ...(postGreenPath ? DESIGN_WEAK_HONESTY_FLAGS : {}),
757
757
  }
758
758
  : postGreenPath
@@ -908,7 +908,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
908
908
  coverageHonesty,
909
909
  cov,
910
910
  packageVersionTruth,
911
- dualTruthNext,
911
+ dualTruthNext, packageInstalled, selfHost,
912
912
  activeCount,
913
913
  writePath,
914
914
  gatesMissing,
@@ -288,14 +288,26 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
288
288
  * primaryNextAction?: string | null,
289
289
  * operatingMode?: string | null,
290
290
  * activeBlockingViolations?: number | null,
291
+ * physicalCohesionResidual?: boolean,
291
292
  * }} input
292
293
  */
294
+ export function physicalCohesionResidualRemains(cohesion) {
295
+ const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
296
+ if (findings.length === 0) return false;
297
+ const covered = new Set();
298
+ for (const row of cohesion?.reshapeDecisions?.current ?? []) {
299
+ if (row?.suppressesPilot === true && typeof row.concept === 'string') covered.add(row.concept);
300
+ }
301
+ return findings.some((finding) => !covered.has(finding?.concept));
302
+ }
303
+
293
304
  export function buildProductHonesty(input = {}) {
294
305
  const reasons = [];
295
306
  const cov = input.coverageHonesty;
296
307
  const base = input.baselineHonesty;
297
308
  const write = input.writePathHonesty;
298
309
  const designWeak = input.designWeak === true;
310
+ const cohesionResidual = input.physicalCohesionResidual === true;
299
311
  const dualTruth = input.packageVersionTruth?.dualTruth === true;
300
312
  const pinCode = input.packageVersionTruth?.code || write?.packagePinCode || null;
301
313
  const pinAbsent =
@@ -365,6 +377,14 @@ export function buildProductHonesty(input = {}) {
365
377
  });
366
378
  }
367
379
 
380
+ if (cohesionResidual) {
381
+ reasons.push({
382
+ id: 'physical-cohesion-residual',
383
+ message:
384
+ 'Physical cohesion residual remains — the gate verdict and exit code are unchanged, and the reshape card is one judgment, not a queue to empty.',
385
+ });
386
+ }
387
+
368
388
  if (dualTruth) {
369
389
  reasons.push({
370
390
  id: 'package-version-dual-truth',
@@ -551,6 +571,12 @@ export function buildProductHonesty(input = {}) {
551
571
  headline = 'Advisory-only adoption — merge status is not required';
552
572
  } else if (!unfinished) {
553
573
  headline = 'Honesty clear on residual signals';
574
+ } else if (
575
+ wholeTreeGoverned &&
576
+ architectureReasons.length === 1 &&
577
+ architectureReasons[0].id === 'physical-cohesion-residual'
578
+ ) {
579
+ headline = 'Residual remaining';
554
580
  } else if (coverageIncomplete) {
555
581
  headline = 'Not finished / not whole-tree guarantee';
556
582
  } else {
@@ -592,8 +618,8 @@ export function buildProductHonesty(input = {}) {
592
618
  : 'unknown';
593
619
 
594
620
  return {
595
- finished: !unfinished && wholeTreeGoverned && !designWeak && activeBlocking === 0,
596
- elegant: !designWeak && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
621
+ finished: !unfinished && wholeTreeGoverned && !designWeak && !cohesionResidual && activeBlocking === 0,
622
+ elegant: !designWeak && !cohesionResidual && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
597
623
  wholeTreeGuarantee:
598
624
  wholeTreeGoverned &&
599
625
  !designWeak &&
@@ -656,6 +682,7 @@ export function computeDoctorEnforcementHonesty({
656
682
  stewardNudge,
657
683
  nativeFailClosed,
658
684
  nativeFailClosedPolicy,
685
+ physicalCohesionResidual,
659
686
  } = {}) {
660
687
  const coverageHonesty = buildCoverageHonesty({
661
688
  percent: governedPercent,
@@ -713,6 +740,7 @@ export function computeDoctorEnforcementHonesty({
713
740
  considerMergeBoundary: true,
714
741
  emptyStewards,
715
742
  stewardNudge,
743
+ physicalCohesionResidual,
716
744
  });
717
745
  return {
718
746
  coverageHonesty,
@@ -152,9 +152,30 @@ export function isCompactRouterAgentsContent(text) {
152
152
  return typeof text === 'string' && COMPACT_ROUTER.test(text);
153
153
  }
154
154
 
155
+ function hasCursorFailClosedHook(root) {
156
+ try {
157
+ const parsed = readJson(path.join(root, '.cursor', 'hooks.json'));
158
+ const entries = Array.isArray(parsed?.hooks?.preToolUse) ? parsed.hooks.preToolUse : [];
159
+ return entries.some(
160
+ (entry) =>
161
+ entry &&
162
+ typeof entry === 'object' &&
163
+ entry.failClosed === true &&
164
+ typeof entry.command === 'string' &&
165
+ /(?:^|\s)--hook(?:\s|$)/.test(entry.command)
166
+ );
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function hasCursorCompactRegistration(root) {
173
+ return hasArkMcpRegistration(root, '.cursor/mcp.json') && hasCursorFailClosedHook(root);
174
+ }
175
+
155
176
  function hasCompactHostRegistration(root, host) {
156
177
  if (host === 'none') return hasArkMcpRegistration(root);
157
- if (host === 'cursor') return hasArkMcpRegistration(root, '.cursor/mcp.json');
178
+ if (host === 'cursor') return hasCursorCompactRegistration(root);
158
179
  if (host === 'codex') return hasCodexCompactRegistration(root);
159
180
  const files = COMPACT_HOST_FILES[host];
160
181
  return Boolean(files) && files.every((relativePath) => fs.existsSync(path.join(root, relativePath)));
@@ -197,7 +197,7 @@ Codex hard write covers only a complete local \`apply_patch\`; Cursor covers onl
197
197
  installed + trusted, while shell/direct
198
198
  filesystem writes, hosted or specialized opt-out paths, and human edits still rely on CI.
199
199
 
200
- This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair **envelopes** may be emitted without reinjection being guaranteed; silent auto-apply never happens. Run \`arkgate-check --doctor\` (or \`ark-check --doctor\`) for the evidence actually detected in the current repository.`;
200
+ This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. **Write-gate order:** install and trust the host pre-hook first. MCP prepare is fallback when that path is missing or fail-open. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair **envelopes** may be emitted without reinjection being guaranteed; silent auto-apply never happens. Run \`arkgate-check --doctor\` (or \`ark-check --doctor\`) for the evidence actually detected in the current repository.`;
201
201
  }
202
202
 
203
203
  /**
@@ -17,6 +17,7 @@ import {
17
17
  buildBaselineHonesty,
18
18
  buildWritePathHonesty,
19
19
  buildProductHonesty,
20
+ physicalCohesionResidualRemains,
20
21
  } from './enforcement-honesty.mjs';
21
22
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
23
  import { attachExtraDoctorSections } from './doctor-advisories.mjs';
@@ -25,6 +26,7 @@ import { describePackageVersionDualTruth } from './field-install.mjs';
25
26
  import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
26
27
  import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
27
28
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
29
+ import { computeReshapeDecisionMemory } from './reshape-decisions.mjs';
28
30
  import { POST_GREEN_LEDE, operatingModeTitle } from './product-copy.mjs';
29
31
 
30
32
  function esc(value) {
@@ -153,6 +155,8 @@ export function buildReportDepthPayload(
153
155
  : packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
154
156
  ? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
155
157
  : null;
158
+ const physicalCohesion = computePhysicalCohesion(root, files);
159
+ physicalCohesion.reshapeDecisions = computeReshapeDecisionMemory(root, files).summary;
156
160
  const productHonesty = buildProductHonesty({
157
161
  coverageHonesty,
158
162
  baselineHonesty,
@@ -169,9 +173,9 @@ export function buildReportDepthPayload(
169
173
  : null,
170
174
  primaryNextAction: postGreenPath?.action ?? dualTruthNext,
171
175
  activeBlockingViolations: activeBlockingCount,
176
+ physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion),
172
177
  });
173
178
  // Doctor parity: same physical-cohesion + baseline stale facts as runDoctor.
174
- const physicalCohesion = computePhysicalCohesion(root, files);
175
179
  const baselineStale =
176
180
  typeof baselineSplit.baselineStale === 'number' ? baselineSplit.baselineStale : null;
177
181
  // Improvement compass — same projection as doctor; notAScore; never a gate input.
@@ -224,7 +228,7 @@ export function writePathModeHint(mode) {
224
228
  case 'reject-only':
225
229
  return 'Hard write boundary without repair payload; edits can be blocked without guided re-entry.';
226
230
  case 'mcp-only':
227
- return 'Advisory MCP only — prepare-write/autoPatch available, no hard PreToolUse for this host.';
231
+ return 'Fell back to MCP prepare — advisory prepare-write/autoPatch; no hard PreToolUse for this host.';
228
232
  case 'none':
229
233
  return 'No hard write boundary or advisory MCP for the active host (or host is unknown in this process).';
230
234
  default:
@@ -200,10 +200,9 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
200
200
  })
201
201
  );
202
202
  if (selectedTools.has('cursor')) {
203
- add('.cursor/mcp.json', mcpJson(root));
204
- // whole-file for managed upgrade/manifest (json-merge is install-time only via
205
- // mergeCursorArkHook below — same pattern as Antigravity hooks).
203
+ // Write-gate order: pre-hook first, MCP prepare as fallback + identity.
206
204
  add('.cursor/hooks.json', cursorHooks(root));
205
+ add('.cursor/mcp.json', mcpJson(root));
207
206
  if (!compact) add('.cursor/rules/ark.mdc', cursorRule(root));
208
207
  }
209
208
  if (selectedTools.has('claude')) add('.claude/settings.json', claudeSettings(root));
@@ -213,8 +212,8 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
213
212
  if (!compact) add('docs/ark-codex-config.toml', codexTomlSnippet(root));
214
213
  }
215
214
  if (selectedTools.has('grok')) {
216
- add('.grok/config.toml', grokProjectConfig(root));
217
215
  add('.grok/hooks/ark-write-gate.json', grokHooks(root));
216
+ add('.grok/config.toml', grokProjectConfig(root));
218
217
  }
219
218
  if (selectedTools.has('antigravity')) {
220
219
  add('.agents/hooks.json', antigravityHooks(root));
@@ -19,7 +19,7 @@ export const INVARIANT_COVERAGE_ROOTS_MESSAGE = 'A domain invariant is enforced,
19
19
  * `omitBudget` drops the budget clause and the load totals for messages whose
20
20
  * own text already carries them — the same number twice reads as two facts.
21
21
  */
22
- function formatCoverageDiscards(stats, omitBudget = false) {
22
+ export function formatCoverageDiscards(stats, omitBudget = false) {
23
23
  if (!stats)
24
24
  return '';
25
25
  const d = stats.discarded;
@@ -62,22 +62,50 @@ function titleMatchesInvariant(content, id) {
62
62
  const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
63
63
  return re.test(content) || content.includes(id);
64
64
  }
65
- function symbolPresent(fileContents, symbol) {
65
+ function escapeRegExp(value) {
66
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+ /**
69
+ * True when `name` is declared here. Imports and calls do not count.
70
+ * `export const name =` is the same binding shape sensors already treat as a
71
+ * declaration (arrow or function). The last segment of `Aggregate.method` is
72
+ * the name that must be declared.
73
+ */
74
+ function declaresIdentifier(content, name) {
75
+ const n = escapeRegExp(name);
76
+ const functionDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:declare\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*[(<]`);
77
+ const classDecl = new RegExp(`(?:^|[\\s;{}])(?:export\\s+(?:default\\s+)?)?(?:abstract\\s+)?class\\s+${n}\\b`);
78
+ const constDecl = new RegExp(`(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}\\s*=`);
79
+ const methodDecl = new RegExp(`(?:^|[\\n;{}])\\s*(?:(?:public|private|protected|static|async|readonly|override|abstract|get|set|declare)\\s+)*${n}\\s*(?:<[^>\\n]*>)?\\s*\\([^;{}]*\\)\\s*(?::\\s*[^;{]+)?\\s*\\{`);
80
+ return (functionDecl.test(content) ||
81
+ classDecl.test(content) ||
82
+ constDecl.test(content) ||
83
+ methodDecl.test(content));
84
+ }
85
+ /** Witness path, or undefined when no non-test file declares `symbol`. */
86
+ function symbolPresent(fileContents, testFiles, symbol) {
66
87
  if (!symbol)
67
- return false;
68
- // Support Aggregate.method or bare method name.
88
+ return undefined;
69
89
  const parts = symbol.split('.');
70
- const needle = parts[parts.length - 1];
90
+ const needle = parts[parts.length - 1] ?? '';
91
+ if (!needle)
92
+ return undefined;
71
93
  const className = parts.length > 1 ? parts[0] : null;
72
- for (const content of Object.values(fileContents)) {
94
+ const tests = new Set(testFiles.map((file) => file.replace(/\\/g, '/').replace(/^\.\//, '')));
95
+ const files = Object.keys(fileContents).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
96
+ for (const file of files) {
97
+ const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
98
+ if (tests.has(normalized))
99
+ continue;
100
+ const content = fileContents[file];
101
+ if (!content)
102
+ continue;
73
103
  if (className && !content.includes(className))
74
104
  continue;
75
- if (new RegExp(`(?:function\\s+|\\b)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[(<]`).test(content) ||
76
- content.includes(symbol)) {
77
- return true;
78
- }
105
+ if (declaresIdentifier(content, needle))
106
+ return file;
79
107
  }
80
- return false;
108
+ return undefined;
81
109
  }
82
110
  export function evaluateInvariantCoverage(input) {
83
111
  const invariants = input.arkRules.invariants ?? [];
@@ -131,9 +159,11 @@ export function evaluateInvariantCoverage(input) {
131
159
  if (testEvidenceFile !== undefined)
132
160
  evidence.push('test-title');
133
161
  }
134
- if (symbol && symbolPresent(input.fileContents, symbol)) {
162
+ const symbolEvidenceFile = symbol
163
+ ? symbolPresent(input.fileContents, testFiles, symbol)
164
+ : undefined;
165
+ if (symbolEvidenceFile)
135
166
  evidence.push('symbol');
136
- }
137
167
  // Covered if any requested evidence is present.
138
168
  // When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
139
169
  const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
@@ -154,6 +184,7 @@ export function evaluateInvariantCoverage(input) {
154
184
  partial,
155
185
  description: inv.description,
156
186
  ...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
187
+ ...(symbolEvidenceFile !== undefined ? { symbolEvidenceFile } : {}),
157
188
  ...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
158
189
  coverageRootsDeclared: rootsDeclared,
159
190
  });
@@ -17,6 +17,7 @@ import {
17
17
  arkPackageVersion,
18
18
  detectActiveAgentHost,
19
19
  normalizeToolsList,
20
+ planSkillInstall,
20
21
  skillContentIdentity,
21
22
  } from './skill-install.mjs';
22
23
 
@@ -396,8 +397,11 @@ function summaryFor(assets, manifestChanged) {
396
397
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
397
398
  const applying = assets.filter((asset) => asset.willApply);
398
399
  const wouldWrite = applying.length;
399
- // Public-summary compatibility: stamp-only writes are no longer scheduled.
400
- const metadataRefresh = 0;
400
+ // Same-body stamp drift (arkVersion / description prefix) stays state=current
401
+ // and is scheduled via planSkillInstall stamp-refresh — not a second pipeline.
402
+ const metadataRefresh = applying.filter(
403
+ (asset) => asset.state === 'current' && asset.reason === 'stamp-refresh'
404
+ ).length;
401
405
  const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
402
406
  const fileChanges = applying.length;
403
407
  return {
@@ -477,9 +481,23 @@ export function planManagedUpgrade(root, options = {}) {
477
481
  refreshSkills &&
478
482
  catalogAsset.kind === 'skill' &&
479
483
  classified.state === 'customized';
484
+ // HS01 / #284: identity-current skill whose arkVersion / description prefix
485
+ // lagged the package — reuse planSkillInstall stamp-refresh, do not reclassify.
486
+ const stampRefresh =
487
+ !unparsedScope &&
488
+ catalogAsset.kind === 'skill' &&
489
+ classified.state === 'current' &&
490
+ currentScoped != null &&
491
+ planSkillInstall({
492
+ existingContent: currentScoped,
493
+ targetContent: desiredScoped,
494
+ packageVersion: catalog.version ?? arkPackageVersion(),
495
+ scope: 'repo',
496
+ }).reason === 'stamp-refresh';
480
497
  const canApply =
481
498
  classified.state === 'stale' ||
482
499
  skillRefresh ||
500
+ stampRefresh ||
483
501
  (classified.state === 'missing' && (!recorded || accepted)) ||
484
502
  (classified.state === 'conflicted' && accepted);
485
503
  const blocked = classified.requiresConsent && !accepted && !skillRefresh;
@@ -490,7 +508,11 @@ export function planManagedUpgrade(root, options = {}) {
490
508
  kind: catalogAsset.kind,
491
509
  scope: catalogAsset.scope,
492
510
  ...classified,
493
- ...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
511
+ ...(unparsedScope
512
+ ? { reason: 'unparsed managed TOML scope preserved' }
513
+ : stampRefresh
514
+ ? { reason: 'stamp-refresh' }
515
+ : {}),
494
516
  action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
495
517
  willApply: canApply,
496
518
  blocked,
@@ -970,8 +992,11 @@ export function renderManagedUpgrade(plan, options = {}) {
970
992
  const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
971
993
  const blocked = summary.blocked ?? 0;
972
994
  console.log(
973
- `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
974
- `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
995
+ `Managed assets: ${managedAssets}; would write: ${wouldWrite}` +
996
+ (summary.metadataRefresh
997
+ ? `; metadata refresh: ${summary.metadataRefresh}`
998
+ : '') +
999
+ `; customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
975
1000
  );
976
1001
  const honesty =
977
1002
  plan.selfService ??
@@ -191,7 +191,10 @@ export function collectAdoptionGaps(root, config, coverage) {
191
191
  host: 'cursor',
192
192
  dir: '.cursor',
193
193
  skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
194
- extras: [['.cursor/mcp.json', 'MCP config']],
194
+ extras: [
195
+ ['.cursor/hooks.json', 'write-gate hook'],
196
+ ['.cursor/mcp.json', 'MCP config (fallback)'],
197
+ ],
195
198
  toolsFlag: 'cursor',
196
199
  },
197
200
  {
@@ -94,9 +94,12 @@ export function arkCommand(root, bin, argsStr = '') {
94
94
  /**
95
95
  * Run an Ark binary from package `arkgate` when local bins may be missing.
96
96
  * Bare `npx arkgate-check` 404s — npx treats that bin name as its own package.
97
+ * Pass `packageSpec` (e.g. `arkgate@4.8.19`) when Age blocked a local add.
97
98
  */
98
- export function arkPackageRecoveryCommand(bin, argsStr = '') {
99
- return `npx --package=arkgate ${bin}${argsStr ? ` ${argsStr}` : ''}`;
99
+ export function arkPackageRecoveryCommand(bin, argsStr = '', packageSpec) {
100
+ const spec =
101
+ typeof packageSpec === 'string' && packageSpec.trim() ? packageSpec.trim() : 'arkgate';
102
+ return `npx --package=${spec} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
100
103
  }
101
104
 
102
105
  /**