agentic-workflow-manager 9.14.1 → 9.15.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 (36) hide show
  1. package/dist/src/commands/context-budget/budget.js +50 -4
  2. package/dist/src/commands/doctor.js +4 -1
  3. package/dist/src/commands/hooks/cursor.js +359 -0
  4. package/dist/src/commands/hooks/install.js +3 -0
  5. package/dist/src/commands/hooks/resync.js +10 -0
  6. package/dist/src/commands/hooks/status.js +3 -0
  7. package/dist/src/commands/hooks/uninstall.js +3 -0
  8. package/dist/src/commands/update.js +4 -0
  9. package/dist/src/core/context/strategies/codex-agents.js +6 -2
  10. package/dist/src/core/cursor-budgets.js +8 -0
  11. package/dist/src/core/dashboard/sanitize.js +2 -2
  12. package/dist/src/core/diagnostics/provider-checks.js +11 -2
  13. package/dist/src/core/init/migrate-cursor-mdc.js +72 -0
  14. package/dist/src/core/init/steps.js +4 -0
  15. package/dist/src/providers/index.js +18 -4
  16. package/dist/tests/commands/add.test.js +5 -7
  17. package/dist/tests/commands/context-budget/budget.test.js +68 -0
  18. package/dist/tests/commands/doctor.test.js +2 -2
  19. package/dist/tests/commands/hooks/cursor.test.js +257 -0
  20. package/dist/tests/commands/hooks/resync.test.js +8 -0
  21. package/dist/tests/commands/init.test.js +23 -22
  22. package/dist/tests/commands/plan/index.test.js +56 -2
  23. package/dist/tests/core/bundle-install.test.js +8 -14
  24. package/dist/tests/core/context/strategies/codex-agents.test.js +31 -12
  25. package/dist/tests/core/diagnostics/provider-tier.test.js +46 -96
  26. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +6 -4
  27. package/dist/tests/core/init/migrate-cursor-mdc.test.js +74 -0
  28. package/dist/tests/core/install-planner.test.js +16 -12
  29. package/dist/tests/integration/cursor-subset-gate.test.js +123 -0
  30. package/dist/tests/providers/index.test.js +34 -21
  31. package/dist/tests/structural/provider-paths-honor-config-home.test.js +3 -2
  32. package/package.json +1 -1
  33. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  34. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  35. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  36. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ // cli/src/core/init/migrate-cursor-mdc.ts
3
+ //
4
+ // Plan B S3 / R8: after Cursor skills move to link under ~/.agents/skills, remove
5
+ // only artifact-state records that were rendered with cursor-mdc under Cursor rules
6
+ // dirs. Never touch unrecorded files; never remove awm.mdc (project guidance carrier).
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.migrateCursorMdcSkills = migrateCursorMdcSkills;
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const paths_1 = require("../paths");
15
+ const artifact_state_1 = require("../artifact-state");
16
+ function isUnderCursorRules(targetPath, projectRoot) {
17
+ const resolved = path_1.default.resolve(targetPath);
18
+ const globalRules = path_1.default.resolve((0, paths_1.homeDir)(), '.cursor', 'rules');
19
+ if (resolved === globalRules || resolved.startsWith(globalRules + path_1.default.sep))
20
+ return true;
21
+ if (!projectRoot)
22
+ return false;
23
+ const localRules = path_1.default.resolve(projectRoot, '.cursor', 'rules');
24
+ return resolved === localRules || resolved.startsWith(localRules + path_1.default.sep);
25
+ }
26
+ function isAwmMdcCarrier(record) {
27
+ return record.name === 'awm' || path_1.default.basename(record.targetPath) === 'awm.mdc';
28
+ }
29
+ function safeUnlinkManaged(targetPath) {
30
+ try {
31
+ const stat = fs_1.default.lstatSync(targetPath);
32
+ if (!stat.isFile() || stat.isSymbolicLink())
33
+ return false;
34
+ fs_1.default.unlinkSync(targetPath);
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return false;
40
+ throw error;
41
+ }
42
+ }
43
+ /**
44
+ * Drop historical cursor-mdc skill renders under Cursor rules dirs once link
45
+ * skills are the delivery path. Idempotent; safe on machines without cursor.
46
+ */
47
+ function migrateCursorMdcSkills(opts) {
48
+ const stateFile = opts.stateFile ?? (0, artifact_state_1.artifactStateFile)();
49
+ const existing = (0, artifact_state_1.readArtifactState)(stateFile);
50
+ const removed = [];
51
+ const kept = [];
52
+ for (const record of existing) {
53
+ if (record.renderer !== 'cursor-mdc') {
54
+ kept.push(record);
55
+ continue;
56
+ }
57
+ if (isAwmMdcCarrier(record)) {
58
+ kept.push(record);
59
+ continue;
60
+ }
61
+ if (!isUnderCursorRules(record.targetPath, opts.projectRoot)) {
62
+ kept.push(record);
63
+ continue;
64
+ }
65
+ safeUnlinkManaged(record.targetPath);
66
+ removed.push(record.targetPath);
67
+ }
68
+ if (removed.length > 0) {
69
+ (0, artifact_state_1.writeArtifactState)(kept, stateFile);
70
+ }
71
+ return { removed, kept: kept.length };
72
+ }
@@ -35,6 +35,7 @@ const skill_integrity_1 = require("../skill-integrity");
35
35
  const registries_2 = require("../registries");
