arkgate 4.5.5 → 4.5.7

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.
@@ -70,9 +70,27 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
70
70
  true,
71
71
  true
72
72
  ),
73
- cursor: hostProfile('Cursor', null, null, [], false, false, {
74
- operationCoverage: { shell: false, 'pre-commit': false },
75
- }),
73
+ // Cursor: official preToolUse deny / exit 2 is a hard block for matched tools when
74
+ // `.cursor/hooks.json` is installed + trusted. Claim hard only for Write|StrReplace;
75
+ // Shell/Tab/human edits still rely on CI. Repair envelope may emit; Write updated_input
76
+ // reinjection is not guaranteed on Cursor (agent_message + retry is the supported path).
77
+ cursor: hostProfile(
78
+ 'Cursor',
79
+ '.cursor/hooks.json',
80
+ 'preToolUse `Write` / `StrReplace`',
81
+ ['Write', 'StrReplace'],
82
+ true,
83
+ false,
84
+ {
85
+ repairEnvelopeEmitted: true,
86
+ operationCoverage: {
87
+ Write: true,
88
+ StrReplace: true,
89
+ shell: false,
90
+ 'pre-commit': false,
91
+ },
92
+ }
93
+ ),
76
94
  codex: hostProfile(
77
95
  'OpenAI Codex',
78
96
  '.codex/hooks.json',
@@ -134,14 +152,15 @@ export function renderHostSupportMatrixMarkdown() {
134
152
  const rows = HOST_SUPPORT_HOSTS.map((host) => {
135
153
  const profile = HOST_SUPPORT_MATRIX[host];
136
154
  const capabilities = profile.capabilities;
137
- // Fail-closed honesty: Cursor/Codex/OpenCode never claim hard write; CI is required-status.
138
- // hookSurface already includes "PreToolUse …" do not prefix PreToolUse again.
155
+ // Fail-closed honesty: Codex/OpenCode never claim hard write; CI is required-status.
156
+ // Cursor claims hard only for listed preToolUse ops when hooks are installed + trusted.
157
+ // hookSurface already includes "PreToolUse/preToolUse …" — do not prefix again.
139
158
  let local;
140
159
  if (capabilities['hard-write']) {
141
160
  local = `**Hard** block for listed ops (${profile.hookSurface}) when installed + trusted`;
142
161
  } else if (host === 'codex') {
143
162
  local =
144
- '**Advisory / best-effort** at write (not equivalent to Claude/Grok hard block)';
163
+ '**Advisory / best-effort** at write (not equivalent to Claude/Grok/Cursor hard block)';
145
164
  } else if (host === 'opencode') {
146
165
  local =
147
166
  '**Advisory / best-effort** at write (MCP + optional plugin; not a hard boundary)';
@@ -169,7 +188,8 @@ ${rows}
169
188
 
170
189
  **Read the CI column:** for every host, the repository-wide hard guarantee is a **required**
171
190
  GitHub **status context** that runs the CLI — not “CI file present,” and not the CLI binary name alone.
172
- Cursor/Codex/OpenCode never get a fake hard write claim.
191
+ Codex/OpenCode never get a fake hard write claim. Cursor hard write covers only listed
192
+ \`preToolUse\` ops when \`.cursor/hooks.json\` is installed and trusted — Shell/Tab/human edits still rely on CI.
173
193
 
174
194
  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.`;
175
195
  }
@@ -203,14 +223,14 @@ export function doctorWritePathHonestyMessage(activeHost, hardWriteActive) {
203
223
  // EH07: distinguish CLI command (arkgate-check / ark-check) from the GitHub required status context name.
204
224
  const mergeBoundary =
205
225
  'Required CI hard merge boundary = a required GitHub status context that runs arkgate-check --strict-merge (alias ark-check --strict-merge)';
206
- if (host === 'cursor') {
207
- return `Cursor: write path is advisory (MCP/rules; no hard PreToolUse). ${mergeBoundary}.`;
226
+ if (host === 'cursor' && !hardWriteActive) {
227
+ return `Cursor: hard preToolUse is supported for Write/StrReplace when .cursor/hooks.json is installed + trusted; without runtime-observed hook evidence, hard is unverified. ${mergeBoundary}.`;
208
228
  }
209
229
  if (host === 'codex') {
210
- return `Codex: write path is advisory / best-effort at write (not Claude/Grok hard). ${mergeBoundary}.`;
230
+ return `Codex: write path is advisory / best-effort at write (not Claude/Grok/Cursor hard). ${mergeBoundary}.`;
211
231
  }
212
232
  if (host === 'opencode') {
213
- return `OpenCode: write path is advisory / best-effort (MCP + optional plugin; not Claude/Grok/Antigravity hard). ${mergeBoundary}.`;
233
+ return `OpenCode: write path is advisory / best-effort (MCP + optional plugin; not Claude/Grok/Antigravity/Cursor hard). ${mergeBoundary}.`;
214
234
  }
215
235
  if ((host === 'claude' || host === 'grok' || host === 'antigravity') && !hardWriteActive) {
216
236
  const label =
@@ -25,9 +25,11 @@ import {
25
25
  claudeSettings,
26
26
  codexHooks,
27
27
  codexProjectConfig,
28
+ cursorHooks,
28
29
  grokHooks,
29
30
  grokProjectConfig,
30
31
  mergeAntigravityArkHook,
32
+ mergeCursorArkHook,
31
33
  mergeOpencodeArkMcp,
32
34
  opencodeProjectConfig,
33
35
  } from './hook-templates.mjs';
@@ -194,6 +196,9 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
194
196
  );
195
197
  if (selectedTools.has('cursor')) {
196
198
  add('.cursor/mcp.json', mcpJson(root));
199
+ // whole-file for managed upgrade/manifest (json-merge is install-time only via
200
+ // mergeCursorArkHook below — same pattern as Antigravity hooks).
201
+ add('.cursor/hooks.json', cursorHooks(root));
197
202
  if (!compact) add('.cursor/rules/ark.mdc', cursorRule(root));
198
203
  }
199
204
  if (selectedTools.has('claude')) add('.claude/settings.json', claudeSettings(root));
@@ -516,6 +521,25 @@ export function runInstallAgentGates(args) {
516
521
  if (merged === existing) return { relativePath, status: 'skipped' };
517
522
  return writeTemplate(root, relativePath, merged, true);
518
523
  }
524
+ if (relativePath === '.cursor/hooks.json') {
525
+ const fullPath = path.join(root, relativePath);
526
+ let existing = '';
527
+ try {
528
+ existing = fs.readFileSync(fullPath, 'utf8');
529
+ } catch {
530
+ // Missing hooks file → write generated Cursor preToolUse gate.
531
+ }
532
+ if (!existing) {
533
+ return writeTemplate(root, relativePath, content, true);
534
+ }
535
+ const merged = mergeCursorArkHook(existing, content);
536
+ if (merged == null) {
537
+ return { relativePath, status: 'skipped-non-ark' };
538
+ }
539
+ if (merged === existing) return { relativePath, status: 'skipped' };
540
+ // Upsert Ark preToolUse without requiring --force; never wipe sibling hooks.
541
+ return writeTemplate(root, relativePath, merged, true);
542
+ }
519
543
  if (relativePath === '.agents/hooks.json') {
520
544
  const fullPath = path.join(root, relativePath);
521
545
  let existing = '';
@@ -126,7 +126,12 @@ function hasArkText(root, relativePath) {
126
126
 
127
127
  const HOST_SIGNALS = {
128
128
  claude: ['.claude/settings.json', '.claude/skills/ark-upgrade/SKILL.md'],
129
- cursor: ['.cursor/mcp.json', '.cursor/rules/ark.mdc', '.cursor/commands/ark-upgrade.md'],
129
+ cursor: [
130
+ '.cursor/mcp.json',
131
+ '.cursor/hooks.json',
132
+ '.cursor/rules/ark.mdc',
133
+ '.cursor/commands/ark-upgrade.md',
134
+ ],
130
135
  codex: ['.codex/hooks.json', '.codex/config.toml', '.agents/skills/ark-upgrade/SKILL.md'],
131
136
  grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json', '.grok/skills/ark-upgrade/SKILL.md'],
132
137
  antigravity: ['.agents/hooks.json', '.agents/skills/ark-upgrade/SKILL.md'],
@@ -465,11 +470,19 @@ export function planManagedUpgrade(root, options = {}) {
465
470
  kind: catalogAsset.kind,
466
471
  });
467
472
  const accepted = options.acceptConflicts === true;
473
+ // FX04: --refresh-skills opt-in rewrites customized *skill* assets to package
474
+ // templates. Conflicted still needs --accept-conflicts. Never silent default.
475
+ const refreshSkills = options.refreshSkills === true;
476
+ const skillRefresh =
477
+ refreshSkills &&
478
+ catalogAsset.kind === 'skill' &&
479
+ classified.state === 'customized';
468
480
  const canApply =
469
481
  classified.state === 'stale' ||
482
+ skillRefresh ||
470
483
  (classified.state === 'missing' && (!recorded || accepted)) ||
471
484
  (classified.state === 'conflicted' && accepted);
472
- const blocked = classified.requiresConsent && !accepted;
485
+ const blocked = classified.requiresConsent && !accepted && !skillRefresh;
473
486
  const desiredFile = afterFileContent(catalogAsset, currentFile, desiredScoped);
474
487
  const asset = {
475
488
  path: catalogAsset.relativePath,
@@ -542,6 +555,7 @@ export function planManagedUpgrade(root, options = {}) {
542
555
  profile: selection.profile,
543
556
  hosts: selection.hosts,
544
557
  acceptConflicts: options.acceptConflicts === true,
558
+ refreshSkills: options.refreshSkills === true,
545
559
  assets,
546
560
  summary,
547
561
  nextManifest,
@@ -552,6 +566,183 @@ export function planManagedUpgrade(root, options = {}) {
552
566
  return plan;
553
567
  }
554
568
 
569
+ /**
570
+ * FX03 — skill content drift honesty (counts by state + sample paths).
571
+ * Skills only; never claims "skills upgraded" when only package pin moved.
572
+ */
573
+ export function buildSkillDriftSummary(plan) {
574
+ const assets = Array.isArray(plan?.assets) ? plan.assets : [];
575
+ const skills = assets.filter((a) => a?.kind === 'skill');
576
+ const byState = {};
577
+ for (const skill of skills) {
578
+ const state = typeof skill.state === 'string' ? skill.state : 'unknown';
579
+ byState[state] = (byState[state] ?? 0) + 1;
580
+ }
581
+ const sample = (state, limit = 5) =>
582
+ skills
583
+ .filter((s) => s.state === state)
584
+ .map((s) => s.path)
585
+ .sort()
586
+ .slice(0, limit);
587
+ const customized = byState.customized ?? 0;
588
+ const stale = byState.stale ?? 0;
589
+ const missing = byState.missing ?? 0;
590
+ const current = byState.current ?? 0;
591
+ const wouldRefresh = skills.filter((s) => s.willApply === true).length;
592
+ return {
593
+ schemaVersion: '1.0',
594
+ notAScore: true,
595
+ skillCount: skills.length,
596
+ byState,
597
+ stale,
598
+ customized,
599
+ missing,
600
+ current,
601
+ wouldRefresh,
602
+ samplePaths: {
603
+ stale: sample('stale'),
604
+ customized: sample('customized'),
605
+ missing: sample('missing'),
606
+ },
607
+ note:
608
+ customized > 0 && wouldRefresh === 0
609
+ ? 'Skills on disk differ from package templates (customized preserved). Use --refresh-skills to opt in to rewrite customized skills; never silent overwrite.'
610
+ : stale > 0
611
+ ? 'Some skills are stale vs package templates and will refresh on apply.'
612
+ : skills.length === 0
613
+ ? 'No managed skill assets in this upgrade selection.'
614
+ : 'Skill content matches package templates or is scheduled for write.',
615
+ };
616
+ }
617
+
618
+ /**
619
+ * FX07 — active host vs managed --tools / manifest hosts.
620
+ */
621
+ export function buildHostSelectionHonesty(plan) {
622
+ const hosts = Array.isArray(plan?.hosts) ? plan.hosts.map((h) => String(h).toLowerCase()) : [];
623
+ let active = null;
624
+ try {
625
+ active = detectActiveAgentHost();
626
+ } catch {
627
+ active = null;
628
+ }
629
+ const activeNorm =
630
+ typeof active === 'string' && active.trim() ? active.trim().toLowerCase() : null;
631
+ const known = activeNorm && KNOWN_TOOLS.includes(activeNorm);
632
+ const inSelection = Boolean(activeNorm && hosts.includes(activeNorm));
633
+ const note =
634
+ known && !inSelection
635
+ ? `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.`
636
+ : known && inSelection
637
+ ? `Detected host "${activeNorm}" is in the managed selection.`
638
+ : activeNorm
639
+ ? `Detected host "${activeNorm}" is outside the known managed tool set.`
640
+ : 'No active agent host detected for this process.';
641
+ return {
642
+ schemaVersion: '1.0',
643
+ notAScore: true,
644
+ activeHost: activeNorm,
645
+ managedHosts: hosts,
646
+ activeInSelection: inSelection,
647
+ suggestTools:
648
+ known && !inSelection
649
+ ? [...new Set([...hosts, activeNorm])].sort().join(',')
650
+ : null,
651
+ note,
652
+ };
653
+ }
654
+
655
+ /**
656
+ * FX05 — post-upgrade verification block (advisory sensors only).
657
+ */
658
+ export function buildPostUpgradeChecks(root, options = {}) {
659
+ const resolvedRoot = path.resolve(root);
660
+ const checks = [];
661
+ let projectVersion = null;
662
+ try {
663
+ const pkgPath = path.join(resolvedRoot, 'node_modules', 'arkgate', 'package.json');
664
+ if (fs.existsSync(pkgPath)) {
665
+ projectVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? null;
666
+ }
667
+ } catch {
668
+ projectVersion = null;
669
+ }
670
+ const cli = typeof options.cliVersion === 'string' ? options.cliVersion : arkPackageVersion();
671
+ const pinOk =
672
+ projectVersion != null && cli != null ? projectVersion === cli : null;
673
+ checks.push({
674
+ id: 'package-pin-cli',
675
+ ok: pinOk,
676
+ detail:
677
+ pinOk === true
678
+ ? `Installed arkgate@${projectVersion} matches CLI ${cli}.`
679
+ : pinOk === false
680
+ ? `Installed arkgate@${projectVersion} ≠ CLI ${cli}; re-run install or restart using project-local CLI.`
681
+ : `Could not compare pin (installed=${projectVersion ?? 'missing'}, cli=${cli ?? 'unknown'}).`,
682
+ });
683
+ checks.push({
684
+ id: 'architecture-verification',
685
+ ok:
686
+ options.verification?.mode === 'skipped'
687
+ ? null
688
+ : options.verification?.exitCode === 0,
689
+ detail:
690
+ options.verification?.mode === 'skipped'
691
+ ? 'Strict architecture verification was skipped (--no-strict).'
692
+ : options.verification?.exitCode === 0
693
+ ? 'Strict-merge architecture verification passed.'
694
+ : `Architecture verification exit ${options.verification?.exitCode ?? 'unknown'}.`,
695
+ });
696
+ checks.push({
697
+ id: 'package-version-truth',
698
+ ok: options.dualTruth?.dualTruth === true ? false : options.dualTruth ? true : null,
699
+ detail:
700
+ options.dualTruth?.dualTruth === true
701
+ ? options.dualTruth.note || 'Package pin dual-truth detected.'
702
+ : options.dualTruth
703
+ ? 'Package pin truth is consistent for this apply.'
704
+ : 'Package version truth not evaluated.',
705
+ });
706
+ checks.push({
707
+ id: 'doctor-compass-coach',
708
+ ok: null,
709
+ detail:
710
+ 'Run `npx arkgate-check --doctor --json` and confirm doctor.improvementCompass + doctor.deepModuleCoach (notAScore).',
711
+ });
712
+ checks.push({
713
+ id: 'agents-md-projection',
714
+ ok: null,
715
+ detail: 'Run `npx arkgate agents-md --check` (or --write) so AGENTS.md matches the package projection.',
716
+ });
717
+ checks.push({
718
+ id: 'status-mode',
719
+ ok: null,
720
+ detail: 'Run `npx arkgate status --json` and read honesty mode; incomplete facts never invent green residual.',
721
+ });
722
+ return {
723
+ schemaVersion: '1.0',
724
+ notAScore: true,
725
+ neverGateInput: true,
726
+ checks,
727
+ mcpNote:
728
+ '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.',
729
+ };
730
+ }
731
+
732
+ export function formatSkillDriftHuman(skillDrift) {
733
+ if (!skillDrift) return [];
734
+ const lines = [
735
+ `Skill drift: ${skillDrift.skillCount} skill(s) — current ${skillDrift.current}, stale ${skillDrift.stale}, customized ${skillDrift.customized}, missing ${skillDrift.missing}, would refresh ${skillDrift.wouldRefresh}.`,
736
+ ];
737
+ if (skillDrift.note) lines.push(` ${skillDrift.note}`);
738
+ return lines;
739
+ }
740
+
741
+ export function formatHostSelectionHuman(hostSelection) {
742
+ if (!hostSelection?.note) return [];
743
+ return [`Host selection: ${hostSelection.note}`];
744
+ }
745
+
555
746
  function publicPlan(plan, overrides = {}) {
556
747
  const assets = plan.assets.map(
557
748
  ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
@@ -784,6 +975,17 @@ export function renderManagedUpgrade(plan, options = {}) {
784
975
  for (const line of formatManagedUpgradeSelfServiceHonesty(honesty)) {
785
976
  console.log(line);
786
977
  }
978
+ const skillDrift =
979
+ options.skillDrift ?? plan.skillDrift ?? buildSkillDriftSummary(plan);
980
+ for (const line of formatSkillDriftHuman(skillDrift)) {
981
+ console.log(line);
982
+ }
983
+ const hostSelection =
984
+ options.hostSelection ?? plan.hostSelection ?? buildHostSelectionHonesty(plan);
985
+ for (const line of formatHostSelectionHuman(hostSelection)) {
986
+ console.log(line);
987
+ }
988
+ // FX08: whatsNew always on preview/apply human path (including nothing-to-apply).
787
989
  const whatsNew = plan.whatsNew ?? buildUpgradeWhatsNewSuggestions();
788
990
  for (const line of formatUpgradeWhatsNewSuggestions(whatsNew)) {
789
991
  console.log(line);
@@ -27,7 +27,12 @@ export const COMMAND_GATE_TEXT_FILES = [
27
27
  '.grok/hooks/ark-write-gate.json', '.grok/config.toml', '.codex/config.toml',
28
28
  '.agents/hooks.json',
29
29
  ];
30
- export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json', 'opencode.json'];
30
+ export const COMMAND_GATE_JSON_FILES = [
31
+ '.mcp.json',
32
+ '.cursor/mcp.json',
33
+ '.cursor/hooks.json',
34
+ 'opencode.json',
35
+ ];
31
36
  // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
32
37
  // before re-emitting a single preferred bin — otherwise a partial rename leaves
33
38
  // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * FX06 — MCP process package vs project install honesty.
3
+ *
4
+ * Pure-ish helpers so unit tests need no MCP server. Production MCP runtime
5
+ * calls buildProcessPackageHonesty with the process version + project root.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { createRequire } from 'node:module';
10
+
11
+ /**
12
+ * Resolve installed arkgate version for a project root (shallow node_modules,
13
+ * then Node module resolution for hoisted monorepos).
14
+ * @param {string} root
15
+ * @returns {string|null}
16
+ */
17
+ export function readProjectInstalledArkgateVersion(root) {
18
+ const resolvedRoot = path.resolve(root);
19
+ try {
20
+ const shallow = path.join(resolvedRoot, 'node_modules', 'arkgate', 'package.json');
21
+ if (fs.existsSync(shallow)) {
22
+ const v = JSON.parse(fs.readFileSync(shallow, 'utf8')).version;
23
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
24
+ }
25
+ } catch {
26
+ /* fall through */
27
+ }
28
+ try {
29
+ const requireFromProject = createRequire(path.join(resolvedRoot, 'package.json'));
30
+ const pkgJson = requireFromProject.resolve('arkgate/package.json');
31
+ const v = JSON.parse(fs.readFileSync(pkgJson, 'utf8')).version;
32
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {{
40
+ * processVersion?: string|null,
41
+ * projectInstalledVersion?: string|null,
42
+ * root?: string,
43
+ * }} input
44
+ * @returns {{
45
+ * schemaVersion: '1.0',
46
+ * notAScore: true,
47
+ * processArkgateVersion: string|null,
48
+ * projectInstalledVersion: string|null,
49
+ * processPackageMismatch: boolean,
50
+ * processStale: boolean,
51
+ * nextAction: string,
52
+ * }}
53
+ */
54
+ export function buildProcessPackageHonesty(input = {}) {
55
+ const processVersion =
56
+ typeof input.processVersion === 'string' && input.processVersion.trim()
57
+ ? input.processVersion.trim()
58
+ : null;
59
+ let projectInstalledVersion =
60
+ input.projectInstalledVersion !== undefined
61
+ ? input.projectInstalledVersion
62
+ : null;
63
+ if (
64
+ projectInstalledVersion === null &&
65
+ input.projectInstalledVersion === undefined &&
66
+ typeof input.root === 'string' &&
67
+ input.root
68
+ ) {
69
+ projectInstalledVersion = readProjectInstalledArkgateVersion(input.root);
70
+ }
71
+ if (typeof projectInstalledVersion === 'string') {
72
+ projectInstalledVersion = projectInstalledVersion.trim() || null;
73
+ } else if (projectInstalledVersion !== null) {
74
+ projectInstalledVersion = null;
75
+ }
76
+
77
+ const mismatch =
78
+ processVersion != null &&
79
+ projectInstalledVersion != null &&
80
+ processVersion !== projectInstalledVersion;
81
+
82
+ return {
83
+ schemaVersion: '1.0',
84
+ notAScore: true,
85
+ processArkgateVersion: processVersion,
86
+ projectInstalledVersion,
87
+ processPackageMismatch: mismatch,
88
+ processStale: mismatch,
89
+ nextAction: mismatch
90
+ ? '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.'
91
+ : projectInstalledVersion == null
92
+ ? 'Project has no resolvable node_modules/arkgate; install the package or use CLI from a project that pins arkgate.'
93
+ : 'Process package version matches project install for this MCP root.',
94
+ };
95
+ }
@@ -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;
@@ -10,6 +10,7 @@ import {
10
10
  claudeSettings,
11
11
  codexHooks,
12
12
  codexProjectConfig,
13
+ cursorHooks,
13
14
  grokHooks,
14
15
  grokProjectConfig,
15
16
  opencodeProjectConfig,
@@ -30,7 +31,10 @@ const COMPACT_HOST_TEMPLATES = {
30
31
  ['.agents/hooks.json', antigravityHooks(root)],
31
32
  ['.mcp.json', mcpJson(root)],
32
33
  ],
33
- cursor: (root) => [['.cursor/mcp.json', mcpJson(root)]],
34
+ cursor: (root) => [
35
+ ['.cursor/mcp.json', mcpJson(root)],
36
+ ['.cursor/hooks.json', cursorHooks(root)],
37
+ ],
34
38
  codex: (root) => [
35
39
  ['.codex/hooks.json', codexHooks(root)],
36
40
  ['.codex/config.toml', codexProjectConfig(root)],