agentic-workflow-manager 9.14.1 → 9.15.1

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 (41) 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/commands/watch/index.js +10 -0
  10. package/dist/src/core/admission/index.js +8 -3
  11. package/dist/src/core/context/strategies/codex-agents.js +6 -2
  12. package/dist/src/core/cursor-budgets.js +8 -0
  13. package/dist/src/core/dashboard/sanitize.js +2 -2
  14. package/dist/src/core/diagnostics/provider-checks.js +11 -2
  15. package/dist/src/core/init/migrate-cursor-mdc.js +72 -0
  16. package/dist/src/core/init/steps.js +4 -0
  17. package/dist/src/core/journal/durable-custody.js +19 -0
  18. package/dist/src/providers/index.js +18 -4
  19. package/dist/tests/commands/add.test.js +5 -7
  20. package/dist/tests/commands/context-budget/budget.test.js +68 -0
  21. package/dist/tests/commands/doctor.test.js +2 -2
  22. package/dist/tests/commands/hooks/cursor.test.js +257 -0
  23. package/dist/tests/commands/hooks/resync.test.js +8 -0
  24. package/dist/tests/commands/init.test.js +23 -22
  25. package/dist/tests/commands/plan/index.test.js +56 -2
  26. package/dist/tests/core/admission/index.test.js +19 -0
  27. package/dist/tests/core/bundle-install.test.js +8 -14
  28. package/dist/tests/core/context/strategies/codex-agents.test.js +31 -12
  29. package/dist/tests/core/diagnostics/provider-tier.test.js +46 -96
  30. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +6 -4
  31. package/dist/tests/core/init/migrate-cursor-mdc.test.js +74 -0
  32. package/dist/tests/core/install-planner.test.js +16 -12
  33. package/dist/tests/core/journal/durable-custody.test.js +44 -0
  34. package/dist/tests/integration/cursor-subset-gate.test.js +123 -0
  35. package/dist/tests/providers/index.test.js +34 -21
  36. package/dist/tests/structural/provider-paths-honor-config-home.test.js +3 -2
  37. package/package.json +1 -1
  38. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  39. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  40. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  41. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CURSOR_REANCHOR_PIN = exports.CURSOR_SESSION_START_PIN = exports.CURSOR_REANCHOR_MAX_BYTES = exports.CURSOR_SESSION_START_MAX_BYTES = void 0;
4
+ /** Cursor hook payload caps (Plan A / Plan B R5). Must match baseline registry scripts. */
5
+ exports.CURSOR_SESSION_START_MAX_BYTES = 24 * 1024;
6
+ exports.CURSOR_REANCHOR_MAX_BYTES = 4 * 1024;
7
+ exports.CURSOR_SESSION_START_PIN = 'cursor.sessionStartMaxBytes';
8
+ exports.CURSOR_REANCHOR_PIN = 'cursor.reanchorMaxBytes';
@@ -15,9 +15,9 @@ const CANONICAL_FINDING_IDS = new Set([
15
15
  'machine.preferences.missing', 'machine.registries.stale', 'project.profile.missing',
16
16
  'project.sensors.unavailable', 'project.preflight.degraded', 'planning.source.unavailable', 'execution.source.unavailable',
17
17
  ]);
18
- const PROVIDER_FINDING_ID = /^machine\.provider\.(?:claude-code|codex|opencode|cursor|copilot|antigravity)\.(?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|hook\.trust|guidance\.project|constitution\.delivery)$/;
18
+ const PROVIDER_FINDING_ID = /^machine\.provider\.(?:claude-code|codex|opencode|cursor|copilot|antigravity)\.(?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|context\.overlap|hook\.trust|guidance\.project|constitution\.delivery)$/;
19
19
  const PROJECT_FINDING_ID = /^project\.(?:profile\.present|extensions\.configured|registry-pins\.present|bundles\.coherent|context\.present|constitution\.present|sensors\.present|preflight\.not_collected)$/;
20
- const PROVIDER_LABEL = /^Provider (?:claude-code|codex|opencode|cursor|copilot|antigravity): (?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|hook\.trust|guidance\.project|constitution\.delivery)$/;
20
+ const PROVIDER_LABEL = /^Provider (?:claude-code|codex|opencode|cursor|copilot|antigravity): (?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|context\.overlap|hook\.trust|guidance\.project|constitution\.delivery)$/;
21
21
  // El slug ya lo validó PROCESS_NAME en core/process/model.ts. Repetir la forma
22
22
  // acá es deliberado: este módulo es la frontera de render y no puede confiar en
23
23
  // que su input pasó por aquel validador — un adapter nuevo podría no hacerlo.
@@ -375,11 +375,12 @@ function hookTrustCheck(agent) {
375
375
  return { id: 'hook.trust', state: 'absent', remediationCode: 'awm-init' };
376
376
  }
377
377
  if (status.trust) {
378
- // Codex: 'pending-trust' | 'healthy' | 'stale' map directly onto ProviderCheckState.
378
+ // Codex: 'pending-trust' | 'healthy' | 'stale'; Cursor: 'pending-first-run' | …
379
379
  return {
380
380
  id: 'hook.trust',
381
381
  state: status.trust,
382
- remediationCode: status.trust === 'pending-trust' ? 'open-hooks-trust' : undefined,
382
+ remediationCode: status.trust === 'pending-trust' ? 'open-hooks-trust'
383
+ : status.trust === 'pending-first-run' ? 'open-cursor-session' : undefined,
383
384
  };
384
385
  }
385
386
  return { id: 'hook.trust', state: status.overall === 'HEALTHY' ? 'healthy' : 'broken' };
@@ -470,6 +471,14 @@ function gatherProviderChecks(agents, scanSkills, projectRoot) {
470
471
  hookTrustCheck(agent),
471
472
  contextGlobalCheck(agent, projectRoot),
472
473
  ].filter((check) => check !== null);
474
+ // R19: informational only — never overall-red by itself (state: pending).
475
+ if (agent === 'cursor' && agents.includes('claude-code') && agents.includes('cursor')) {
476
+ checks.push({
477
+ id: 'context.overlap',
478
+ state: 'pending',
479
+ detail: 'Claude Code may import Cursor hooks as a third-party path; R15 dual-delivery suppression requires the Cursor session-start hook installed',
480
+ });
481
+ }
473
482
  return { id: agent, label: provider.label, tier: providerTier(provider), checks };
474
483
  });
475
484
  }
@@ -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). */
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /**
3
+ * Durable custody = `awm watch` + journal-bound supervisor/controller relaunch.
4
+ *
5
+ * Suspended for every provider until routing/custody work resumes. The supported
6
+ * unattended path is the native provider session (compact-slices/v1, no journal).
7
+ *
8
+ * The Jest suite sets `AWM_ALLOW_DURABLE_CUSTODY=1` so the machinery stays tested
9
+ * for when this suspension is lifted. Operators never set that variable.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.DURABLE_CUSTODY_SUSPENDED_MESSAGE = void 0;
13
+ exports.isDurableCustodySuspended = isDurableCustodySuspended;
14
+ exports.DURABLE_CUSTODY_SUSPENDED_MESSAGE = 'Durable custody (awm watch) is suspended for all providers until routing/custody work resumes. '
15
+ + 'Use native provider-session unattended (compact-slices/v1, no journal).';
16
+ /** True for operators; false only when tests explicitly re-enable the machinery. */
17
+ function isDurableCustodySuspended() {
18
+ return process.env.AWM_ALLOW_DURABLE_CUSTODY !== '1';
19
+ }
@@ -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
  });