36
36
  const project_constitution_inject_1 = require("../context/project-constitution-inject");
37
37
  const codex_agents_1 = require("../context/strategies/codex-agents");
38
+ const migrate_cursor_mdc_1 = require("./migrate-cursor-mdc");
38
39
  // ---------------------------------------------------------------------------
39
40
  // defaultActions — bridges the real functions to the InitActions interface
40
41
  // ---------------------------------------------------------------------------
@@ -231,6 +232,9 @@ function stepDevCore(d) {
231
232
  ...(localOnly ? { scopeOverride: 'local' } : {}),
232
233
  });
233
234
  }
235
+ if (d.enabledAgents.includes('cursor') || d.agent === 'cursor') {
236
+ (0, migrate_cursor_mdc_1.migrateCursorMdcSkills)({ projectRoot: d.ctx.project?.root ?? d.cwd });
237
+ }
234
238
  return ok('machine.devCore', 'machine', 'applied', localOnly ? 'installed at project scope (no global skill dir)' : undefined);
235
239
  }
236
240
  /** Step 3.5 – Repair broken global skill symlinks (orphans outside the baseline). */
@@ -163,13 +163,27 @@ function providers() {
163
163
  cursor: {
164
164
  label: 'Cursor',
165
165
  configHome: configHomeFor('cursor'),
166
+ // Skills join the Codex/OpenCode shared physical dirs (~/.agents/skills +
167
+ // .agents/skills, renderer link). cursor-mdc remains registered for S3
168
+ // migration reads of historical ~/.cursor/rules installs.
166
169
  skill: {
167
- global: path_1.default.join(root('cursor'), 'rules'),
168
- local: '.cursor/rules',
169
- renderer: 'cursor-mdc',
170
+ global: path_1.default.join(home, '.agents/skills'),
171
+ local: '.agents/skills',
172
+ renderer: 'link',
170
173
  },
171
174
  workflow: null,
172
- agent: null,
175
+ agent: {
176
+ global: path_1.default.join(root('cursor'), 'agents'),
177
+ local: '.cursor/agents',
178
+ renderer: 'link',
179
+ },
180
+ hooks: {
181
+ type: 'cursor-hooks-json',
182
+ settingsPath: path_1.default.join(root('cursor'), 'hooks.json'),
183
+ scriptsDir: path_1.default.join(awm, 'hooks/cursor'),
184
+ matcher: '',
185
+ eventName: '',
186
+ },
173
187
  injection: {
174
188
  type: 'managed-agents-md',
175
189
  // Cursor has no confirmed user-level/global AGENTS.md-equivalent file — its
@@ -65,18 +65,16 @@ function makeProjectRoot() {
65
65
  fs_1.default.writeFileSync(path_1.default.join(projectRoot, 'package.json'), JSON.stringify({ name: 'fixture' }));
66
66
  return projectRoot;
67
67
  }
68
- it('awm add demo materializes a real Cursor .mdc file end-to-end', () => {
68
+ it('awm add demo materializes a Cursor skill link under .agents/skills end-to-end', () => {
69
69
  const content = makeContentFixture();
70
70
  const projectRoot = makeProjectRoot();
71
71
  const bundles = (0, bundles_1.discoverBundles)(content);
72
72
  const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'cursor', cwd: projectRoot }, prefsFor('cursor'), bundles);
73
73
  expect(outcome.code).toBe(0);
74
- const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/demo-skill.mdc');
75
- expect(fs_1.default.existsSync(mdcPath)).toBe(true);
76
- const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
77
- expect(rendered).toContain('description: A demo skill for add.test.ts e2e');
78
- expect(rendered).toContain('alwaysApply: false');
79
- expect(rendered).toContain('Follow the demo skill body.');
74
+ const linked = path_1.default.join(projectRoot, '.agents/skills/demo-skill');
75
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/demo-skill.mdc'))).toBe(false);
76
+ expect(fs_1.default.lstatSync(linked).isSymbolicLink() || fs_1.default.existsSync(path_1.default.join(linked, 'SKILL.md'))).toBe(true);
77
+ expect(fs_1.default.readFileSync(path_1.default.join(linked, 'SKILL.md'), 'utf8')).toContain('Follow the demo skill body.');
80
78
  fs_1.default.rmSync(content, { recursive: true, force: true });
81
79
  fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
82
80
  });
@@ -134,3 +134,71 @@ describe('pinning refuses to happen when there is nothing to measure', () => {
134
134
  expect(fs_1.default.existsSync(path_1.default.join(cwd, budget_1.CONFIG_FILE))).toBe(true);
135
135
  });
136
136
  });
137
+ describe('Cursor hook budget pins (Plan B R5)', () => {
138
+ let tmpHome;
139
+ let originalHome;
140
+ let originalAwmHome;
141
+ const dirs = [];
142
+ beforeEach(() => {
143
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-cursor-budget-home-'));
144
+ originalHome = process.env.HOME;
145
+ originalAwmHome = process.env.AWM_HOME;
146
+ process.env.HOME = tmpHome;
147
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
148
+ jest.resetModules();
149
+ });
150
+ afterEach(() => {
151
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
152
+ dirs.forEach((d) => fs_1.default.rmSync(d, { recursive: true, force: true }));
153
+ if (originalHome === undefined)
154
+ delete process.env.HOME;
155
+ else
156
+ process.env.HOME = originalHome;
157
+ if (originalAwmHome === undefined)
158
+ delete process.env.AWM_HOME;
159
+ else
160
+ process.env.AWM_HOME = originalAwmHome;
161
+ });
162
+ it('skips cursor pins when hooks are not installed', () => {
163
+ const { checkBudget } = require('../../../src/commands/context-budget/budget');
164
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-budget-'));
165
+ dirs.push(dir);
166
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), 'a'.repeat(100));
167
+ const report = checkBudget(dir);
168
+ expect(report.cursorHooks).toBeUndefined();
169
+ expect(report.status).toBe('pinned');
170
+ });
171
+ it('marks over when installed script caps exceed pin or disagree with module constants', () => {
172
+ const { checkBudget, writeConfig } = require('../../../src/commands/context-budget/budget');
173
+ const { CURSOR_SESSION_START_MAX_BYTES, CURSOR_REANCHOR_MAX_BYTES, } = require('../../../src/core/cursor-budgets');
174
+ expect(CURSOR_SESSION_START_MAX_BYTES).toBe(24 * 1024);
175
+ expect(CURSOR_REANCHOR_MAX_BYTES).toBe(4 * 1024);
176
+ const scripts = path_1.default.join(tmpHome, '.awm/hooks/cursor');
177
+ fs_1.default.mkdirSync(scripts, { recursive: true });
178
+ fs_1.default.writeFileSync(path_1.default.join(scripts, 'session-start'), 'const MAX_SESSION_BYTES = 24 * 1024;\nconst MAX_REANCHOR_BYTES = 4 * 1024;\n');
179
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-budget-'));
180
+ dirs.push(dir);
181
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), 'a'.repeat(100));
182
+ writeConfig(dir, {
183
+ files: ['AGENTS.md'],
184
+ maxBytes: 1000,
185
+ 'cursor.sessionStartMaxBytes': 1024, // below observed 24KiB
186
+ 'cursor.reanchorMaxBytes': 4096,
187
+ });
188
+ const report = checkBudget(dir);
189
+ expect(report.cursorHooks?.status).toBe('over');
190
+ expect(report.status).toBe('over');
191
+ });
192
+ it('is within when script literals match defaults', () => {
193
+ const { checkBudget } = require('../../../src/commands/context-budget/budget');
194
+ const scripts = path_1.default.join(tmpHome, '.awm/hooks/cursor');
195
+ fs_1.default.mkdirSync(scripts, { recursive: true });
196
+ fs_1.default.writeFileSync(path_1.default.join(scripts, 'session-start'), 'const MAX_SESSION_BYTES = 24 * 1024;\nconst MAX_REANCHOR_BYTES = 4 * 1024;\n');
197
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-budget-'));
198
+ dirs.push(dir);
199
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), 'a'.repeat(100));
200
+ const report = checkBudget(dir);
201
+ expect(report.cursorHooks?.status).toBe('within');
202
+ expect(report.cursorHooks?.observedSessionStartMaxBytes).toBe(24 * 1024);
203
+ });
204
+ });
@@ -229,13 +229,13 @@ describe('renderProviderReport — capability tier (Task 4.4)', () => {
229
229
  overall: 'healthy',
230
230
  providers: [
231
231
  { id: 'claude-code', label: 'Claude Code', tier: 'hooks-native', checks: [] },
232
- { id: 'cursor', label: 'Cursor', tier: 'agents-md-managed', checks: [] },
232
+ { id: 'cursor', label: 'Cursor', tier: 'hooks-native', checks: [] },
233
233
  { id: 'antigravity', label: 'Antigravity', tier: 'context-only', checks: [] },
234
234
  ],
235
235
  };
236
236
  const out = (0, doctor_1.renderProviderReport)(report);
237
237
  expect(out).toContain('Provider: Claude Code (hooks-native)');
238
- expect(out).toContain('Provider: Cursor (agents-md-managed)');
238
+ expect(out).toContain('Provider: Cursor (hooks-native)');
239
239
  expect(out).toContain('Provider: Antigravity (context-only)');
240
240
  });
