arkgate 4.5.5 → 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.
package/CHANGELOG.md CHANGED
@@ -5,14 +5,57 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 4.5.6 — 2026-08-11
9
+
10
+ **Patch** over **4.5.5**. Field upgrade & multi-project MCP truth (Phase FX): registry-aware
11
+ package install, structured skip recovery, skill drift + opt-in refresh, post-upgrade checks,
12
+ MCP process package honesty, host selection notes, early whatsNew, inventory UX-copy quieting.
13
+ **No required config migration.** No new skill names, sensors, or scores. Freezes held.
14
+
15
+ **Status: prepared** (not on npm `latest` until publish train completes; see
16
+ `docs/releases/4.5.6.md`).
17
+
18
+ ### Added
19
+
20
+ - **Registry-aware upgrade (FX01–FX02):** `ark upgrade --apply` no longer skips package install
21
+ solely because CLI version equals `node_modules/arkgate`. When the registry (injectable for
22
+ tests) is ahead, install runs. Skip paths emit structured JSON (`reasonCode`,
23
+ `installedVersion`, `cliVersion`, `registryLatest`, `suggestedInstallCmd`) plus human
24
+ recovery. Offline / registry-unknown stays honest (`REGISTRY_UNAVAILABLE`) without inventing
25
+ a bump.
26
+ - **Skill content drift honesty (FX03):** upgrade preview/JSON `skillDrift` counts
27
+ current/stale/customized/missing skills with sample paths and preserve notes.
28
+ - **Opt-in skill refresh (FX04):** `--refresh-skills` rewrites customized managed *skills* to
29
+ package templates with explicit consent. Conflicted assets still need `--accept-conflicts`.
30
+ Never silent overwrite of true user edits by default.
31
+ - **Post-upgrade verification block (FX05):** after apply, advisory `postUpgradeChecks` (pin↔CLI,
32
+ architecture verification, dual-truth, doctor/status/MCP notes). Always `notAScore`.
33
+ - **MCP multi-project process honesty (FX06):** every MCP tool context includes
34
+ `processPackage` (`processArkgateVersion`, `projectInstalledVersion`,
35
+ `processPackageMismatch` / `processStale`, `nextAction`). Prefer project-local CLI until
36
+ identity matched and versions align; multi-checkout recipe in agent-guide + `/ark-upgrade`.
37
+ - **Host selection honesty (FX07):** upgrade notes when detected active host ∉ managed
38
+ `--tools`/manifest hosts and suggests `--tools` expansion.
39
+ - **whatsNew early path (FX08):** suggested improvements on preview even when nothing to apply;
40
+ includes FX field-truth try/inspect items.
41
+ - **Inventory UX message quieting (FX09):** rules inventory downranks pure UX/error-message
42
+ string constants so they do not crowd business-rule pilots (no new sensors).
43
+
44
+ ### Changed
45
+
46
+ - `/ark-upgrade` skill + upgrade help document registry-aware install, `--refresh-skills`,
47
+ skill drift, post-upgrade checks, and multi-project MCP restart guidance.
48
+
49
+ **Freezes held:** no principle scores/ranks; no new skill names; no silent customized overwrite;
50
+ no silent multi-project MCP retarget; soft hosts stay advisory; no fake published claims.
51
+
8
52
  ## 4.5.5 — 2026-08-11
9
53
 
10
54
  **Patch** over **4.5.0**. Deep-module coach train plus upgrade **suggested improvements /
11
55
  what’s new** so consumers know what to try after install. **No required config migration.**
12
56
  No new skill names, sensors, or scores. Freezes held.
13
57
 
14
- **Status: prepared** (implementation only; not yet on npm `latest` remains `4.5.0` until
15
- the publication checklist completes).
58
+ **Status: published** (`arkgate@4.5.5` on npm `latest`; see `docs/releases/4.5.5.md`).
16
59
 
17
60
  ### Added
