arkgate 4.5.0 → 4.5.6

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 (48) hide show
  1. package/CHANGELOG.md +69 -2
  2. package/README.md +5 -4
  3. package/bin/ark-check-runtime.mjs +4 -0
  4. package/bin/ark-mcp-runtime.mjs +49 -0
  5. package/bin/ark-shared.mjs +8 -29
  6. package/bin/ark.mjs +7 -3
  7. package/bin/lib/adapter-contract.mjs +5 -5
  8. package/bin/lib/analysis-engine.mjs +1 -1
  9. package/bin/lib/ci-and-commands.mjs +5 -0
  10. package/bin/lib/deep-module-coach.mjs +177 -0
  11. package/bin/lib/deepening-coach.mjs +177 -0
  12. package/bin/lib/doctor-plan.mjs +14 -0
  13. package/bin/lib/html-report-advisories.mjs +33 -0
  14. package/bin/lib/html-report-depth.mjs +9 -0
  15. package/bin/lib/managed-upgrade.mjs +215 -1
  16. package/bin/lib/remediation.mjs +5 -5
  17. package/bin/lib/rules-inventory.mjs +23 -0
  18. package/bin/lib/upgrade-command.mjs +132 -11
  19. package/bin/lib/upgrade-package-decision.mjs +241 -0
  20. package/bin/lib/upgrade-whats-new.mjs +135 -0
  21. package/dist/eslint/index.cjs +1 -1
  22. package/dist/eslint/index.js +1 -1
  23. package/dist/index.cjs +27 -27
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +29 -29
  26. package/docs/README.md +4 -5
  27. package/docs/agent-guide.md +36 -0
  28. package/docs/brownfield-adoption.md +12 -0
  29. package/docs/package-surface.md +6 -3
  30. package/docs/product-voice.md +21 -0
  31. package/docs/use.md +3 -0
  32. package/package.json +1 -1
  33. package/server.json +2 -2
  34. package/templates/agent-skills/README.md +2 -2
  35. package/templates/agent-skills/ark-adopt/SKILL.md +13 -0
  36. package/templates/agent-skills/ark-explore/SKILL.md +21 -0
  37. package/templates/agent-skills/ark-fix/SKILL.md +7 -0
  38. package/templates/agent-skills/ark-loop/SKILL.md +7 -0
  39. package/templates/agent-skills/ark-place/SKILL.md +7 -0
  40. package/templates/agent-skills/ark-think/SKILL.md +7 -0
  41. package/templates/agent-skills/ark-upgrade/SKILL.md +61 -15
  42. package/templates/skills/ark-adopt.md +13 -0
  43. package/templates/skills/ark-explore.md +21 -0
  44. package/templates/skills/ark-fix.md +7 -0
  45. package/templates/skills/ark-loop.md +7 -0
  46. package/templates/skills/ark-place.md +7 -0
  47. package/templates/skills/ark-think.md +7 -0
  48. package/templates/skills/ark-upgrade.md +61 -15