241
241
  });
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ describe('installHook / computeHookStatus / uninstallHook — Cursor adapter', () => {
11
+ let tmpHome;
12
+ let tmpRegistry;
13
+ let hooksJson;
14
+ let cursorScriptsDir;
15
+ let originalHome;
16
+ let originalAwmHome;
17
+ beforeEach(() => {
18
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-cursor-hooks-'));
19
+ tmpRegistry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-cursor-registry-'));
20
+ hooksJson = path_1.default.join(tmpHome, '.cursor/hooks.json');
21
+ cursorScriptsDir = path_1.default.join(tmpHome, '.awm/hooks/cursor');
22
+ originalHome = process.env.HOME;
23
+ originalAwmHome = process.env.AWM_HOME;
24
+ process.env.HOME = tmpHome;
25
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
26
+ jest.resetModules();
27
+ });
28
+ afterEach(() => {
29
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
30
+ fs_1.default.rmSync(tmpRegistry, { recursive: true, force: true });
31
+ if (originalHome === undefined)
32
+ delete process.env.HOME;
33
+ else
34
+ process.env.HOME = originalHome;
35
+ if (originalAwmHome === undefined)
36
+ delete process.env.AWM_HOME;
37
+ else
38
+ process.env.AWM_HOME = originalAwmHome;
39
+ });
40
+ function writeRegistry(content = '#!/usr/bin/env node\nconsole.log("{}")') {
41
+ const regHooks = path_1.default.join(tmpRegistry, 'hooks');
42
+ const regSkill = path_1.default.join(tmpRegistry, 'skills/using-awm');
43
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
44
+ fs_1.default.mkdirSync(regSkill, { recursive: true });
45
+ for (const name of ['cursor-session-start', 'cursor-pre-compact', 'cursor-post-tool-use']) {
46
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, name), content, { mode: 0o755 });
47
+ }
48
+ fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.\n');
49
+ }
50
+ function installOpts(over = {}) {
51
+ return {
52
+ agent: 'cursor',
53
+ registryRoot: tmpRegistry,
54
+ installMethod: 'copy',
55
+ baselineRegistryVersion: '4.9.0',
56
+ ...over,
57
+ };
58
+ }
59
+ function awmEntries() {
60
+ return {
61
+ sessionStart: [{ command: path_1.default.join(cursorScriptsDir, 'session-start') }],
62
+ preCompact: [{ command: path_1.default.join(cursorScriptsDir, 'pre-compact') }],
63
+ postToolUse: [{ command: path_1.default.join(cursorScriptsDir, 'post-tool-use') }],
64
+ };
65
+ }
66
+ it('merges three AWM events and preserves non-AWM entries', () => {
67
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
68
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
69
+ version: 1,
70
+ hooks: {
71
+ sessionStart: [{ command: '/user/custom-start' }],
72
+ stop: [{ command: 'echo stop' }],
73
+ },
74
+ }));
75
+ writeRegistry();
76
+ const { installHook } = require('../../../src/commands/hooks/install');
77
+ installHook(installOpts());
78
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
79
+ expect(cfg.version).toBe(1);
80
+ expect(cfg.hooks.stop).toEqual([{ command: 'echo stop' }]);
81
+ expect(cfg.hooks.sessionStart).toEqual([
82
+ { command: '/user/custom-start' },
83
+ { command: path_1.default.join(cursorScriptsDir, 'session-start') },
84
+ ]);
85
+ expect(cfg.hooks.preCompact).toEqual([{ command: path_1.default.join(cursorScriptsDir, 'pre-compact') }]);
86
+ expect(cfg.hooks.postToolUse).toEqual([{ command: path_1.default.join(cursorScriptsDir, 'post-tool-use') }]);
87
+ expect(fs_1.default.existsSync(path_1.default.join(cursorScriptsDir, 'session-start'))).toBe(true);
88
+ expect(fs_1.default.readFileSync(path_1.default.join(cursorScriptsDir, 'using-awm.md'), 'utf8')).toContain('MUST invoke skills.');
89
+ });
90
+ it('installs cleanly with no pre-existing hooks.json and backs up on replace', () => {
91
+ writeRegistry();
92
+ const { installHook } = require('../../../src/commands/hooks/install');
93
+ const first = installHook(installOpts({ installMethod: 'symlink' }));
94
+ expect(first.status).toBe('installed');
95
+ expect(first.backupPath).toBeNull();
96
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
97
+ expect(cfg).toEqual({ version: 1, hooks: awmEntries() });
98
+ expect(fs_1.default.lstatSync(path_1.default.join(cursorScriptsDir, 'session-start')).isSymbolicLink()).toBe(true);
99
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({ version: 1, hooks: { sessionStart: [] } }, null, 2));
100
+ const second = installHook(installOpts());
101
+ expect(second.status).toBe('installed');
102
+ expect(second.backupPath).toMatch(/hooks\.json\..*\.bak$/);
103
+ });
104
+ it('fails when registry lacks cursor-session-start', () => {
105
+ const regHooks = path_1.default.join(tmpRegistry, 'hooks');
106
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
107
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'cursor-pre-compact'), 'x', { mode: 0o755 });
108
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'cursor-post-tool-use'), 'x', { mode: 0o755 });
109
+ fs_1.default.mkdirSync(path_1.default.join(tmpRegistry, 'skills/using-awm'), { recursive: true });
110
+ fs_1.default.writeFileSync(path_1.default.join(tmpRegistry, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\n---\n');
111
+ const { installHook } = require('../../../src/commands/hooks/install');
112
+ expect(() => installHook(installOpts())).toThrow(/Cursor hook source missing/);
113
+ expect(fs_1.default.existsSync(hooksJson)).toBe(false);
114
+ });
115
+ it('refuses invalid JSON, unknown version, and duplicate AWM entries without writing', () => {
116
+ writeRegistry();
117
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
118
+ fs_1.default.writeFileSync(hooksJson, '{not json');
119
+ const beforeBad = fs_1.default.readFileSync(hooksJson, 'utf8');
120
+ const { installHook } = require('../../../src/commands/hooks/install');
121
+ expect(() => installHook(installOpts())).toThrow(/not valid JSON/);
122
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(beforeBad);
123
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({ version: 2, hooks: {} }));
124
+ const beforeVer = fs_1.default.readFileSync(hooksJson, 'utf8');
125
+ expect(() => installHook(installOpts())).toThrow(/unsupported hooks\.json version/);
126
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(beforeVer);
127
+ const dup = {
128
+ version: 1,
129
+ hooks: {
130
+ sessionStart: [
131
+ { command: path_1.default.join(cursorScriptsDir, 'session-start') },
132
+ { command: path_1.default.join(cursorScriptsDir, 'session-start') },
133
+ ],
134
+ },
135
+ };
136
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify(dup, null, 2));
137
+ const beforeDup = fs_1.default.readFileSync(hooksJson, 'utf8');
138
+ expect(() => installHook(installOpts())).toThrow(/multiple AWM sessionStart/);
139
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(beforeDup);
140
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/backups'))).toBe(false);
141
+ });
142
+ it('aborts when an event key is present but not an array', () => {
143
+ writeRegistry();
144
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
145
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({ version: 1, hooks: { sessionStart: { command: 'x' } } }));
146
+ const before = fs_1.default.readFileSync(hooksJson, 'utf8');
147
+ const { installHook } = require('../../../src/commands/hooks/install');
148
+ expect(() => installHook(installOpts())).toThrow(/must be an array/);
149
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(before);
150
+ });
151
+ it('prunes dead AWM entries from another AWM_HOME and preserves live parallels', () => {
152
+ writeRegistry();
153
+ const dead = '/tmp/awm-gone-never/hooks/cursor/session-start';
154
+ const liveOther = path_1.default.join(tmpHome, '.awm-other/hooks/cursor/session-start');
155
+ fs_1.default.mkdirSync(path_1.default.dirname(liveOther), { recursive: true });
156
+ fs_1.default.writeFileSync(liveOther, '#!/usr/bin/env node\n', { mode: 0o755 });
157
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
158
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
159
+ version: 1,
160
+ hooks: {
161
+ sessionStart: [
162
+ { command: dead },
163
+ { command: liveOther },
164
+ ],
165
+ },
166
+ }, null, 2));
167
+ const { installHook } = require('../../../src/commands/hooks/install');
168
+ installHook(installOpts());
169
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
170
+ expect(cfg.hooks.sessionStart.map((e) => e.command)).toEqual([
171
+ liveOther,
172
+ path_1.default.join(cursorScriptsDir, 'session-start'),
173
+ ]);
174
+ });
175
+ it('is idempotent when nothing changed', () => {
176
+ writeRegistry();
177
+ const { installHook } = require('../../../src/commands/hooks/install');
178
+ const first = installHook(installOpts());
179
+ expect(first.status).toBe('installed');
180
+ const contentBefore = fs_1.default.readFileSync(hooksJson, 'utf8');
181
+ const mtimeBefore = fs_1.default.statSync(hooksJson).mtimeMs;
182
+ const second = installHook(installOpts());
183
+ expect(second.status).toBe('already-up-to-date');
184
+ expect(second.backupPath).toBeNull();
185
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(contentBefore);
186
+ expect(fs_1.default.statSync(hooksJson).mtimeMs).toBe(mtimeBefore);
187
+ });
188
+ it.each([
189
+ [false, 'pending-first-run'],
190
+ [true, 'healthy'],
191
+ ])('derives trust from heartbeat (present=%s → %s)', (heartbeat, expected) => {
192
+ fs_1.default.mkdirSync(cursorScriptsDir, { recursive: true });
193
+ const scriptPath = path_1.default.join(cursorScriptsDir, 'session-start');
194
+ fs_1.default.writeFileSync(scriptPath, '#!/usr/bin/env node\n', { mode: 0o755 });
195
+ fs_1.default.writeFileSync(path_1.default.join(cursorScriptsDir, 'using-awm.md'), '# using-awm\n');
196
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
197
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({ version: 1, hooks: awmEntries() }, null, 2));
198
+ if (heartbeat) {
199
+ fs_1.default.writeFileSync(path_1.default.join(cursorScriptsDir, 'heartbeat.json'), JSON.stringify({ hash: crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(scriptPath)).digest('hex') }));
200
+ }
201
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
202
+ expect(computeHookStatus('cursor').trust).toBe(expected);
203
+ });
204
+ it('reports stale trust when heartbeat hash mismatches', () => {
205
+ fs_1.default.mkdirSync(cursorScriptsDir, { recursive: true });
206
+ const scriptPath = path_1.default.join(cursorScriptsDir, 'session-start');
207
+ fs_1.default.writeFileSync(scriptPath, '#!/usr/bin/env node\n', { mode: 0o755 });
208
+ fs_1.default.writeFileSync(path_1.default.join(cursorScriptsDir, 'using-awm.md'), '# using-awm\n');
209
+ fs_1.default.writeFileSync(path_1.default.join(cursorScriptsDir, 'heartbeat.json'), JSON.stringify({ hash: 'deadbeef' }));
210
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
211
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({ version: 1, hooks: awmEntries() }, null, 2));
212
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
213
+ const status = computeHookStatus('cursor');
214
+ expect(status.trust).toBe('stale');
215
+ expect(status.overall).toBe('DEGRADED');
216
+ });
217
+ it('refuses install when baseline registry is below 4.9.0 and accepts 4.9.0', () => {
218
+ writeRegistry();
219
+ const { installHook } = require('../../../src/commands/hooks/install');
220
+ expect(() => installHook(installOpts({ baselineRegistryVersion: '4.8.1' })))
221
+ .toThrow("Cursor hooks require baseline registry >= 4.9.0; installed is 4.8.1. Run 'awm update -y'.");
222
+ expect(fs_1.default.existsSync(hooksJson)).toBe(false);
223
+ expect(installHook(installOpts({ baselineRegistryVersion: '4.9.0' })).status).toBe('installed');
224
+ });
225
+ it('shouldDisableDeferredReanchor follows R16.2 thresholds', () => {
226
+ const { shouldDisableDeferredReanchor } = require('../../../src/commands/hooks/cursor');
227
+ expect(shouldDisableDeferredReanchor({ deliveryVerified: false, p95Ms: 10 })).toBe(true);
228
+ expect(shouldDisableDeferredReanchor({ deliveryVerified: true, p95Ms: 51 })).toBe(true);
229
+ expect(shouldDisableDeferredReanchor({ deliveryVerified: true, p95Ms: 50 })).toBe(false);
230
+ });
231
+ it('omits deferred events when disableDeferredReanchor is set', () => {
232
+ writeRegistry();
233
+ const { installHook } = require('../../../src/commands/hooks/install');
234
+ installHook(installOpts({ disableDeferredReanchor: true }));
235
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
236
+ expect(cfg.hooks.sessionStart).toEqual([{ command: path_1.default.join(cursorScriptsDir, 'session-start') }]);
237
+ expect(cfg.hooks.preCompact).toBeUndefined();
238
+ expect(cfg.hooks.postToolUse).toBeUndefined();
239
+ expect(fs_1.default.existsSync(path_1.default.join(cursorScriptsDir, 'session-start'))).toBe(true);
240
+ expect(fs_1.default.existsSync(path_1.default.join(cursorScriptsDir, 'pre-compact'))).toBe(false);
241
+ });
242
+ it('uninstall removes only AWM Cursor entries', () => {
243
+ writeRegistry();
244
+ const { installHook } = require('../../../src/commands/hooks/install');
245
+ const { uninstallHook } = require('../../../src/commands/hooks/uninstall');
246
+ installHook(installOpts());
247
+ const current = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
248
+ current.hooks.sessionStart.unshift({ command: '/user/keep' });
249
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify(current, null, 2));
250
+ const result = uninstallHook({ agent: 'cursor' });
251
+ expect(result.status).toBe('uninstalled');
252
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
253
+ expect(cfg.hooks.sessionStart).toEqual([{ command: '/user/keep' }]);
254
+ expect(cfg.hooks.preCompact).toBeUndefined();
255
+ expect(cfg.hooks.postToolUse).toBeUndefined();
256
+ });
257
+ });
@@ -65,6 +65,7 @@ describe('resyncInstalledHooks', () => {
65
65
  expect(results).toEqual([
66
66
  { agent: 'claude-code', action: 'resynced' },
67
67
  { agent: 'codex', action: 'not-installed' },
68
+ { agent: 'cursor', action: 'not-installed' },
68
69
  ]);
69
70
  const settings = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.claude/settings.json'), 'utf8'));
70
71
  expect(settings.hooks.SubagentStart[0].hooks[0].command).toBe('awm model-policy hook-event --event start');
@@ -85,6 +86,7 @@ describe('resyncInstalledHooks', () => {
85
86
  expect(results).toEqual([
86
87
  { agent: 'claude-code', action: 'not-installed' },
87
88
  { agent: 'codex', action: 'not-installed' },
89
+ { agent: 'cursor', action: 'not-installed' },
88
90
  ]);
89
91
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks/session-start'))).toBe(false);
90
92
  });
@@ -117,6 +119,7 @@ describe('resyncInstalledHooks', () => {
117
119
  expect(results).toEqual([
118
120
  { agent: 'claude-code', action: 'resynced' },
119
121
  { agent: 'codex', action: 'not-installed' },
122
+ { agent: 'cursor', action: 'not-installed' },
120
123
  ]);
121
124
  expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false);
122
125
  const content = fs_1.default.readFileSync(skillDest, 'utf-8');
@@ -135,6 +138,7 @@ describe('resyncInstalledHooks', () => {
135
138
  expect(results).toEqual([
136
139
  { agent: 'claude-code', action: 'resynced' },
137
140
  { agent: 'codex', action: 'not-installed' },
141
+ { agent: 'cursor', action: 'not-installed' },
138
142
  ]);
139
143
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(true);
140
144
  // Task 6: using-awm.md is materialized regardless of the scripts' install method —
@@ -151,6 +155,7 @@ describe('resyncInstalledHooks', () => {
151
155
  expect(results).toEqual([
152
156
  { agent: 'claude-code', action: 'registry-missing' },
153
157
  { agent: 'codex', action: 'not-installed' },
158
+ { agent: 'cursor', action: 'not-installed' },
154
159
  ]);
155
160
  expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'session-start'), 'utf-8')).toContain('OLD');
156
161
  });
@@ -166,6 +171,7 @@ describe('resyncInstalledHooks', () => {
166
171
  expect(results).toEqual([
167
172
  { agent: 'claude-code', action: 'resynced' },
168
173
  { agent: 'codex', action: 'not-installed' },
174
+ { agent: 'cursor', action: 'not-installed' },
169
175
  ]);
170
176
  expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'session-start'))).toBe(true);
171
177
  // detectInstallMethod fell back to copy since lstatSync threw
@@ -189,6 +195,7 @@ describe('resyncInstalledHooks', () => {
189
195
  expect(results).toEqual([
190
196
  { agent: 'claude-code', action: 'registry-missing' },
191
197
  { agent: 'codex', action: 'not-installed' },
198
+ { agent: 'cursor', action: 'not-installed' },
192
199
  ]);
193
200
  // old script left intact — never leave user without hook
194
201
  expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'session-start'), 'utf-8')).toContain('OLD');
