arkgate 4.0.0 → 4.1.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 (57) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/graph-blind.mjs +1 -1
  20. package/bin/lib/html-report-advisories.mjs +8 -25
  21. package/bin/lib/html-report-depth.mjs +167 -3
  22. package/bin/lib/html-report.mjs +12 -5
  23. package/bin/lib/install-migrate.mjs +109 -6
  24. package/bin/lib/managed-upgrade.mjs +100 -1
  25. package/bin/lib/presets.mjs +314 -46
  26. package/bin/lib/project-root.mjs +268 -0
  27. package/bin/lib/remediation.mjs +12 -11
  28. package/bin/lib/rules-inventory.mjs +71 -29
  29. package/bin/lib/rules-under-contract.mjs +389 -5
  30. package/bin/lib/start-preview.mjs +48 -14
  31. package/bin/lib/suggestions.mjs +118 -3
  32. package/bin/lib/unavailable-analysis.mjs +2 -0
  33. package/bin/lib/upgrade-command.mjs +325 -14
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/dist/eslint/index.cjs +2 -2
  36. package/dist/eslint/index.d.ts +27 -2
  37. package/dist/eslint/index.js +2 -2
  38. package/dist/index.cjs +16 -14
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +16 -14
  41. package/docs/README.md +3 -2
  42. package/docs/ai-gates.md +15 -11
  43. package/docs/brownfield-adoption.md +38 -0
  44. package/docs/configuration.md +59 -7
  45. package/docs/package-surface.md +3 -2
  46. package/docs/product-voice.md +10 -1
  47. package/docs/typescript-support.md +9 -5
  48. package/docs/use.md +7 -5
  49. package/package.json +3 -1
  50. package/server.json +3 -3
  51. package/templates/architecture-playbook.json +3 -0
  52. package/templates/layers/shared-types.starter.json +29 -0
  53. package/templates/skills/ark-adopt.md +2 -0
  54. package/templates/skills/ark-explain.md +23 -5
  55. package/templates/skills/ark-explore.md +21 -1
  56. package/templates/skills/ark-fix.md +16 -5
  57. package/templates/skills/ark-upgrade.md +57 -11
@@ -12,6 +12,15 @@ import { summarizePilotLoop } from './pilot-loop.mjs';
12
12
  import { buildPostGreenNextAction } from './post-green-path.mjs';
13
13
  import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
14
14
  import { collectAdoptionGaps } from './mcp-adoption.mjs';
15
+ import {
16
+ buildCoverageHonesty,
17
+ buildBaselineHonesty,
18
+ buildWritePathHonesty,
19
+ buildProductHonesty,
20
+ } from './enforcement-honesty.mjs';
21
+ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
+ import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
23
+ import { describePackageVersionDualTruth } from './field-install.mjs';
15
24
 