@@ -0,0 +1,177 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/deepeningCoach.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/deepening-coach.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_DEEPENING_COACH_SCHEMA_VERSION = '1.0';
12
+ /** Cap on listed deepening candidates (agent-legible; not a ranking score). */
13
+ export const DEEPENING_CANDIDATE_CAP = 5;
14
+ function asString(value) {
15
+ return typeof value === 'string' ? value.trim() : '';
16
+ }
17
+ function firstEvidencePath(evidence) {
18
+ if (!Array.isArray(evidence))
19
+ return '';
20
+ for (const row of evidence) {
21
+ if (typeof row === 'string' && row.trim())
22
+ return row.trim().replace(/\\/g, '/');
23
+ if (row && typeof row === 'object') {
24
+ const path = asString(row.path)
25
+ || asString(row.file);
26
+ if (path)
27
+ return path.replace(/\\/g, '/');
28
+ }
29
+ }
30
+ return '';
31
+ }
32
+ function evidenceList(source, refs, detail) {
33
+ const out = [];
34
+ for (const ref of refs) {
35
+ const r = asString(ref);
36
+ if (!r)
37
+ continue;
38
+ out.push(detail ? { source, ref: r, detail } : { source, ref: r });
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Project deepening candidates from existing doctor-side evidence only.
44
+ * Empty input / no residual → empty `candidates` (honesty: no fake list).
45
+ */
46
+ export function buildDeepeningCandidates(input = {}) {
47
+ const candidates = [];
48
+ const seen = new Set();
49
+ const push = (candidate) => {
50
+ if (candidates.length >= DEEPENING_CANDIDATE_CAP)
51
+ return;
52
+ const key = `${candidate.target}|${candidate.friction}`.toLowerCase();
53
+ if (seen.has(key))
54
+ return;
55
+ if (!asString(candidate.target) || !asString(candidate.friction))
56
+ return;
57
+ seen.add(key);
58
+ candidates.push(candidate);
59
+ };
60
+ const pilot = input.pilotLoop?.active === true && input.pilotLoop.nextPilot
61
+ ? input.pilotLoop.nextPilot
62
+ : null;
63
+ if (pilot) {
64
+ // Require a real pilot identity — empty shells are not evidence.
65
+ const target = asString(pilot.pilotTarget) || asString(pilot.pilot) || asString(pilot.smellId);
66
+ if (target) {
67
+ push({
68
+ target,
69
+ friction: asString(pilot.smellId)
70
+ ? `Shape pilot residual (${pilot.smellId}) — one extraction at a time`
71
+ : 'Shape pilot residual — one extraction at a time',
72
+ intent: asString(pilot.move) ||
73
+ 'Deepen the public seam; hide implementation behind a small interface',
74
+ benefit: asString(pilot.successSignal) ||
75
+ 'Locality: change and tests concentrate at the public interface',
76
+ evidence: evidenceList('pilotLoop', [target], 'nextPilot'),
77
+ });
78
+ }
79
+ }
80
+ const smells = Array.isArray(input.designSmells) ? input.designSmells : [];
81
+ for (const smell of smells) {
82
+ if (!smell || typeof smell !== 'object')
83
+ continue;
84
+ const id = asString(smell.id);
85
+ const path = firstEvidencePath(smell.evidence);
86
+ // Require non-empty smell id or evidence path — whitespace shells are not evidence.
87
+ if (!id && !path)
88
+ continue;
89
+ const target = path || id;
90
+ const friction = asString(smell.outcome) || asString(smell.message) || `Design residual (${id || target})`;
91
+ push({
92
+ target,
93
+ friction,
94
+ intent: asString(smell.fix) ||
95
+ 'Prefer a deep module at a named seam; apply the deletion test before pass-through extracts',
96
+ benefit: 'Leverage: callers learn a smaller interface; locality of change improves',
97
+ evidence: evidenceList('designSmells', path ? [path, id].filter(Boolean) : [id], id || undefined),
98
+ });
99
+ }
100
+ const pc = input.physicalCohesion;
101
+ const reshape = pc?.reshapePilot?.nextPilot;
102
+ if (reshape) {
103
+ const target = asString(reshape.pilotTarget) || asString(reshape.pilot);
104
+ if (target) {
105
+ push({
106
+ target,
107
+ friction: 'Physical cohesion residual — concept concentration across anchors',
108
+ intent: asString(reshape.move) ||
109
+ 'One reshape pilot toward locality; never mechanical-safe multi-file batch',
110
+ benefit: asString(reshape.successSignal) ||
111
+ 'Related behavior co-located; public seams stay small',
112
+ evidence: evidenceList('physicalCohesion', [target], 'reshapePilot'),
113
+ });
114
+ }
115
+ }
116
+ const findings = Array.isArray(pc?.findings) ? pc.findings : [];
117
+ for (const finding of findings) {
118
+ if (!finding || typeof finding !== 'object')
119
+ continue;
120
+ const concept = asString(finding.concept);
121
+ const anchors = Array.isArray(finding.anchors)
122
+ ? finding.anchors.map((a) => asString(a)).filter(Boolean)
123
+ : [];
124
+ const target = anchors[0] || concept;
125
+ if (!target)
126
+ continue;
127
+ push({
128
+ target,
129
+ friction: asString(finding.message) ||
130
+ (concept
131
+ ? `Concept "${concept}" concentrated across physical anchors`
132
+ : 'Physical cohesion residual'),
133
+ intent: 'Deepen by colocating behavior behind one public interface per concern',
134
+ benefit: 'Locality of change; fewer cross-anchor edits for one concept',
135
+ evidence: evidenceList('physicalCohesion', anchors.length > 0 ? anchors.slice(0, 3) : [target], concept || undefined),
136
+ });
137
+ }
138
+ const compass = input.improvementCompass;
139
+ if (compass && compass.notAScore === true) {
140
+ const residualIds = Array.isArray(compass.topResidual)
141
+ ? compass.topResidual.map((id) => asString(id)).filter(Boolean)
142
+ : [];
143
+ const lensById = new Map();
144
+ if (Array.isArray(compass.lenses)) {
145
+ for (const lens of compass.lenses) {
146
+ if (lens && typeof lens === 'object' && asString(lens.id)) {
147
+ lensById.set(asString(lens.id), { summary: asString(lens.summary) || undefined });
148
+ }
149
+ }
150
+ }
151
+ for (const id of residualIds) {
152
+ // Each residual lens id is independent evidence. Cap + target|friction dedupe may
153
+ // still list both a smell card and lens:<id> — no cross-source suppression.
154
+ const summary = lensById.get(id)?.summary;
155
+ push({
156
+ target: `lens:${id}`,
157
+ friction: summary
158
+ ? `Residual lens ${id}: ${summary}`
159
+ : `Residual architecture lens ${id} (not a score)`,
160
+ intent: 'Process judgment: deepen modules / name seams that clear this lens residual',
161
+ benefit: 'Clear residual without inventing a depth score or gate fail',
162
+ evidence: evidenceList('improvementCompass', [id], 'topResidual'),
163
+ });
164
+ }
165
+ }
166
+ return {
167
+ schemaVersion: ARK_DEEPENING_COACH_SCHEMA_VERSION,
168
+ notAScore: true,
169
+ candidates,
170
+ };
171
+ }
172
+ /**
173
+ * True when the pure projection would list zero candidates (honesty helper for tests).
174
+ */
175
+ export function hasNoDeepeningEvidence(input) {
176
+ return buildDeepeningCandidates(input).candidates.length === 0;
177
+ }
@@ -66,6 +66,10 @@ import {
66
66
  buildDoctorImprovementCompass,
67
67
  printImprovementCompassSection,
68
68
  } from './improvement-compass-doctor.mjs';
69
+ import {
70
+ buildDeepModuleCoachAdvisory,
71
+ printDeepModuleCoachSection,
72
+ } from './deep-module-coach.mjs';
69
73
 
70
74
  const color = {
71
75
  green: (s) => `\x1b[32m${s}\x1b[0m`,
@@ -660,6 +664,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
660
664
  goldenPatternPresent: goldenPattern.present === true,
661
665
  arkRulesLoaded: rulesUnderContract?.active === true,
662
666
  });
667
+ // Deep-module coach: hot paths + deepening candidates — advisory only (notAScore).
668
+ const deepModuleCoach = buildDeepModuleCoachAdvisory(root, {
669
+ designSmells,
670
+ physicalCohesion: doctorAdvisories.physicalCohesion,
671
+ improvementCompass,
672
+ pilotLoop,
673
+ });
663
674
 
664
675
  if (asJson) {
665
676
  (options.writeJson ?? console.log)(
@@ -677,6 +688,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
677
688
  designSmells,
678
689
  // Improvement compass (lenses; notAScore; never a gate input).
679
690
  improvementCompass,
691
+ // Deep-module coach (hot paths + deepening; notAScore; never a gate input).
692
+ deepModuleCoach,
680
693
  ...(options.designDelta ? { designDelta: options.designDelta } : {}),
681
694
  // Q01: primary next action when Shape residual dominates (null if not design-weak).
682
695
  postGreenPath,
@@ -870,6 +883,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
870
883
  }
871
884
 
872
885
  printImprovementCompassSection(improvementCompass, { line, warn, ok, color });
886
+ printDeepModuleCoachSection(deepModuleCoach, { line, warn, ok, color });
873
887
 
874
888
  console.log('');
875
889
  console.log(color.bold('Design fitness'));
@@ -279,11 +279,44 @@ function improvementCompassHtml(compass) {
279
279
  </section>`;
280
280
  }
281
281
 
282
+ /** Deep-module coach — hot paths + deepening candidates; never a gate input. */
283
+ function deepModuleCoachHtml(coach) {
284
+ if (!coach || coach.notAScore !== true) return '';
285
+ const hot = coach.hotPaths;
286
+ const candidates = Array.isArray(coach.deepeningCandidates) ? coach.deepeningCandidates : [];
287
+ let hotBlock = '';
288
+ if (hot?.status === 'unavailable') {
289
+ hotBlock = `<p class="muted">Hot paths: unavailable — ${esc(hot.reason || 'no git history')}; never invented.</p>`;
290
+ } else if (hot?.available === true && Array.isArray(hot.paths) && hot.paths.length > 0) {
291
+ hotBlock = `<p><span class="tag warn">hot paths</span> ${hot.paths
292
+ .slice(0, 8)
293
+ .map((p) => `<code>${esc(p.path)}</code> (${esc(String(p.changeCount))})`)
294
+ .join(' · ')}</p>`;
295
+ } else {
296
+ hotBlock = '<p class="muted">Hot paths: none above churn threshold (advisory).</p>';
297
+ }
298
+ const deepenBlock =
299
+ candidates.length === 0
300
+ ? '<p class="muted">Deepening candidates: none from existing evidence (not a score).</p>'
301
+ : `<p><span class="tag warn">deepening</span> ${candidates
302
+ .slice(0, 5)
303
+ .map((c) => `<code>${esc(c.target)}</code> — ${esc(c.friction)}`)
304
+ .join('<br/>')}</p>`;
305
+ return `
306
+ <section class="section card" data-advisory="deepModuleCoach">
307
+ <h2>Deep-module coach <span class="muted">(advisory — never changes the verdict)</span></h2>
308
+ ${hotBlock}
309
+ ${deepenBlock}
310
+ <p class="muted">Prefer deep modules; name seams; test at the public interface. Always <code>notAScore</code>.</p>
311
+ </section>`;
312
+ }
313
+
282
314
  export function renderAdvisorySections(advisories, escape) {
283
315
  if (!advisories || typeof advisories !== 'object') return '';
284
316
  if (typeof escape === 'function') esc = escape;
285
317
  return [
286
318
  improvementCompassHtml(advisories.improvementCompass),
319
+ deepModuleCoachHtml(advisories.deepModuleCoach),
287
320
  contractHealthHtml(advisories.contractHealth),
288
321
  ambientStateHtml(advisories.ambientState),
289
322
  physicalCohesionHtml(advisories.physicalCohesion),
@@ -22,6 +22,7 @@ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
22
22
  import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
23
23
  import { describePackageVersionDualTruth } from './field-install.mjs';
24
24
  import { buildDoctorImprovementCompass } from './improvement-compass-doctor.mjs';
25
+ import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
25
26
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
26
27
 
27
28
  function esc(value) {
@@ -178,6 +179,13 @@ export function buildReportDepthPayload(
178
179
  goldenPatternPresent: goldenPattern.present === true,
179
180
  arkRulesLoaded: rulesUnderContract?.active === true,
180
181
  });
182
+ // Deep-module coach — same advisory as doctor; never a gate input.
183
+ const deepModuleCoach = buildDeepModuleCoachAdvisory(root, {
184
+ designSmells,
185
+ physicalCohesion,
186
+ improvementCompass,
187
+ pilotLoop,
188
+ });
181
189
  return {
182
190
  adoption,
183
191
  designDepth: {
@@ -190,6 +198,7 @@ export function buildReportDepthPayload(
190
198
  productHonesty,
191
199
  mergePlanes: rulesUnderContract?.mergePlanes ?? null,
192
200
  improvementCompass,
201
+ deepModuleCoach,
193
202
  },
194
203
  };
195
204
  }
@@ -8,6 +8,10 @@ import {
8
8
  formatManagedUpgradeSelfServiceHonesty,
9
9
  projectManagedUpgradeSelfServiceHonesty,
10
10
  } from './managed-upgrade-honesty.mjs';
11
+ import {
12
+ buildUpgradeWhatsNewSuggestions,
13
+ formatUpgradeWhatsNewSuggestions,
14
+ } from './upgrade-whats-new.mjs';
11
15
  import {
12
16
  KNOWN_TOOLS,
13
17
  arkPackageVersion,
@@ -21,6 +25,11 @@ export {
21
25
  projectHostWritePathActivation,
22
26
  projectManagedUpgradeSelfServiceHonesty,
23
27
  } from './managed-upgrade-honesty.mjs';
28
+ export {
29
+ buildUpgradeWhatsNewSuggestions,
30
+ formatUpgradeWhatsNewSuggestions,
31
+ UPGRADE_WHATS_NEW_SCHEMA_VERSION,
32
+ } from './upgrade-whats-new.mjs';
24
33
 
25
34
  export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
26
35
  const MANIFEST_VERSION = '1.0';
@@ -456,11 +465,19 @@ export function planManagedUpgrade(root, options = {}) {
456
465
  kind: catalogAsset.kind,
457
466
  });
458
467
  const accepted = options.acceptConflicts === true;
468
+ // FX04: --refresh-skills opt-in rewrites customized *skill* assets to package
469
+ // templates. Conflicted still needs --accept-conflicts. Never silent default.
470
+ const refreshSkills = options.refreshSkills === true;
471
+ const skillRefresh =
472
+ refreshSkills &&
473
+ catalogAsset.kind === 'skill' &&
474
+ classified.state === 'customized';
459
475
  const canApply =
460
476
  classified.state === 'stale' ||
477
+ skillRefresh ||
461
478
  (classified.state === 'missing' && (!recorded || accepted)) ||
462
479
  (classified.state === 'conflicted' && accepted);
463
- const blocked = classified.requiresConsent && !accepted;
480
+ const blocked = classified.requiresConsent && !accepted && !skillRefresh;
464
481
  const desiredFile = afterFileContent(catalogAsset, currentFile, desiredScoped);
465
482
  const asset = {
466
483
  path: catalogAsset.relativePath,
@@ -533,6 +550,7 @@ export function planManagedUpgrade(root, options = {}) {
533
550
  profile: selection.profile,
534
551
  hosts: selection.hosts,
535
552
  acceptConflicts: options.acceptConflicts === true,
553
+ refreshSkills: options.refreshSkills === true,
536
554
  assets,
537
555
  summary,
538
556
  nextManifest,
@@ -543,6 +561,183 @@ export function planManagedUpgrade(root, options = {}) {
543
561
  return plan;
544
562
  }
545
563
 
564
+ /**
565
+ * FX03 — skill content drift honesty (counts by state + sample paths).
566
+ * Skills only; never claims "skills upgraded" when only package pin moved.
567
+ */
568
+ export function buildSkillDriftSummary(plan) {
569
+ const assets = Array.isArray(plan?.assets) ? plan.assets : [];
570
+ const skills = assets.filter((a) => a?.kind === 'skill');
571
+ const byState = {};
572
+ for (const skill of skills) {
573
+ const state = typeof skill.state === 'string' ? skill.state : 'unknown';
574
+ byState[state] = (byState[state] ?? 0) + 1;
575
+ }
576
+ const sample = (state, limit = 5) =>
577
+ skills
578
+ .filter((s) => s.state === state)
579
+ .map((s) => s.path)
580
+ .sort()
581
+ .slice(0, limit);
582
+ const customized = byState.customized ?? 0;
583
+ const stale = byState.stale ?? 0;
584
+ const missing = byState.missing ?? 0;
585
+ const current = byState.current ?? 0;
586
+ const wouldRefresh = skills.filter((s) => s.willApply === true).length;
587
+ return {
588
+ schemaVersion: '1.0',
589
+ notAScore: true,
590
+ skillCount: skills.length,
591
+ byState,
592
+ stale,
593
+ customized,
594
+ missing,
595
+ current,
596
+ wouldRefresh,
597
+ samplePaths: {
598
+ stale: sample('stale'),
599
+ customized: sample('customized'),
600
+ missing: sample('missing'),
601
+ },
602
+ note:
603
+ customized > 0 && wouldRefresh === 0
604
+ ? 'Skills on disk differ from package templates (customized preserved). Use --refresh-skills to opt in to rewrite customized skills; never silent overwrite.'
605
+ : stale > 0
606
+ ? 'Some skills are stale vs package templates and will refresh on apply.'
607
+ : skills.length === 0
608
+ ? 'No managed skill assets in this upgrade selection.'
609
+ : 'Skill content matches package templates or is scheduled for write.',
610
+ };
611
+ }
612
+
613
+ /**
614
+ * FX07 — active host vs managed --tools / manifest hosts.
615
+ */
616
+ export function buildHostSelectionHonesty(plan) {
617
+ const hosts = Array.isArray(plan?.hosts) ? plan.hosts.map((h) => String(h).toLowerCase()) : [];
618
+ let active = null;
619
+ try {
620
+ active = detectActiveAgentHost();
621
+ } catch {
622
+ active = null;
623
+ }
624
+ const activeNorm =
625
+ typeof active === 'string' && active.trim() ? active.trim().toLowerCase() : null;
626
+ const known = activeNorm && KNOWN_TOOLS.includes(activeNorm);
627
+ const inSelection = Boolean(activeNorm && hosts.includes(activeNorm));
628
+ const note =
629
+ known && !inSelection
630
+ ? `Detected host "${activeNorm}" is not in managed tools [${hosts.join(', ') || 'none'}]. Re-run with --tools ${[...new Set([...hosts, activeNorm])].sort().join(',')} so that host's skills/hooks are in the plan.`
631
+ : known && inSelection
632
+ ? `Detected host "${activeNorm}" is in the managed selection.`
633
+ : activeNorm
634
+ ? `Detected host "${activeNorm}" is outside the known managed tool set.`
635
+ : 'No active agent host detected for this process.';
636
+ return {
637
+ schemaVersion: '1.0',
638
+ notAScore: true,
639
+ activeHost: activeNorm,
640
+ managedHosts: hosts,
641
+ activeInSelection: inSelection,
642
+ suggestTools:
643
+ known && !inSelection
644
+ ? [...new Set([...hosts, activeNorm])].sort().join(',')
645
+ : null,
646
+ note,
647
+ };
648
+ }
649
+
650
+ /**
651
+ * FX05 — post-upgrade verification block (advisory sensors only).
652
+ */
653
+ export function buildPostUpgradeChecks(root, options = {}) {
654
+ const resolvedRoot = path.resolve(root);
655
+ const checks = [];
656
+ let projectVersion = null;
657
+ try {
658
+ const pkgPath = path.join(resolvedRoot, 'node_modules', 'arkgate', 'package.json');
659
+ if (fs.existsSync(pkgPath)) {
660
+ projectVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? null;
661
+ }
662
+ } catch {
663
+ projectVersion = null;
664
+ }
665
+ const cli = typeof options.cliVersion === 'string' ? options.cliVersion : arkPackageVersion();
666
+ const pinOk =
667
+ projectVersion != null && cli != null ? projectVersion === cli : null;
668
+ checks.push({
669
+ id: 'package-pin-cli',
670
+ ok: pinOk,
671
+ detail:
672
+ pinOk === true
673
+ ? `Installed arkgate@${projectVersion} matches CLI ${cli}.`
674
+ : pinOk === false
675
+ ? `Installed arkgate@${projectVersion} ≠ CLI ${cli}; re-run install or restart using project-local CLI.`
676
+ : `Could not compare pin (installed=${projectVersion ?? 'missing'}, cli=${cli ?? 'unknown'}).`,
677
+ });
678
+ checks.push({
679
+ id: 'architecture-verification',
680
+ ok:
681
+ options.verification?.mode === 'skipped'
682
+ ? null
683
+ : options.verification?.exitCode === 0,
684
+ detail:
685
+ options.verification?.mode === 'skipped'
686
+ ? 'Strict architecture verification was skipped (--no-strict).'
687
+ : options.verification?.exitCode === 0
688
+ ? 'Strict-merge architecture verification passed.'
689
+ : `Architecture verification exit ${options.verification?.exitCode ?? 'unknown'}.`,
690
+ });
691
+ checks.push({
692
+ id: 'package-version-truth',
693
+ ok: options.dualTruth?.dualTruth === true ? false : options.dualTruth ? true : null,
694
+ detail:
695
+ options.dualTruth?.dualTruth === true
696
+ ? options.dualTruth.note || 'Package pin dual-truth detected.'
697
+ : options.dualTruth
698
+ ? 'Package pin truth is consistent for this apply.'
699
+ : 'Package version truth not evaluated.',
700
+ });
701
+ checks.push({
702
+ id: 'doctor-compass-coach',
703
+ ok: null,
704
+ detail:
705
+ 'Run `npx arkgate-check --doctor --json` and confirm doctor.improvementCompass + doctor.deepModuleCoach (notAScore).',
706
+ });
707
+ checks.push({
708
+ id: 'agents-md-projection',
709
+ ok: null,
710
+ detail: 'Run `npx arkgate agents-md --check` (or --write) so AGENTS.md matches the package projection.',
711
+ });
712
+ checks.push({
713
+ id: 'status-mode',
714
+ ok: null,
715
+ detail: 'Run `npx arkgate status --json` and read honesty mode; incomplete facts never invent green residual.',
716
+ });
717
+ return {
718
+ schemaVersion: '1.0',
719
+ notAScore: true,
720
+ neverGateInput: true,
721
+ checks,
722
+ mcpNote:
723
+ 'If you used Ark MCP this session: restart/retarget MCP after package bump so process arkgateVersion matches project install; always pass project.expectedRoot + expectedProjectId (WI01). Prefer project-local CLI until identity matched and versions align.',
724
+ };
725
+ }
726
+
727
+ export function formatSkillDriftHuman(skillDrift) {
728
+ if (!skillDrift) return [];
729
+ const lines = [
730
+ `Skill drift: ${skillDrift.skillCount} skill(s) — current ${skillDrift.current}, stale ${skillDrift.stale}, customized ${skillDrift.customized}, missing ${skillDrift.missing}, would refresh ${skillDrift.wouldRefresh}.`,
731
+ ];
732
+ if (skillDrift.note) lines.push(` ${skillDrift.note}`);
733
+ return lines;
734
+ }
735
+
736
+ export function formatHostSelectionHuman(hostSelection) {
737
+ if (!hostSelection?.note) return [];
738
+ return [`Host selection: ${hostSelection.note}`];
739
+ }
740
+
546
741
  function publicPlan(plan, overrides = {}) {
547
742
  const assets = plan.assets.map(
548
743
  ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
@@ -567,11 +762,15 @@ function publicPlan(plan, overrides = {}) {
567
762
  assets,
568
763
  summary: base.summary,
569
764
  });
765
+ // Suggested improvements / what’s new — product capabilities to try after install/upgrade.
766
+ // Always notAScore; never a gate input; not part of planDigest.
767
+ const whatsNew = buildUpgradeWhatsNewSuggestions();
570
768
  return {
571
769
  ...base,
572
770
  ...overrides,
573
771
  // DF05 projection always present unless an override supplies a replacement.
574
772
  selfService: overrides.selfService ?? selfService,
773
+ whatsNew: overrides.whatsNew ?? whatsNew,
575
774
  };
576
775
  }
577
776
 
@@ -771,6 +970,21 @@ export function renderManagedUpgrade(plan, options = {}) {
771
970
  for (const line of formatManagedUpgradeSelfServiceHonesty(honesty)) {
772
971
  console.log(line);
773
972
  }
973
+ const skillDrift =
974
+ options.skillDrift ?? plan.skillDrift ?? buildSkillDriftSummary(plan);
975
+ for (const line of formatSkillDriftHuman(skillDrift)) {
976
+ console.log(line);
977
+ }
978
+ const hostSelection =
979
+ options.hostSelection ?? plan.hostSelection ?? buildHostSelectionHonesty(plan);
980
+ for (const line of formatHostSelectionHuman(hostSelection)) {
981
+ console.log(line);
982
+ }
983
+ // FX08: whatsNew always on preview/apply human path (including nothing-to-apply).
984
+ const whatsNew = plan.whatsNew ?? buildUpgradeWhatsNewSuggestions();
985
+ for (const line of formatUpgradeWhatsNewSuggestions(whatsNew)) {
986
+ console.log(line);
987
+ }
774
988
  if (plan.applied) {
775
989
  console.log(
776
990
  `Applied ${wouldWrite} content write(s)` +
@@ -49,15 +49,15 @@ export function deterministicNextAction(violation) {
49
49
  return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
50
50
  }
51
51
  if (violation.peerIsolation) {
52
- return 'Extract the shared dependency to a shared layer, then preflight again.';
52
+ return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
53
53
  }
54
- return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
54
+ return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
55
55
  case 'FORBIDDEN_GLOBAL':
56
- return `Inject ${violation.target ?? 'the capability'} through a port, then preflight again.`;
56
+ return `Inject ${violation.target ?? 'the capability'} through a port, test at the public interface, then preflight again.`;
57
57
  case 'CAPABILITY_VIOLATION':
58
- return `Define a ${String(violation.capability ?? 'capability')} port in ${violation.fromLayer ?? 'the walled layer'}, bind the implementation outside it, then preflight again.`;
58
+ return `Define a ${String(violation.capability ?? 'capability')} port in ${violation.fromLayer ?? 'the walled layer'}, bind the implementation outside it, test at the public interface, then preflight again.`;
59
59
  case 'CIRCULAR_DEPENDENCY':
60
- return 'Extract the shared dependency into a third module, then preflight again.';
60
+ return 'Extract the shared dependency into a third module, test at the public interface, then preflight again.';
61
61
  case 'RAW_EVENT_PUBLISH':
62
62
  return 'Publish through a registered intent creator, then run Ark again.';
63
63
  case 'PUBLISH_MISSING_SOURCE':
@@ -136,8 +136,29 @@ export function buildRulesInventory(input) {
136
136
  /_(?:OID|OIDS)$/i.test(name) ||
137
137
  /^(?:INT2|INT4|INT8|FLOAT4|FLOAT8|NUMERIC|DATE|TIME|TIMESTAMP|TIMESTAMPTZ|JSON|JSONB|UUID)OID$/i.test(name) ||
138
138
  /(?:^|_)(?:SCHEMA|PROTOCOL|RESOLVER|FORMAT)_(?:URL|URI|VERSION|ID|IDENTITY)$/i.test(name);
139
+ /**
140
+ * FX09 — pure UX copy / error-message string constants crowd inventory pilots.
141
+ * Downrank (skip) sentence-like strings and message-named identifiers; keep
142
+ * numeric thresholds and domain status tokens for adopt/contract pilots.
143
+ */
144
+ const isUxMessageConstant = (name, rawValue) => {
145
+ if (/^(?:ERROR|SUCCESS|WARNING|INFO|HINT|HELP|EMPTY|TOAST|SNACK|ALERT|BANNER|DIALOG|MODAL|TOOLTIP|CAPTION|SUBTITLE|HEADLINE|USER|UI|DISPLAY|FEEDBACK)_(?:MSG|MESSAGE|TEXT|COPY|LABEL|TITLE|BODY|DESC|DESCRIPTION|HINT|HELP)?/i.test(name) ||
146
+ /_(?:MSG|MESSAGE|TEXT|COPY|TOAST|SNACK|ALERT|BANNER|CAPTION|HINT|HELP_TEXT|ERROR_TEXT|EMPTY_TEXT|PLACEHOLDER_TEXT|USER_MESSAGE|FEEDBACK)$/i.test(name)) {
147
+ return true;
148
+ }
149
+ const unquoted = rawValue.replace(/^['"]|['"]$/g, '');
150
+ // Sentence-like string values (spaces or terminal punctuation) are UX copy,
151
+ // not behavioral business limits — unless the name is a clear domain status seed.
152
+ if (/^['"]/.test(rawValue) &&
153
+ (/\s/.test(unquoted) || /[.!?…]$/.test(unquoted)) &&
154
+ !/^(?:STATUS|STATE|PHASE|ROLE|TYPE|KIND|ORDER|PAYMENT|CART|INVOICE|POLICY)_[A-Z0-9_]+$/i.test(name)) {
155
+ return true;
156
+ }
157
+ return false;
158
+ };
139
159
  while ((magic = magicRe.exec(content)) !== null) {
140
160
  const name = magic[2];
161
+ const rawValue = magic[3] ?? '';
141
162
  // With governed layer evidence, generic Tooling/Kernel constants are not
142
163
  // business-rule candidates. Controller-shaped boundaries stay eligible
143
164
  // because business policy can leak into them.
@@ -145,6 +166,8 @@ export function buildRulesInventory(input) {
145
166
  continue;
146
167
  if (isInfraMagicName(name))
147
168
  continue;
169
+ if (isUxMessageConstant(name, rawValue))
170
+ continue;
148
171
  // P2-N: skip remaining ALL_CAPS noise only on clear UI chrome (not all of app/).
149
172
  if (isUiChrome && !isDomain)
150
173
  continue;