@@ -219,6 +226,7 @@ describe('resyncInstalledHooks', () => {
219
226
  expect(results).toEqual([
220
227
  { agent: 'claude-code', action: 'not-installed' },
221
228
  { agent: 'codex', action: 'resynced' },
229
+ { agent: 'cursor', action: 'not-installed' },
222
230
  ]);
223
231
  expect(fs_1.default.readFileSync(path_1.default.join(codexScriptsDir, 'session-start'), 'utf-8')).toContain('NEW CODEX');
224
232
  });
@@ -395,20 +395,22 @@ describe('runInit', () => {
395
395
  expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
396
396
  });
397
397
  // -----------------------------------------------------------------------
398
- // Gap A (QA panel) — GLOBAL-scope render pipeline for Cursor/Copilot was
399
- // only ever exercised via LOCAL-scope `awm add`/bundle-install tests
400
- // (tests/core/bundle-install.test.ts). This drives a real `awm init
401
- // --agent cursor` through the REAL installBundle/applyInstallPlan path
402
- // (no installBundle override — only syncCache is stubbed, to avoid a
403
- // real network clone) and asserts a real rendered .mdc file lands in the
404
- // GLOBAL ~/.cursor/rules directory with the expected frontmatter shape.
398
+ // Gap A (QA panel) — GLOBAL-scope Cursor skill delivery. Plan B installs
399
+ // Cursor skills as links under ~/.agents/skills (shared with Codex/
400
+ // OpenCode) plus native hooks.json entries (registry floor >= 4.9.0).
401
+ // Drives a real `awm init --agent cursor` through installBundle/
402
+ // applyInstallPlan (only syncCache stubbed) and asserts the link + hooks.
405
403
  // -----------------------------------------------------------------------