16
25
  function esc(value) {
17
26
  return String(value)
@@ -27,12 +36,33 @@ function esc(value) {
27
36
  * @param {object} config
28
37
  * @param {string[]} files
29
38
  * @param {object} coverage
30
- * @param {object[]} activeViolations
39
+ * @param {object[]} activeViolations already baseline-filtered active findings
40
+ * @param {{
41
+ * suppressedCount?: number,
42
+ * totalViolationCount?: number,
43
+ * frozenKeys?: number,
44
+ * activeCount?: number,
45
+ * activeBlockingCount?: number,
46
+ * }} [baselineSplit] same numbers doctor uses (do not recompute from active-only list)
31
47
  */
32
- export function buildReportDepthPayload(root, config, files, coverage, activeViolations = []) {
48
+ export function buildReportDepthPayload(
49
+ root,
50
+ config,
51
+ files,
52
+ coverage,
53
+ activeViolations = [],
54
+ baselineSplit = {}
55
+ ) {
56
+ // Blocking = failsStrict !== false only (type-only placement debt is non-blocking).
57
+ // Prefer caller-supplied count for doctor parity; else derive from active list.
58
+ const activeBlockingCount =
59
+ typeof baselineSplit.activeBlockingCount === 'number'
60
+ ? baselineSplit.activeBlockingCount
61
+ : activeViolations.filter((v) => v?.failsStrict !== false).length;
33
62
  const designSmells = detectDesignSmells(root, config, files, coverage);
34
63
  const designFitness = summarizeDesignFitness(designSmells, {
35
- activeViolations: activeViolations.length,
64
+ // Doctor parity: fitness uses blocking count, not raw active (incl. type-only).
65
+ activeViolations: activeBlockingCount,
36
66
  governedPercent: coverage?.governed?.percent,
37
67
  totalFiles: coverage?.governed?.totalFiles,
38
68
  });
@@ -45,6 +75,86 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
45
75
  });
46
76
  const goldenPattern = summarizeGoldenPattern(loadGoldenPattern(root));
47
77
  const adoption = collectAdoptionGaps(root, config, coverage);
78
+ const baseline = readBaseline(root, '.ark-baseline.json');
79
+ // Prefer caller-supplied baseline split (doctor parity). Recomputing suppressed from an
80
+ // already-filtered activeViolations list always yields 0 and hides dirty-freeze.
81
+ const suppressed =
82
+ typeof baselineSplit.suppressedCount === 'number'
83
+ ? baselineSplit.suppressedCount
84
+ : baseline.exists
85
+ ? baselineOccurrenceKeys(activeViolations).filter((key) => baseline.keys.has(key)).length
86
+ : 0;
87
+ const frozenKeys =
88
+ typeof baselineSplit.frozenKeys === 'number'
89
+ ? baselineSplit.frozenKeys
90
+ : baseline.exists
91
+ ? baseline.keys.size
92
+ : 0;
93
+ const activeCount =
94
+ typeof baselineSplit.activeCount === 'number'
95
+ ? baselineSplit.activeCount
96
+ : Math.max(0, activeViolations.length - suppressed);
97
+ const totalViolations =
98
+ typeof baselineSplit.totalViolationCount === 'number'
99
+ ? baselineSplit.totalViolationCount
100
+ : activeViolations.length + suppressed;
101
+ const coverageHonesty = buildCoverageHonesty({
102
+ percent: coverage?.governed?.percent,
103
+ totalFiles: coverage?.governed?.totalFiles,
104
+ emptyScope: coverage?.emptyScope === true || (coverage?.governed?.totalFiles ?? 0) === 0,
105
+ });
106
+ const baselineHonesty = buildBaselineHonesty({
107
+ exists: baseline.exists || frozenKeys > 0,
108
+ frozenKeys,
109
+ activeViolations: activeCount,
110
+ suppressed,
111
+ totalViolations,
112
+ });
113
+ const writePath = adoption.writePath;
114
+ const packageVersionTruth = describePackageVersionDualTruth(root);
115
+ const hardWriteActive = writePath?.enforcementState?.localWrite?.hard === true;
116
+ const packageInstalled = writePath?.enforcementState?.localWrite?.installed === true;
117
+ const writePathHonesty = buildWritePathHonesty(writePath?.activeHost, hardWriteActive, {
118
+ packageInstalled,
119
+ packagePinCode: packageVersionTruth?.code,
120
+ packagePinAbsent: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT',
121
+ selfHost:
122
+ packageVersionTruth?.selfHost === true ||
123
+ packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
124
+ });
125
+ const rulesUnderContract = summarizeRulesUnderContract(root, config, undefined, {
126
+ governedPercent: coverage?.governed?.percent ?? null,
127
+ populatedLayerCount: Array.isArray(coverage?.layers)
128
+ ? coverage.layers.filter((row) => (row?.files ?? 0) > 0).length
129
+ : null,
130
+ classifiedFiles: coverage?.governed?.classifiedFiles ?? null,
131
+ });
132
+ // Single residual expression (parity with doctor): nextPilot || extractionCard.
133
+ const residualPilot =
134
+ pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
135
+ const dualTruthNext =
136
+ packageVersionTruth?.dualTruth === true
137
+ ? `Bump package.json arkgate pin to ${packageVersionTruth.cliVersion || 'this CLI'} (or re-run install without --no-install)`
138
+ : packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
139
+ ? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
140
+ : null;
141
+ const productHonesty = buildProductHonesty({
142
+ coverageHonesty,
143
+ baselineHonesty,
144
+ writePathHonesty,
145
+ designWeak: designFitness.designWeak === true,
146
+ designWeakLabel: designFitness.label,
147
+ designSmellCount: designSmells.length,
148
+ designSmellsWithOpenEdges: designSmells.length > 0 && activeBlockingCount > 0,
149
+ packageVersionTruth,
150
+ residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
151
+ pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
152
+ arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
153
+ ? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
154
+ : null,
155
+ primaryNextAction: postGreenPath?.action ?? dualTruthNext,
156
+ activeBlockingViolations: activeBlockingCount,
157
+ });
48
158
  return {
49
159
  adoption,
50
160
  designDepth: {
@@ -53,6 +163,9 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
53
163
  pilotLoop,
54
164
  postGreenPath,
55
165
  goldenPattern,
166
+ // P0-B / P1-M — folded into designDepth so --report stays a single payload.
167
+ productHonesty,
168
+ mergePlanes: rulesUnderContract?.mergePlanes ?? null,
56
169
  },
57
170
  };
58
171
  }
@@ -167,6 +280,57 @@ function baselineLegendBody(signal) {
167
280
  * mode?: string,
168
281
  * }} depth