18
61
 
package/README.md CHANGED
@@ -16,9 +16,8 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 4.5.5** is **prepared** (deep-module coach + upgrade what’s new). npm `latest` remains
20
- > **4.5.0** until publish. [4.5.5 notes](docs/releases/4.5.5.md) · [4.5.0](docs/releases/4.5.0.md) ·
21
- > [4.4.0](docs/releases/4.4.0.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
19
+ > **ArkGate 4.5.5** is on npm `latest` — deep-module coach, upgrade what’s new, session honesty.
20
+ > [4.5.5 notes](docs/releases/4.5.5.md) · [4.5.0](docs/releases/4.5.0.md) · [4.4.0](docs/releases/4.4.0.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
22
21
 
23
22
  ---
24
23
 
@@ -209,8 +208,8 @@ for real systems. Details: [docs/production-hardening.md](docs/production-harden
209
208
  | Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
210
209
  | Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
211
210
  | Security | [SECURITY.md](SECURITY.md) |
212
- | Prepared (4.5.5) | [docs/releases/4.5.5.md](docs/releases/4.5.5.md) · [CHANGELOG](CHANGELOG.md) |
213
- | Current published (4.5.0 on npm `latest`) | [docs/releases/4.5.0.md](docs/releases/4.5.0.md) |
211
+ | Current release (4.5.5 on npm `latest`) | [docs/releases/4.5.5.md](docs/releases/4.5.5.md) · [CHANGELOG](CHANGELOG.md) |
212
+ | Prior (4.5.0) | [docs/releases/4.5.0.md](docs/releases/4.5.0.md) |
214
213
  | Prior (4.4.0) | [docs/releases/4.4.0.md](docs/releases/4.4.0.md) |
215
214
  | Prior (4.3.0) | [docs/releases/4.3.0.md](docs/releases/4.3.0.md) |
216
215
  | Prior (4.2.1) | [docs/releases/4.2.1.md](docs/releases/4.2.1.md) |
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { createRequire } from 'node:module';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import {
10
11
  DEFAULT_INTENT_PREFIXES,
@@ -1847,11 +1848,59 @@ export async function runArkMcp({ hookInput } = {}) {
1847
1848
  return currentBinding;
1848
1849
  }
1849
1850
 
1851
+ /**
1852
+ * FX06 — process package vs project install honesty (multi-project field truth).
1853
+ * Process arkgateVersion is startup-loaded; after consumer `npm i arkgate@newer`,
1854
+ * long-lived MCP can report a stale process version until restart.
1855
+ */
1856
+ function readProjectInstalledArkgateVersion() {
1857
+ try {
1858
+ const shallow = path.join(resolvedRoot, 'node_modules', 'arkgate', 'package.json');
1859
+ if (fs.existsSync(shallow)) {
1860
+ const v = JSON.parse(fs.readFileSync(shallow, 'utf8')).version;
1861
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
1862
+ }
1863
+ } catch {
1864
+ /* fall through */
1865
+ }
1866
+ try {
1867
+ const requireFromProject = createRequire(path.join(resolvedRoot, 'package.json'));
1868
+ const pkgJson = requireFromProject.resolve('arkgate/package.json');
1869
+ const v = JSON.parse(fs.readFileSync(pkgJson, 'utf8')).version;
1870
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
1871
+ } catch {
1872
+ return null;
1873
+ }
1874
+ }
1875
+
1876
+ function processPackageHonesty() {
1877
+ const processVersion = typeof ark.version === 'string' ? ark.version : null;
1878
+ const projectInstalledVersion = readProjectInstalledArkgateVersion();
1879
+ const mismatch =
1880
+ processVersion != null &&
1881
+ projectInstalledVersion != null &&
1882
+ processVersion !== projectInstalledVersion;
1883
+ return {
1884
+ schemaVersion: '1.0',
1885
+ notAScore: true,
1886
+ processArkgateVersion: processVersion,
1887
+ projectInstalledVersion,
1888
+ processPackageMismatch: mismatch,
1889
+ processStale: mismatch,
1890
+ nextAction: mismatch
1891
+ ? 'Restart or retarget the Ark MCP server so process arkgateVersion matches the project install. Prefer project-local CLI (`npx arkgate` / `npx arkgate-check`) until identity is matched and versions align. Multi-checkout users: one expectedRoot per project; never reuse another checkout’s projectId.'
1892
+ : projectInstalledVersion == null
1893
+ ? 'Project has no resolvable node_modules/arkgate; install the package or use CLI from a project that pins arkgate.'
1894
+ : 'Process package version matches project install for this MCP root.',
1895
+ };
1896
+ }
1897
+
1850
1898
  function contextFor(binding) {
1851
1899
  return {
1852
1900
  projectIdentity,
1853
1901
  binding,
1854
1902
  authoritative: binding.authoritative,
1903
+ processPackage: processPackageHonesty(),
1855
1904
  };
1856
1905
  }
1857
1906
 
@@ -722,35 +722,14 @@ export function packageInstallArgv(root, versionSpec = 'latest') {
722
722
  return ['npm', ['install', '-D', pkgSpec]];
723
723
  }
724
724
 
725
- /**
726
- * Whether an install of arkgate@latest can be skipped because node_modules already
727
- * resolves the same version as this CLI package.
728
- *
729
- * @param {string} root
730
- * @param {string} [cliVersion] this binary's package version
731
- * @returns {{ skip: boolean, installedVersion: string|null, reason: string }}
732
- */
733
- export function shouldSkipArkgateInstall(root, cliVersion) {
734
- const pkgPath = path.join(root, 'node_modules', 'arkgate', 'package.json');
735
- if (!fs.existsSync(pkgPath)) {
736
- return { skip: false, installedVersion: null, reason: 'not-installed' };
737
- }
738
- let installedVersion = null;
739
- try {
740
- installedVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? null;
741
- } catch {
742
- return { skip: false, installedVersion: null, reason: 'unreadable' };
743
- }
744
- if (
745
- typeof cliVersion === 'string' &&
746
- cliVersion &&
747
- installedVersion &&
748
- installedVersion === cliVersion
749
- ) {
750
- return { skip: true, installedVersion, reason: 'already-current' };
751
- }
752
- return { skip: false, installedVersion, reason: 'version-differs' };
753
- }
725
+ // FX01–FX02: registry-aware skip lives in upgrade-package-decision (injectable probe).
726
+ export {
727
+ shouldSkipArkgateInstall,
728
+ buildPackageInstallSkipPayload,
729
+ formatPackageInstallDecisionHuman,
730
+ compareSemverCore,
731
+ probeRegistryArkgateLatest,
732
+ } from './lib/upgrade-package-decision.mjs';
754
733
 
755
734
  /** Package-manager aware "install a dev dependency" hint (e.g. for a missing typescript). */
756
735
  export function installDevHint(root, pkg) {
package/bin/ark.mjs CHANGED
@@ -77,6 +77,7 @@ function parseArgs(argv) {
77
77
  installExplicit: false,
78
78
  apply: false,
79
79
  acceptConflicts: false,
80
+ refreshSkills: false,
80
81
  planDigest: undefined,
81
82
  json: false,
82
83
  internalApply: false,
@@ -118,6 +119,7 @@ function parseArgs(argv) {
118
119
  }
119
120
  else if (arg === '--apply') args.apply = true;
120
121
  else if (arg === '--accept-conflicts') args.acceptConflicts = true;
122
+ else if (arg === '--refresh-skills') args.refreshSkills = true;
121
123
  else if (arg === '--plan-digest') args.planDigest = requireValue(arg, i++);
122
124
  else if (arg === '--json') args.json = true;
123
125
  else if (arg === '--internal-apply') args.internalApply = true;
@@ -153,7 +155,7 @@ function usage() {
153
155
  ark start [--root <project>] [--tools <host>] [--require-write-hook <host>] [--install] [--apply] [--json]
154
156
  ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture]
155
157
  [--archetype <playbook-id>] [--tools <list>] [--require-write-hook <host>] [--yes] [--force] [--no-strict]
156
- ark upgrade [--root <project>] [--tools <list>] [--apply] [--plan-digest <sha256>] [--accept-conflicts] [--json] [--no-install] [--no-strict]
158
+ ark upgrade [--root <project>] [--tools <list>] [--apply] [--plan-digest <sha256>] [--accept-conflicts] [--refresh-skills] [--json] [--no-install] [--no-strict]
157
159
  ark preflight --changes <change-set.json> [--change-map <map.json>] [--root <project>] [--config ark.config.json] [--manifest <manifest.json>] [--tsconfig <tsconfig.json>] [--json]
158
160
  ark status [--root <project>] [--config ark.config.json] [--json]
159
161
  [--expected-root <abs>] [--expected-project-id sha256:…] [--tools <host>]
@@ -164,8 +166,10 @@ Commands:
164
166
  start New here? Analyze and preview the complete setup. Read-only unless --apply.
165
167
  init Configure Ark project enforcement with explicit prompts.
166
168
  upgrade Preview identity-proven Ark-managed asset updates. With package install,
167
- --apply bumps to @latest and recomputes the preview; a second explicit
168
- --apply --no-install applies those exact bytes and verifies them.
169
+ --apply bumps toward registry latest when behind (not only when CLI ≠ pin)
170
+ and recomputes the preview; a second explicit --apply --no-install applies
171
+ those exact bytes and verifies them. --refresh-skills opts in to rewrite
172
+ customized managed skills to package templates (never silent default).
169
173
  (alias: ark update)
170
174
  preflight Validate one atomic create/update/delete set without writing project files.
171
175
  status Unified session/project manifest (identity, activation, last check, rules).
@@ -465,11 +465,19 @@ export function planManagedUpgrade(root, options = {}) {
465
465
  kind: catalogAsset.kind,
466
466
  });
467
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';
468
475
  const canApply =
469
476
  classified.state === 'stale' ||
477
+ skillRefresh ||
470
478
  (classified.state === 'missing' && (!recorded || accepted)) ||
471
479
  (classified.state === 'conflicted' && accepted);
472
- const blocked = classified.requiresConsent && !accepted;
480
+ const blocked = classified.requiresConsent && !accepted && !skillRefresh;
473
481
  const desiredFile = afterFileContent(catalogAsset, currentFile, desiredScoped);
474
482
  const asset = {
475
483
  path: catalogAsset.relativePath,
@@ -542,6 +550,7 @@ export function planManagedUpgrade(root, options = {}) {
542
550
  profile: selection.profile,
543
551
  hosts: selection.hosts,
544
552
  acceptConflicts: options.acceptConflicts === true,
553
+ refreshSkills: options.refreshSkills === true,
545
554
  assets,
546
555
  summary,
547
556
  nextManifest,
@@ -552,6 +561,183 @@ export function planManagedUpgrade(root, options = {}) {
552
561
  return plan;
553
562
  }
554
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
+
555
741
  function publicPlan(plan, overrides = {}) {
556
742
  const assets = plan.assets.map(
557
743
  ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
@@ -784,6 +970,17 @@ export function renderManagedUpgrade(plan, options = {}) {
784
970
  for (const line of formatManagedUpgradeSelfServiceHonesty(honesty)) {
785
971
  console.log(line);
786
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).
787
984
  const whatsNew = plan.whatsNew ?? buildUpgradeWhatsNewSuggestions();
788
985
  for (const line of formatUpgradeWhatsNewSuggestions(whatsNew)) {
789
986
  console.log(line);
@@ -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;