406
- it('Gap A — real global-scope render: awm init --agent cursor renders a real .mdc into ~/.cursor/rules', async () => {
404
+ it('Gap A — real global-scope link: awm init --agent cursor links using-awm into ~/.agents/skills', async () => {
405
+ const { spawnSync } = require('child_process');
407
406
  const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
408
- // A 'hooks' dir (even empty) is what makes capabilityRoot('hooks') resolve to
409
- // this content root — that's the registryRoot stepContextInjection uses to
410
- // find skills/using-awm/SKILL.md for the (unrelated) context-injection step.
407
+ // A 'hooks' dir is what makes capabilityRoot('hooks') resolve to this content
408
+ // root — registryRoot for stepContextInjection / stepHook. Plan B also needs
409
+ // the three Cursor hook scripts and an exact v4.9.0+ tag for the registry floor.
411
410
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
411
+ for (const name of ['cursor-session-start', 'cursor-pre-compact', 'cursor-post-tool-use']) {
412
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', name), '#!/usr/bin/env node\nconsole.log("{}")\n', { mode: 0o755 });
413
+ }
412
414
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', 'using-awm'), { recursive: true });
413
415
  fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'skills', 'using-awm', 'SKILL.md'), '---\nname: using-awm\ndescription: Use when starting any development conversation\n---\n\nMUST invoke skills per the tiered policy.\n');
414
416
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'bundles', 'dev-core'), { recursive: true });
@@ -420,6 +422,10 @@ describe('runInit', () => {
420
422
  name: 'dev-core', version: '1.0.0', description: 'Baseline', scope: 'baseline',
421
423
  dependsOn: [], skills: ['using-awm'], workflows: [], agents: [],
422
424
  }));