169
282
  */
283
+ /**
284
+ * P0-B — prominent anti-false-green honesty card (never a score).
285
+ * @param {object|null|undefined} productHonesty
286
+ * @param {object|null|undefined} [mergePlanes]
287
+ */
288
+ export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
289
+ if (!productHonesty || typeof productHonesty !== 'object') return '';
290
+ const unfinished = productHonesty.unfinished === true;
291
+ const headline = productHonesty.headline || (unfinished ? 'Not finished' : 'Honesty clear');
292
+ const primary = productHonesty.primaryMessage || '';
293
+ // Avoid repeating the same status label in title and body (past-issue pattern).
294
+ let body = primary;
295
+ if (primary && headline) {
296
+ const h = String(headline).trim();
297
+ const p = String(primary).trim();
298
+ if (p === h) {
299
+ body = unfinished
300
+ ? 'Residual honesty signals remain — not a whole-tree guarantee and not a score.'
301
+ : 'No residual honesty blockers on this slice — still not a numeric architecture score.';
302
+ } else if (p.toLowerCase().startsWith(h.toLowerCase())) {
303
+ const stripped = p.slice(h.length).replace(/^[\s—–:-]+/, '').trim();
304
+ body = stripped || p;
305
+ }
306
+ }
307
+ const reasons = Array.isArray(productHonesty.reasonIds) ? productHonesty.reasonIds : [];
308
+ const reasonHtml =
309
+ reasons.length > 0
310
+ ? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">signals: ${reasons
311
+ .map((id) => `<code>${esc(id)}</code>`)
312
+ .join(' · ')} · <code>notAScore</code></p>`
313
+ : '';
314
+ const mergeHtml =
315
+ mergePlanes?.failMergeWhen
316
+ ? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">merge planes: ${esc(mergePlanes.failMergeWhen)}</p>`
317
+ : '';
318
+ const dual =
319
+ mergePlanes?.dualPlaneStamp
320
+ ? `<p class="dim" style="margin:.25rem 0 0;font-size:.84rem">${esc(mergePlanes.dualPlaneStamp)}</p>`
321
+ : '';
322
+ return `<div class="section card design-strip ${unfinished ? 'is-weak' : 'is-clean'}" id="product-honesty" data-product-honesty="1">
323
+ <div class="design-head">
324
+ <span class="badge design" title="Product honesty — not a score">${esc(headline)}</span>
325
+ <span class="dim" style="font-size:.86rem">${unfinished ? 'residual honesty signals' : 'no residual honesty blockers'}</span>
326
+ </div>
327
+ <p style="margin:.45rem 0 0">${esc(body)}</p>
328
+ ${reasonHtml}
329
+ ${mergeHtml}
330
+ ${dual}
331
+ </div>`;
332
+ }
333
+
170
334
  export function renderDesignDepthStrip(depth = {}) {
171
335
  const fitness = depth.designFitness;
172
336
  const smells = Array.isArray(depth.designSmells) ? depth.designSmells : [];
@@ -16,6 +16,7 @@ import {
16
16
  renderBaselineSignalLegend,
17
17
  renderDesignCleanNote,
18
18
  renderDesignDepthStrip,
19
+ renderProductHonestyCard,
19
20
  renderWritePathAdoptionBlock,
20
21
  } from './html-report-depth.mjs';
21
22
  import { FIX_HINTS } from './violations.mjs';
@@ -222,13 +223,13 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
222
223
  const row = (coverage?.layers ?? []).find((r) => r.name === layer.name);
223
224
  return (row?.files ?? 0) > 0;
224
225
  }).length;
226
+ // planMet: blocking (failsStrict !== false) only — type-only alone must not force ADAPT.
227
+ const blockingCount = Array.isArray(violations)
228
+ ? violations.filter((v) => v?.failsStrict !== false).length
229
+ : 0;
225
230
  const mode = resolveOperatingMode({
226
231
  governedPercent: totalFiles === 0 ? 0 : governedPercent,
227
- planMet:
228
- ok &&
229
- (violations?.length ?? 0) === 0 &&
230
- totalFiles > 0 &&
231
- (governedPercent == null || governedPercent >= 50),
232
+ planMet: ok && blockingCount === 0 && totalFiles > 0 && (governedPercent == null || governedPercent >= 50),
232
233
  mature: totalFiles >= 150,
233
234
  totalFiles,
234
235
  emptyLayers,
@@ -531,6 +532,11 @@ export function renderHtmlReport({
531
532
  ok,
532
533
  mode,
533
534
  });
535
+ // P0-B product honesty card — prefer payload from buildReportDepthPayload when present.
536
+ const productHonestyHtml = renderProductHonestyCard(
537
+ depth.productHonesty ?? null,
538
+ depth.mergePlanes ?? null
539
+ );
534
540
  const writePathHtml = renderWritePathAdoptionBlock(adoptionView.writePath);
535
541
  const baselineLegendHtml = renderBaselineSignalLegend();
536
542
 
@@ -1111,6 +1117,7 @@ export function renderHtmlReport({
1111
1117
  </div>
1112
1118
 
1113
1119
  ${designStripHtml}
1120
+ ${productHonestyHtml}
1114
1121
 
1115
1122
  <div class="section card" id="adoption">
1116
1123
  <h2>Adoption</h2>
@@ -49,7 +49,15 @@ import {
49
49
  codexTomlSnippet,
50
50
  arkCheckCommand,
51
51
  checkArchitectureScriptSnippet,
52
+ ensureCheckArchitectureScript,
52
53
  } from './ci-and-commands.mjs';
54
+ // Break cycle with managed-upgrade (it imports buildManagedAssetCatalog from here).
55
+ // Static import would form CIRCULAR_DEPENDENCY under mother --strict.
56
+ import { createRequire } from 'node:module';
57
+ const requireFromHere = createRequire(import.meta.url);
58
+ function loadManagedUpgrade() {
59
+ return requireFromHere('./managed-upgrade.mjs');
60
+ }
53
61
  import {
54
62
  resolveTools,
55
63
  SKILL_TOOL_TARGETS,
@@ -351,6 +359,28 @@ export function runInstallAgentGates(args) {
351
359
  return;
352
360
  }
353
361
  const hasCheckScript = hasCheckArchitectureScript(root);
362
+ // S4.2: when a managed-upgrade manifest is already present, a bare
363
+ // --install-agent-gates (no --force / --compact) defaults to skills-only so a
364
+ // refresh cannot clobber customized gates or invalidate upgrade planDigest.
365
+ // Missing AGENTS still gets a full install (gates need repair).
366
+ const {
367
+ MANAGED_MANIFEST_PATH,
368
+ tryReadManagedManifest,
369
+ isManagedAssetCustomizedOnDisk,
370
+ syncManagedManifestFromDisk,
371
+ } = loadManagedUpgrade();
372
+ const managedPresent = Boolean(tryReadManagedManifest(root));
373
+ let defaultedSkillsOnly = false;
374
+ if (
375
+ managedPresent &&
376
+ !args.skillsOnly &&
377
+ !args.compact &&
378
+ !args.force &&
379
+ fs.existsSync(path.join(root, 'AGENTS.md'))
380
+ ) {
381
+ args.skillsOnly = true;
382
+ defaultedSkillsOnly = true;
383
+ }
354
384
  const { tools, source } = args.compact && args.tools == null
355
385
  ? { tools: new Set(), source: 'compact-none' }
356
386
  : resolveTools(args);
@@ -373,7 +403,12 @@ export function runInstallAgentGates(args) {
373
403
  const host = [...tools][0];
374
404
  const later = host ? ` --tools ${host}` : '';
375
405
  if (args.compact) console.log(`Profile: compact router. Expert skills later: ${arkCommand(root, 'ark-check', `--install-agent-gates --skills-only${later} --force`)}`);
376
- else if (args.skillsOnly) console.log('Profile: expert skill pack only (refreshes /ark-*; leaves customized gates).');
406
+ else if (defaultedSkillsOnly) {
407
+ console.log(
408
+ `Profile: skills-only refresh (default — ${MANAGED_MANIFEST_PATH} present). ` +
409
+ 'Full gate rewrite: re-run with --force (preserves customized content-identity assets and recomputes managed digest).'
410
+ );
411
+ } else if (args.skillsOnly) console.log('Profile: expert skill pack only (refreshes /ark-*; leaves customized gates).');
377
412
  else console.log('Profile: full agent gates. Compact-only onboarding: ark start.');
378
413
  }
379
414
  // --skills-only refreshes just the canonical /ark-* skills, which are safe to
@@ -382,6 +417,13 @@ export function runInstallAgentGates(args) {
382
417
  // `--force` clobbers them — this is the safe way to pick up new skill versions.
383
418
  // Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
384
419
  if (!args.skillsOnly) {
420
+ // S4.4: always write check:architecture on gate install / start (including compact).
421
+ const checkBootstrap = ensureCheckArchitectureScript(root, { write: true });
422
+ if (checkBootstrap.changed && !args.json) {
423
+ console.log(
424
+ `Added package.json script "check:architecture": "${checkBootstrap.script}" (local/CI parity).`
425
+ );
426
+ }
385
427
  // Bootstrap typecheck before CI template so generated workflow includes the step.
386
428
  const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
387
429
  if (typecheckBootstrap.changed && !args.compact && !args.json) {
@@ -397,6 +439,9 @@ export function runInstallAgentGates(args) {
397
439
  skillsOnly: args.skillsOnly,
398
440
  });
399
441
  const { skills, skillPaths, version } = catalog;
442
+ const assetKindByPath = new Map(
443
+ catalog.assets.map((asset) => [asset.relativePath, asset.kind ?? 'gate'])
444
+ );
400
445
  const templates = catalog.assets.map(({ relativePath, content }) => [relativePath, content]);
401
446
 
402
447
  // A compact router can be moved back from an explicit host removal. Delete the
@@ -412,6 +457,16 @@ export function runInstallAgentGates(args) {
412
457
  }
413
458
 
414
459
  const results = templates.map(([relativePath, content]) => {
460
+ // S4.1: preserve content-identity customized/conflicted managed assets even under --force.
461
+ const kind = assetKindByPath.get(relativePath) ?? 'gate';
462
+ if (
463
+ args.force &&
464
+ managedPresent &&
465
+ kind !== 'skill' &&
466
+ isManagedAssetCustomizedOnDisk(root, relativePath, content, kind)
467
+ ) {
468
+ return { relativePath, status: 'skipped-customized' };
469
+ }
415
470
  if (relativePath === '.codex/config.toml') {
416
471
  const fullPath = path.join(root, relativePath);
417
472
  let existing = '';
@@ -474,6 +529,7 @@ export function runInstallAgentGates(args) {
474
529
 
475
530
  console.log('Ark agent gate templates:');
476
531
  let staleSkipped = 0;
532
+ let preservedCustomized = 0;
477
533
  for (const result of results) {
478
534
  const marker =
479
535
  result.status === 'written'
@@ -482,13 +538,18 @@ export function runInstallAgentGates(args) {
482
538
  ? 'merged'
483
539
  : result.status === 'skipped-non-ark'
484
540
  ? 'kept'
485
- : result.status === 'failed'
486
- ? 'FAILED'
487
- : 'skipped';
541
+ : result.status === 'skipped-customized'
542
+ ? 'kept'
543
+ : result.status === 'failed'
544
+ ? 'FAILED'
545
+ : 'skipped';
488
546
  // A skipped skill reads as "you're fine" — but it may be a version behind.
489
547
  // Say which, so the user isn't left guessing (and knows the safe refresh cmd).
490
548
  let note = '';
491
- if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
549
+ if (result.status === 'skipped-customized') {
550
+ preservedCustomized += 1;
551
+ note = ' (customized — content-identity preserved)';
552
+ } else if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
492
553
  const installed = installedSkillVersion(path.join(root, result.relativePath));
493
554
  if (installed === null || isVersionOlder(installed, version)) {
494
555
  staleSkipped += 1;
@@ -499,6 +560,11 @@ export function runInstallAgentGates(args) {
499
560
  }
500
561
  console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
501
562
  }
563
+ if (preservedCustomized > 0 && !args.json) {
564
+ console.log(
565
+ ` ${preservedCustomized} customized managed asset(s) preserved (use ark upgrade --accept-conflicts to overwrite deliberately).`
566
+ );
567
+ }
502
568
  if (staleSkipped > 0 && !args.skillsOnly) {
503
569
  console.log('');
504
570
  console.log(
@@ -605,11 +671,48 @@ export function runInstallAgentGates(args) {
605
671
  }
606
672
  console.log(`\nHard-write hook verified for ${writeRequest.host}.`);
607
673
  }
674
+ // S4.1: after --force, recompute ark.managed.json so a prior upgrade planDigest
675
+ // is not left silently broken (field: force clobber → digest mismatch).
676
+ const wroteManaged =
677
+ results.some((r) => r.status === 'written' || r.status === 'merged') ||
678
+ homeResults.some((r) => r.status === 'written');
679
+ if (args.force && managedPresent) {
680
+ try {
681
+ const sync = syncManagedManifestFromDisk(root, {
682
+ tools: [...tools],
683
+ profile: args.compact ? 'compact' : 'full',
684
+ });
685
+ if (!args.json) {
686
+ console.log('');
687
+ console.log(
688
+ sync.wrote
689
+ ? `Recomputed ${MANAGED_MANIFEST_PATH} after --force (planDigest ${sync.planDigest.slice(0, 18)}…). Run a fresh ark upgrade preview before --apply.`
690
+ : `${MANAGED_MANIFEST_PATH} already matches on-disk content after --force.`
691
+ );
692
+ }
693
+ } catch (error) {
694
+ if (!args.json) {
695
+ console.log('');
696
+ console.log(
697
+ `Warning: could not recompute ${MANAGED_MANIFEST_PATH} after --force (${error instanceof Error ? error.message : String(error)}). Run a new ark upgrade preview before --apply.`
698
+ );
699
+ }
700
+ }
701
+ } else if (args.force && wroteManaged && !args.skillsOnly) {
702
+ // No prior manifest: still warn that any in-flight upgrade preview is stale.
703
+ if (!args.json) {
704
+ console.log('');
705
+ console.log(
706
+ 'Note: --force rewrote gate files. If you had an ark upgrade preview open, run a new preview (planDigest changed).'
707
+ );
708
+ }
709
+ }
710
+
608
711
  console.log('');
609
712
  console.log('Next steps:');
610
713
  console.log(' 1. Review the generated files and commit the ones that match your tools.');
611
714
  console.log(` 2. Run: ${arkCheckCommand(root)}`);
612
- if (!hasCheckScript) {
715
+ if (!hasCheckScript && !hasCheckArchitectureScript(root)) {
613
716
  console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
614
717
  console.log(` ${checkArchitectureScriptSnippet(root)}`);
615
718
  }
@@ -180,6 +180,105 @@ function readManifest(root) {
180
180
  return { file, content, value };
181
181
  }
182
182
 
183
+ /**
184
+ * Best-effort managed manifest load for install-agent-gates force/preserve paths.
185
+ * Returns null when missing or invalid (never throws).
186
+ * @param {string} root
187
+ * @returns {{ schemaVersion: string, profile: string, hosts: string[], assets: object[] } | null}
188
+ */
189
+ export function tryReadManagedManifest(root) {
190
+ try {
191
+ const { value } = readManifest(path.resolve(root));
192
+ return value;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Recompute `ark.managed.json` identities from on-disk content after a force
200
+ * gate install (or any out-of-band rewrite of managed assets). Without this,
201
+ * a prior upgrade planDigest is silently invalidated and apply fails with
202
+ * "managed upgrade plan digest mismatch".
203
+ *
204
+ * @param {string} root
205
+ * @param {{ tools?: string[]|string|Set<string>, profile?: 'compact'|'full' }} [options]
206
+ * @returns {{ planDigest: string, profile: string, hosts: string[], assetCount: number, wrote: boolean }}
207
+ */
208
+ export function syncManagedManifestFromDisk(root, options = {}) {
209
+ const resolvedRoot = path.resolve(root);
210
+ const plan = planManagedUpgrade(resolvedRoot, {
211
+ tools: options.tools,
212
+ profile: options.profile,
213
+ acceptConflicts: options.acceptConflicts === true,
214
+ });
215
+ const file = assertSafeTarget(resolvedRoot, MANAGED_MANIFEST_PATH);
216
+ const previous = readFile(file);
217
+ const next = Buffer.from(plan.manifestContent);
218
+ if (previous && hash(previous) === hash(next)) {
219
+ return {
220
+ planDigest: plan.planDigest,
221
+ profile: plan.profile,
222
+ hosts: plan.hosts,
223
+ assetCount: plan.assets.length,
224
+ wrote: false,
225
+ };
226
+ }
227
+ ensureParentDirectories(resolvedRoot, MANAGED_MANIFEST_PATH, []);
228
+ fs.writeFileSync(file, plan.manifestContent);
229
+ return {
230
+ planDigest: plan.planDigest,
231
+ profile: plan.profile,
232
+ hosts: plan.hosts,
233
+ assetCount: plan.assets.length,
234
+ wrote: true,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * True when an existing managed asset is customized/conflicted vs the recorded
240
+ * identity — install-agent-gates --force must preserve these (same contract as
241
+ * content-identity upgrade).
242
+ *
243
+ * Force preserve also applies by content-identity when the asset is missing from
244
+ * ark.managed.json (incomplete manifest must not clobber customized AGENTS.md).
245
+ */
246
+ export function isManagedAssetCustomizedOnDisk(root, relativePath, desiredContent, kind = 'gate') {
247
+ const file = path.join(path.resolve(root), relativePath);
248
+ const currentFile = readFile(file);
249
+ if (currentFile == null) return false;
250
+ let currentScoped = currentFile.toString('utf8');
251
+
252
+ const manifest = tryReadManagedManifest(root);
253
+ const recorded = manifest
254
+ ? (manifest.assets ?? []).find((asset) => asset.path === relativePath)
255
+ : null;
256
+
257
+ if (recorded) {
258
+ // Whole-file gates: compare full file bytes. Toml-section uses primary table only.
259
+ if (recorded.scope === 'toml-section') {
260
+ currentScoped = codexPrimaryTable(currentScoped)?.block ?? currentScoped;
261
+ }
262
+ const classified = classifyManagedAsset({
263
+ recorded,
264
+ currentContent: currentScoped,
265
+ targetContent: desiredContent,
266
+ kind,
267
+ });
268
+ return classified.state === 'customized' || classified.state === 'conflicted';
269
+ }
270
+
271
+ // Incomplete / missing managed list: still preserve when on-disk content differs
272
+ // from the desired template (content-identity force preserve, especially AGENTS.md).
273
+ if (kind === 'skill') return false;
274
+ const desiredNorm = normalizedIdentityContent(desiredContent ?? '');
275
+ const currentNorm = normalizedIdentityContent(currentScoped);
276
+ if (!currentNorm.trim()) return false;
277
+ if (currentNorm === desiredNorm) return false;
278
+ // AGENTS.md and other whole-file gates: any divergence from desired template is customized.
279
+ return managedContentIdentity(currentNorm, kind) !== managedContentIdentity(desiredNorm, kind);
280
+ }
281
+
183
282
  function resolveSelection(root, options, manifest) {
184
283
  const explicit = normalizeToolsList(options.tools);
185
284
  const compactHost = compactRouterHost(root);
@@ -689,5 +788,5 @@ export function renderManagedUpgrade(plan, options = {}) {
689
788
  }
690
789
  console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
691
790
  if (options.next) console.log(options.next);
692
- else console.log('Apply the exact preview with: ark upgrade --apply --no-install');
791
+ else console.log('Apply the exact preview with: npx arkgate upgrade --apply --no-install');
693
792
  }