425
+ spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: contentRoot, stdio: 'ignore' });
426
+ spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: contentRoot, stdio: 'ignore' });
427
+ spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'], { cwd: contentRoot, stdio: 'ignore' });
428
+ spawnSync('git', ['tag', 'v4.9.0'], { cwd: contentRoot, stdio: 'ignore' });
423
429
  const projectCwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-cursor-cwd-'));
424
430
  try {
425
431
  const { runInit } = require('../../src/commands/init');
@@ -431,20 +437,15 @@ describe('runInit', () => {
431
437
  // seedBaselineRegistry() writes registries.json pointing at 'baseline'
432
438
  // (our manually-seeded content root above) since none exists yet — no
433
439
  // real network clone happens because that content root already exists
434
- // on disk. Only syncCache is stubbed, so stepCache's own sync (the
435
- // content root above has no `.git`, so it reads as "not yet cloned")
436
- // doesn't try to shell out to git either.
440
+ // on disk. Only syncCache is stubbed.
437
441
  actions: { syncCache: async () => { } },
438
442
  });
439
443
  expect(code).toBeLessThanOrEqual(1);
440
- const mdcPath = path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm.mdc');
441
- expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm'))).toBe(false);
442
- expect(fs_1.default.existsSync(mdcPath)).toBe(true);
443
- const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
444
- expect(rendered).toContain('description: Use when starting any development conversation');
445
- expect(rendered).toContain('globs:');
446
- expect(rendered).toContain('alwaysApply: false');
447
- expect(rendered).toContain('MUST invoke skills per the tiered policy.');
444
+ const linked = path_1.default.join(tmpHome, '.agents', 'skills', 'using-awm');
445
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm.mdc'))).toBe(false);
446
+ expect(fs_1.default.lstatSync(linked).isSymbolicLink() || fs_1.default.existsSync(path_1.default.join(linked, 'SKILL.md'))).toBe(true);
447
+ expect(fs_1.default.readFileSync(path_1.default.join(linked, 'SKILL.md'), 'utf8')).toContain('MUST invoke skills per the tiered policy.');
448
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'hooks.json'))).toBe(true);
448
449
  }
449
450
  finally {
450
451
  fs_1.default.rmSync(projectCwd, { recursive: true, force: true });