agentic-workflow-manager 2.0.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 (145) hide show
  1. package/README.md +62 -0
  2. package/dist/src/commands/doctor.js +77 -0
  3. package/dist/src/commands/hooks/index.js +118 -0
  4. package/dist/src/commands/hooks/install.js +113 -0
  5. package/dist/src/commands/hooks/resync.js +50 -0
  6. package/dist/src/commands/hooks/status.js +74 -0
  7. package/dist/src/commands/hooks/uninstall.js +59 -0
  8. package/dist/src/commands/init.js +181 -0
  9. package/dist/src/commands/ledger/index.js +73 -0
  10. package/dist/src/commands/pin.js +75 -0
  11. package/dist/src/commands/registry/add.js +62 -0
  12. package/dist/src/commands/registry/index.js +166 -0
  13. package/dist/src/commands/registry/install-bundles.js +36 -0
  14. package/dist/src/commands/registry/remove.js +18 -0
  15. package/dist/src/commands/registry/status.js +31 -0
  16. package/dist/src/commands/sensors/baseline.js +93 -0
  17. package/dist/src/commands/sensors/formatters/eslint.js +29 -0
  18. package/dist/src/commands/sensors/formatters/generic.js +8 -0
  19. package/dist/src/commands/sensors/formatters/semgrep.js +18 -0
  20. package/dist/src/commands/sensors/formatters/test.js +12 -0
  21. package/dist/src/commands/sensors/formatters/tsc.js +23 -0
  22. package/dist/src/commands/sensors/index.js +94 -0
  23. package/dist/src/commands/sensors/init.js +112 -0
  24. package/dist/src/commands/sensors/install.js +78 -0
  25. package/dist/src/commands/sensors/run.js +217 -0
  26. package/dist/src/commands/sensors/status.js +85 -0
  27. package/dist/src/commands/sensors/types.js +2 -0
  28. package/dist/src/core/bundle-install.js +116 -0
  29. package/dist/src/core/bundles.js +122 -0
  30. package/dist/src/core/cli-version.js +30 -0
  31. package/dist/src/core/context/materializer.js +30 -0
  32. package/dist/src/core/context/orchestrator.js +75 -0
  33. package/dist/src/core/context/project-constitution-inject.js +47 -0
  34. package/dist/src/core/context/provider.js +29 -0
  35. package/dist/src/core/context/regenerate.js +61 -0
  36. package/dist/src/core/context/strategies/config-instructions.js +73 -0
  37. package/dist/src/core/context/strategies/hook-merge.js +23 -0
  38. package/dist/src/core/context/strategies/strategy.js +2 -0
  39. package/dist/src/core/context/types.js +2 -0
  40. package/dist/src/core/diagnostics/checks.js +141 -0
  41. package/dist/src/core/diagnostics/context.js +181 -0
  42. package/dist/src/core/diagnostics/types.js +2 -0
  43. package/dist/src/core/discovery.js +135 -0
  44. package/dist/src/core/executor.js +41 -0
  45. package/dist/src/core/init/detector.js +67 -0
  46. package/dist/src/core/init/orchestrator.js +54 -0
  47. package/dist/src/core/init/steps.js +279 -0
  48. package/dist/src/core/init/types.js +2 -0
  49. package/dist/src/core/ledger/store.js +73 -0
  50. package/dist/src/core/ledger/types.js +2 -0
  51. package/dist/src/core/miro.js +314 -0
  52. package/dist/src/core/profile-pins.js +36 -0
  53. package/dist/src/core/profile.js +146 -0
  54. package/dist/src/core/registries.js +205 -0
  55. package/dist/src/core/registry.js +28 -0
  56. package/dist/src/core/skill-integrity.js +97 -0
  57. package/dist/src/core/story-map-parser.js +83 -0
  58. package/dist/src/core/update-check-worker.js +10 -0
  59. package/dist/src/core/update-check.js +106 -0
  60. package/dist/src/core/versioning.js +112 -0
  61. package/dist/src/index.js +689 -0
  62. package/dist/src/providers/index.js +63 -0
  63. package/dist/src/utils/config.js +35 -0
  64. package/dist/src/utils/grouping.js +51 -0
  65. package/dist/src/utils/registry-view.js +154 -0
  66. package/dist/tests/commands/doctor.test.js +98 -0
  67. package/dist/tests/commands/hooks/install.test.js +149 -0
  68. package/dist/tests/commands/hooks/resync.test.js +135 -0
  69. package/dist/tests/commands/hooks/router.test.js +26 -0
  70. package/dist/tests/commands/hooks/status.test.js +88 -0
  71. package/dist/tests/commands/hooks/uninstall.test.js +104 -0
  72. package/dist/tests/commands/init.test.js +87 -0
  73. package/dist/tests/commands/ledger/index.test.js +64 -0
  74. package/dist/tests/commands/pin.test.js +72 -0
  75. package/dist/tests/commands/registry/add.test.js +223 -0
  76. package/dist/tests/commands/registry/install-bundles.test.js +87 -0
  77. package/dist/tests/commands/registry/remove.test.js +49 -0
  78. package/dist/tests/commands/registry/status.test.js +43 -0
  79. package/dist/tests/commands/sensors/baseline.test.js +106 -0
  80. package/dist/tests/commands/sensors/formatters/eslint.test.js +32 -0
  81. package/dist/tests/commands/sensors/formatters/semgrep.test.js +38 -0
  82. package/dist/tests/commands/sensors/formatters/tsc.test.js +25 -0
  83. package/dist/tests/commands/sensors/index.test.js +18 -0
  84. package/dist/tests/commands/sensors/init.test.js +137 -0
  85. package/dist/tests/commands/sensors/install.test.js +60 -0
  86. package/dist/tests/commands/sensors/router.test.js +23 -0
  87. package/dist/tests/commands/sensors/run.test.js +353 -0
  88. package/dist/tests/commands/sensors/status.test.js +98 -0
  89. package/dist/tests/core/base-remote.test.js +70 -0
  90. package/dist/tests/core/bundle-install.test.js +198 -0
  91. package/dist/tests/core/bundles-multiroot.test.js +68 -0
  92. package/dist/tests/core/bundles-overrides.test.js +49 -0
  93. package/dist/tests/core/bundles.test.js +96 -0
  94. package/dist/tests/core/cli-version.test.js +17 -0
  95. package/dist/tests/core/context/materializer.test.js +46 -0
  96. package/dist/tests/core/context/orchestrator.test.js +127 -0
  97. package/dist/tests/core/context/project-constitution-inject.test.js +82 -0
  98. package/dist/tests/core/context/provider.test.js +45 -0
  99. package/dist/tests/core/context/regenerate.test.js +106 -0
  100. package/dist/tests/core/context/strategies/config-instructions.test.js +88 -0
  101. package/dist/tests/core/context/strategies/hook-merge.test.js +71 -0
  102. package/dist/tests/core/context/types.test.js +15 -0
  103. package/dist/tests/core/diagnostics/checks.test.js +208 -0
  104. package/dist/tests/core/diagnostics/context.test.js +170 -0
  105. package/dist/tests/core/discovery-multiroot.test.js +63 -0
  106. package/dist/tests/core/discovery-overrides.test.js +105 -0
  107. package/dist/tests/core/discovery.test.js +113 -0
  108. package/dist/tests/core/executor.test.js +44 -0
  109. package/dist/tests/core/init/detector.test.js +56 -0
  110. package/dist/tests/core/init/orchestrator.test.js +122 -0
  111. package/dist/tests/core/init/steps.test.js +363 -0
  112. package/dist/tests/core/ledger/gitignore.test.js +13 -0
  113. package/dist/tests/core/ledger/store.test.js +122 -0
  114. package/dist/tests/core/miro-layout.test.js +150 -0
  115. package/dist/tests/core/profile-pins.test.js +131 -0
  116. package/dist/tests/core/profile-registries.test.js +59 -0
  117. package/dist/tests/core/profile.test.js +110 -0
  118. package/dist/tests/core/registries-capability.test.js +52 -0
  119. package/dist/tests/core/registries-seed.test.js +66 -0
  120. package/dist/tests/core/registries-sync.test.js +205 -0
  121. package/dist/tests/core/registries.test.js +91 -0
  122. package/dist/tests/core/registry-manifest.test.js +95 -0
  123. package/dist/tests/core/registry-versioned-sync.test.js +113 -0
  124. package/dist/tests/core/registry.test.js +51 -0
  125. package/dist/tests/core/skill-integrity.test.js +126 -0
  126. package/dist/tests/core/story-map-parser.test.js +136 -0
  127. package/dist/tests/core/sync-gates.test.js +97 -0
  128. package/dist/tests/core/update-check.test.js +106 -0
  129. package/dist/tests/core/versioning.test.js +169 -0
  130. package/dist/tests/integration/pack-e2e.test.js +50 -0
  131. package/dist/tests/providers/hooks-config.test.js +45 -0
  132. package/dist/tests/providers/index.test.js +58 -0
  133. package/dist/tests/providers/injection-config.test.js +25 -0
  134. package/dist/tests/registry/b3-ledger-wiring.test.js +74 -0
  135. package/dist/tests/registry/catalog-consistency.test.js +47 -0
  136. package/dist/tests/registry/design-skills.test.js +146 -0
  137. package/dist/tests/registry/prose-agnostic.test.js +18 -0
  138. package/dist/tests/registry/sensor-packs.test.js +45 -0
  139. package/dist/tests/registry/skill-versions.test.js +26 -0
  140. package/dist/tests/registry/using-awm.test.js +39 -0
  141. package/dist/tests/utils/config.test.js +31 -0
  142. package/dist/tests/utils/grouping.test.js +43 -0
  143. package/dist/tests/utils/registry-view-overrides.test.js +41 -0
  144. package/dist/tests/utils/registry-view.test.js +156 -0
  145. package/package.json +49 -0
@@ -0,0 +1,98 @@
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 child_process_1 = require("child_process");
10
+ const status_1 = require("../../../src/commands/sensors/status");
11
+ jest.mock('child_process', () => ({ execSync: jest.fn() }));
12
+ const mockExecSync = child_process_1.execSync;
13
+ describe('computeSensorStatus', () => {
14
+ let tmpDir;
15
+ beforeEach(() => {
16
+ tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-'));
17
+ mockExecSync.mockReset();
18
+ });
19
+ afterEach(() => { fs_1.default.rmSync(tmpDir, { recursive: true }); });
20
+ it('returns NOT_CONFIGURED when .awm/sensors.json missing', () => {
21
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
22
+ expect(result.overall).toBe('NOT_CONFIGURED');
23
+ expect(result.pack).toBeNull();
24
+ });
25
+ // Helper: simulate a tool installed locally (node_modules/.bin/<tool>)
26
+ function installLocalBin(tool) {
27
+ const binDir = path_1.default.join(tmpDir, 'node_modules', '.bin');
28
+ fs_1.default.mkdirSync(binDir, { recursive: true });
29
+ fs_1.default.writeFileSync(path_1.default.join(binDir, tool), '');
30
+ }
31
+ it('returns HEALTHY when an npx tool is installed locally', () => {
32
+ installLocalBin('tsc');
33
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
34
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
35
+ pack: 'js-ts',
36
+ sensors: { typecheck: { cmd: 'npx tsc --noEmit', fast: true } }
37
+ }));
38
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
39
+ expect(result.overall).toBe('HEALTHY');
40
+ expect(result.pack).toBe('js-ts');
41
+ expect(result.checks.typecheck.ok).toBe(true);
42
+ });
43
+ it('marks an npx sensor DEGRADED when the tool is NOT installed locally (npx would fetch a remote package)', () => {
44
+ // No node_modules/.bin/depcruise → status must NOT report ✔ just because npx exists.
45
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
46
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
47
+ pack: 'js-ts',
48
+ sensors: { depcheck: { cmd: 'npx depcruise --config .dep-cruiser.awm.js app', fast: false } }
49
+ }));
50
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
51
+ expect(result.overall).toBe('DEGRADED');
52
+ expect(result.checks.depcheck.ok).toBe(false);
53
+ expect(result.checks.depcheck.detail).toMatch(/no instalada/i);
54
+ });
55
+ it('marks a sensor DEGRADED when its --config file is missing', () => {
56
+ installLocalBin('eslint');
57
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
58
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
59
+ pack: 'js-ts',
60
+ sensors: { lint: { cmd: 'npx eslint . --config eslint.config.awm.mjs --format json', fast: true } }
61
+ }));
62
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
63
+ expect(result.checks.lint.ok).toBe(false);
64
+ expect(result.checks.lint.detail).toMatch(/config faltante/i);
65
+ });
66
+ it('is HEALTHY when the npx tool is installed and the --config file exists', () => {
67
+ installLocalBin('eslint');
68
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'eslint.config.awm.mjs'), 'export default []');
69
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
70
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
71
+ pack: 'js-ts',
72
+ sensors: { lint: { cmd: 'npx eslint . --config eslint.config.awm.mjs --format json', fast: true } }
73
+ }));
74
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
75
+ expect(result.checks.lint.ok).toBe(true);
76
+ });
77
+ it('returns DEGRADED when a binary is missing', () => {
78
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
79
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
80
+ pack: 'js-ts',
81
+ sensors: { security: { cmd: 'semgrep --json .', fast: false } }
82
+ }));
83
+ mockExecSync.mockImplementation(() => { throw new Error('not found'); });
84
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
85
+ expect(result.overall).toBe('DEGRADED');
86
+ expect(result.checks.security.ok).toBe(false);
87
+ });
88
+ it('marks disabled sensors as ok', () => {
89
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
90
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
91
+ pack: 'js-ts',
92
+ sensors: { mutation: { enabled: false } }
93
+ }));
94
+ const result = (0, status_1.computeSensorStatus)(tmpDir);
95
+ expect(result.checks.mutation.ok).toBe(true);
96
+ expect(result.checks.mutation.detail).toBe('disabled');
97
+ });
98
+ });
@@ -0,0 +1,70 @@
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
+ describe('resolveBaseRemote', () => {
10
+ let tmpHome;
11
+ const origHome = process.env.HOME;
12
+ const origAwmHome = process.env.AWM_HOME;
13
+ const origEnvRemote = process.env.AWM_BASE_REMOTE;
14
+ beforeEach(() => {
15
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-remote-'));
16
+ process.env.HOME = tmpHome;
17
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
18
+ delete process.env.AWM_BASE_REMOTE;
19
+ jest.resetModules();
20
+ });
21
+ afterEach(() => {
22
+ process.env.HOME = origHome;
23
+ if (origAwmHome === undefined)
24
+ delete process.env.AWM_HOME;
25
+ else
26
+ process.env.AWM_HOME = origAwmHome;
27
+ if (origEnvRemote === undefined)
28
+ delete process.env.AWM_BASE_REMOTE;
29
+ else
30
+ process.env.AWM_BASE_REMOTE = origEnvRemote;
31
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
32
+ });
33
+ it('falls back to DEFAULT_REMOTE when no env and no prefs', () => {
34
+ const { resolveBaseRemote, DEFAULT_REMOTE } = require('../../src/core/registry');
35
+ expect(resolveBaseRemote()).toBe(DEFAULT_REMOTE);
36
+ });
37
+ it('prefers preferences.json baseRemote over the default', () => {
38
+ const awmDir = path_1.default.join(tmpHome, '.awm');
39
+ fs_1.default.mkdirSync(awmDir, { recursive: true });
40
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', baseRemote: 'git@team:content.git' }));
41
+ const { resolveBaseRemote } = require('../../src/core/registry');
42
+ expect(resolveBaseRemote()).toBe('git@team:content.git');
43
+ });
44
+ it('env AWM_BASE_REMOTE wins over prefs and default', () => {
45
+ const awmDir = path_1.default.join(tmpHome, '.awm');
46
+ fs_1.default.mkdirSync(awmDir, { recursive: true });
47
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', baseRemote: 'git@team:content.git' }));
48
+ process.env.AWM_BASE_REMOTE = 'git@env:wins.git';
49
+ jest.resetModules();
50
+ const { resolveBaseRemote } = require('../../src/core/registry');
51
+ expect(resolveBaseRemote()).toBe('git@env:wins.git');
52
+ });
53
+ it('resolveBaseRemoteInfo reports where the remote came from', () => {
54
+ const { resolveBaseRemoteInfo, DEFAULT_REMOTE } = require('../../src/core/registry');
55
+ // default
56
+ expect(resolveBaseRemoteInfo()).toEqual({ remote: DEFAULT_REMOTE, source: 'default' });
57
+ // prefs
58
+ const awmDir = path_1.default.join(tmpHome, '.awm');
59
+ fs_1.default.mkdirSync(awmDir, { recursive: true });
60
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', baseRemote: 'git@prefs:content.git' }));
61
+ jest.resetModules();
62
+ const m2 = require('../../src/core/registry');
63
+ expect(m2.resolveBaseRemoteInfo()).toEqual({ remote: 'git@prefs:content.git', source: 'prefs' });
64
+ // env wins over prefs
65
+ process.env.AWM_BASE_REMOTE = 'git@env:x.git';
66
+ jest.resetModules();
67
+ const m3 = require('../../src/core/registry');
68
+ expect(m3.resolveBaseRemoteInfo()).toEqual({ remote: 'git@env:x.git', source: 'env' });
69
+ });
70
+ });
@@ -0,0 +1,198 @@
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 os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const bundles_1 = require("../../src/core/bundles");
10
+ const bundle_install_1 = require("../../src/core/bundle-install");
11
+ const profile_1 = require("../../src/core/profile");
12
+ /**
13
+ * Builds a fixture with:
14
+ * - content registry: catalog + two project bundles (base, ext dependsOn base),
15
+ * plus a skill dir per skill, one workflow .md and one agent .md.
16
+ * - a separate empty project root for local installs.
17
+ * Both bundles are `project` scope so every artifact lands under projectRoot/.claude.
18
+ */
19
+ function makeFixture() {
20
+ const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-binstall-'));
21
+ const content = path_1.default.join(tmp, 'registry');
22
+ const projectRoot = path_1.default.join(tmp, 'project');
23
+ fs_1.default.mkdirSync(projectRoot, { recursive: true });
24
+ fs_1.default.mkdirSync(path_1.default.join(content, 'bundles', 'base'), { recursive: true });
25
+ fs_1.default.mkdirSync(path_1.default.join(content, 'bundles', 'ext'), { recursive: true });
26
+ // skill source dirs
27
+ for (const s of ['s-base', 's-ext']) {
28
+ fs_1.default.mkdirSync(path_1.default.join(content, 'skills', s), { recursive: true });
29
+ fs_1.default.writeFileSync(path_1.default.join(content, 'skills', s, 'SKILL.md'), `---\nname: ${s}\n---\n`);
30
+ }
31
+ // a workflow + agent source for `ext`
32
+ fs_1.default.mkdirSync(path_1.default.join(content, 'workflows'), { recursive: true });
33
+ fs_1.default.writeFileSync(path_1.default.join(content, 'workflows', 'wf-ext.md'), '# wf');
34
+ fs_1.default.mkdirSync(path_1.default.join(content, 'agents'), { recursive: true });
35
+ fs_1.default.writeFileSync(path_1.default.join(content, 'agents', 'ag-ext.md'), '# agent');
36
+ fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
37
+ version: 1,
38
+ bundles: [
39
+ { name: 'base', source: './bundles/base', version: '1.0.0', scope: 'project' },
40
+ { name: 'ext', source: './bundles/ext', version: '1.0.0', scope: 'project' },
41
+ ],
42
+ }));
43
+ fs_1.default.writeFileSync(path_1.default.join(content, 'bundles', 'base', 'bundle.json'), JSON.stringify({
44
+ name: 'base', version: '1.0.0', description: 'Base', scope: 'project', dependsOn: [],
45
+ skills: ['s-base'], workflows: [], agents: [],
46
+ }));
47
+ fs_1.default.writeFileSync(path_1.default.join(content, 'bundles', 'ext', 'bundle.json'), JSON.stringify({
48
+ name: 'ext', version: '1.0.0', description: 'Ext', scope: 'project', dependsOn: ['base'],
49
+ skills: ['s-ext'], workflows: ['wf-ext'], agents: ['ag-ext'],
50
+ }));
51
+ return { content, projectRoot, bundles: (0, bundles_1.discoverBundles)(content) };
52
+ }
53
+ describe('installBundle', () => {
54
+ it('materializes the bundle closure as local symlinks (deps + own skills)', () => {
55
+ const { content, projectRoot, bundles } = makeFixture();
56
+ const result = (0, bundle_install_1.installBundle)({
57
+ bundleName: 'ext', bundles, agents: ['claude-code'],
58
+ method: 'symlink', projectRoot, contentDir: content,
59
+ });
60
+ const skillsDir = path_1.default.join(projectRoot, '.claude', 'skills');
61
+ expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 's-base'))).toBe(true); // from dep `base`
62
+ expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 's-ext'))).toBe(true); // from `ext`
63
+ expect(fs_1.default.lstatSync(path_1.default.join(skillsDir, 's-ext')).isSymbolicLink()).toBe(true);
64
+ expect(result.installed.some((l) => l.includes('s-base'))).toBe(true);
65
+ });
66
+ it('installs supported artifact types and skips unsupported ones (claude-code workflows)', () => {
67
+ const { content, projectRoot, bundles } = makeFixture();
68
+ const result = (0, bundle_install_1.installBundle)({
69
+ bundleName: 'ext', bundles, agents: ['claude-code'],
70
+ method: 'symlink', projectRoot, contentDir: content,
71
+ });
72
+ // claude-code has no workflow dir → wf-ext is skipped; agents are supported.
73
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'agents', 'ag-ext.md'))).toBe(true);
74
+ expect(result.skipped.some((l) => l.includes('wf-ext'))).toBe(true);
75
+ });
76
+ it('is idempotent: a second run leaves valid symlinks and does not throw', () => {
77
+ const { content, projectRoot, bundles } = makeFixture();
78
+ const opts = {
79
+ bundleName: 'ext', bundles, agents: ['claude-code'],
80
+ method: 'symlink', projectRoot, contentDir: content,
81
+ };
82
+ (0, bundle_install_1.installBundle)(opts);
83
+ expect(() => (0, bundle_install_1.installBundle)(opts)).not.toThrow();
84
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
85
+ });
86
+ it('skips artifacts whose source is missing instead of throwing', () => {
87
+ const { content, projectRoot, bundles } = makeFixture();
88
+ fs_1.default.rmSync(path_1.default.join(content, 'skills', 's-base'), { recursive: true, force: true });
89
+ const result = (0, bundle_install_1.installBundle)({
90
+ bundleName: 'ext', bundles, agents: ['claude-code'],
91
+ method: 'symlink', projectRoot, contentDir: content,
92
+ });
93
+ expect(result.skipped.some((l) => l.includes('s-base'))).toBe(true);
94
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
95
+ });
96
+ });
97
+ describe('addBundle', () => {
98
+ it('records a project bundle installed locally as an extension + gitignores symlinks', () => {
99
+ const { content, projectRoot, bundles } = makeFixture();
100
+ const result = (0, bundle_install_1.addBundle)({
101
+ bundleName: 'ext', bundles, agents: ['claude-code'],
102
+ method: 'symlink', projectRoot, contentDir: content,
103
+ });
104
+ expect(result.recordedExtension).toBe('ext');
105
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']);
106
+ const gi = fs_1.default.readFileSync(path_1.default.join(projectRoot, '.gitignore'), 'utf-8');
107
+ expect(gi).toContain('.claude/skills/');
108
+ });
109
+ it('does not record the dependency bundle, only the named one', () => {
110
+ const { content, projectRoot, bundles } = makeFixture();
111
+ (0, bundle_install_1.addBundle)({
112
+ bundleName: 'ext', bundles, agents: ['claude-code'],
113
+ method: 'symlink', projectRoot, contentDir: content,
114
+ });
115
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']); // not ['base','ext']
116
+ });
117
+ it('is idempotent: adding the same bundle twice keeps one extension entry', () => {
118
+ const { content, projectRoot, bundles } = makeFixture();
119
+ const opts = {
120
+ bundleName: 'ext', bundles, agents: ['claude-code'],
121
+ method: 'symlink', projectRoot, contentDir: content,
122
+ };
123
+ (0, bundle_install_1.addBundle)(opts);
124
+ (0, bundle_install_1.addBundle)(opts);
125
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']);
126
+ });
127
+ it('does not record extension when all sources are missing (nothing installed)', () => {
128
+ const { content, projectRoot, bundles } = makeFixture();
129
+ fs_1.default.rmSync(path_1.default.join(content, 'skills', 's-ext'), { recursive: true, force: true });
130
+ fs_1.default.rmSync(path_1.default.join(content, 'skills', 's-base'), { recursive: true, force: true });
131
+ fs_1.default.rmSync(path_1.default.join(content, 'agents', 'ag-ext.md'), { force: true });
132
+ const result = (0, bundle_install_1.addBundle)({
133
+ bundleName: 'ext', bundles, agents: ['claude-code'],
134
+ method: 'symlink', projectRoot, contentDir: content,
135
+ });
136
+ expect(result.recordedExtension).toBeNull();
137
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual([]);
138
+ });
139
+ it('does not record extension when only dep sources are present (named bundle own artifacts missing)', () => {
140
+ const { content, projectRoot, bundles } = makeFixture();
141
+ // Remove only ext's own sources; keep dep (base) sources intact.
142
+ fs_1.default.rmSync(path_1.default.join(content, 'skills', 's-ext'), { recursive: true, force: true });
143
+ fs_1.default.rmSync(path_1.default.join(content, 'agents', 'ag-ext.md'), { force: true });
144
+ const result = (0, bundle_install_1.addBundle)({
145
+ bundleName: 'ext', bundles, agents: ['claude-code'],
146
+ method: 'symlink', projectRoot, contentDir: content,
147
+ });
148
+ expect(result.installed.some((l) => l.includes('s-base'))).toBe(true); // dep installed
149
+ expect(result.recordedExtension).toBeNull(); // ext not recorded
150
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual([]);
151
+ });
152
+ it('respects explicit scopeOverride: local and still records the extension', () => {
153
+ const { content, projectRoot, bundles } = makeFixture();
154
+ const result = (0, bundle_install_1.addBundle)({
155
+ bundleName: 'ext', bundles, agents: ['claude-code'],
156
+ method: 'symlink', projectRoot, contentDir: content,
157
+ scopeOverride: 'local',
158
+ });
159
+ expect(result.recordedExtension).toBe('ext');
160
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
161
+ expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']);
162
+ });
163
+ });
164
+ describe('syncProfile', () => {
165
+ it('rematerializes symlinks for every extension listed in the profile', () => {
166
+ const { content, projectRoot, bundles } = makeFixture();
167
+ (0, profile_1.writeProfile)(projectRoot, { extensions: ['ext'] });
168
+ const result = (0, bundle_install_1.syncProfile)({
169
+ projectRoot, bundles, agents: ['claude-code'],
170
+ method: 'symlink', contentDir: content,
171
+ });
172
+ expect(result.extensions).toEqual(['ext']);
173
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
174
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-base'))).toBe(true);
175
+ });
176
+ it('is a no-op when the profile has no extensions', () => {
177
+ const { content, projectRoot, bundles } = makeFixture();
178
+ (0, profile_1.writeProfile)(projectRoot, { extensions: [] });
179
+ const result = (0, bundle_install_1.syncProfile)({
180
+ projectRoot, bundles, agents: ['claude-code'],
181
+ method: 'symlink', contentDir: content,
182
+ });
183
+ expect(result.extensions).toEqual([]);
184
+ expect(result.installed).toEqual([]);
185
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills'))).toBe(false);
186
+ });
187
+ it('skips and warns about extensions no longer in registry (stale entries)', () => {
188
+ const { content, projectRoot, bundles } = makeFixture();
189
+ (0, profile_1.writeProfile)(projectRoot, { extensions: ['stale-bundle', 'ext'] });
190
+ const result = (0, bundle_install_1.syncProfile)({
191
+ projectRoot, bundles, agents: ['claude-code'],
192
+ method: 'symlink', contentDir: content,
193
+ });
194
+ expect(result.extensions).toEqual(['stale-bundle', 'ext']);
195
+ expect(result.skipped.some((l) => l.includes('stale-bundle'))).toBe(true);
196
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
197
+ });
198
+ });
@@ -0,0 +1,68 @@
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
+ // cli/tests/core/bundles-multiroot.test.ts
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const os_1 = __importDefault(require("os"));
10
+ function writeBundleRoot(root, bundleName, skillName) {
11
+ fs_1.default.mkdirSync(path_1.default.join(root, 'bundles', bundleName), { recursive: true });
12
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills', skillName), { recursive: true });
13
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills', skillName, 'SKILL.md'), `---\nname: ${skillName}\ndescription: d\n---\n`);
14
+ fs_1.default.writeFileSync(path_1.default.join(root, 'bundles', bundleName, 'bundle.json'), JSON.stringify({ name: bundleName, version: '1.0.0', scope: 'ambient', skills: [skillName] }));
15
+ fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
16
+ version: 1,
17
+ bundles: [{ name: bundleName, source: `./bundles/${bundleName}`, version: '1.0.0', scope: 'ambient' }],
18
+ }));
19
+ }
20
+ describe('bundles multi-root', () => {
21
+ let tmp;
22
+ let rootA;
23
+ let rootB;
24
+ beforeEach(() => {
25
+ tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bundles-'));
26
+ rootA = path_1.default.join(tmp, 'a');
27
+ rootB = path_1.default.join(tmp, 'b');
28
+ jest.resetModules();
29
+ });
30
+ afterEach(() => {
31
+ fs_1.default.rmSync(tmp, { recursive: true, force: true });
32
+ });
33
+ it('discoverAllBundles merges bundles from all roots and stamps contentRoot', () => {
34
+ writeBundleRoot(rootA, 'dev-x', 'sx');
35
+ writeBundleRoot(rootB, 'personal-x', 'px');
36
+ const { discoverAllBundles } = require('../../src/core/bundles');
37
+ const all = discoverAllBundles([rootA, rootB]);
38
+ expect(all.map((b) => b.name).sort()).toEqual(['dev-x', 'personal-x']);
39
+ expect(all.find((b) => b.name === 'personal-x').contentRoot).toBe(rootB);
40
+ });
41
+ it('discoverAllBundles throws naming both sources on bundle name collision', () => {
42
+ writeBundleRoot(rootA, 'dup', 's1');
43
+ writeBundleRoot(rootB, 'dup', 's2');
44
+ const { discoverAllBundles } = require('../../src/core/bundles');
45
+ expect(() => discoverAllBundles([rootA, rootB])).toThrow(/dup/);
46
+ expect(() => discoverAllBundles([rootA, rootB])).toThrow(new RegExp(rootA.replace(/[/\\]/g, '.')));
47
+ expect(() => discoverAllBundles([rootA, rootB])).toThrow(new RegExp(rootB.replace(/[/\\]/g, '.')));
48
+ });
49
+ it('installBundle resolves artifacts from the bundle own contentRoot', () => {
50
+ writeBundleRoot(rootB, 'personal-x', 'px');
51
+ const { discoverAllBundles } = require('../../src/core/bundles');
52
+ const { installBundle } = require('../../src/core/bundle-install');
53
+ const bundles = discoverAllBundles([rootB]);
54
+ const projectRoot = path_1.default.join(tmp, 'proj');
55
+ fs_1.default.mkdirSync(projectRoot, { recursive: true });
56
+ const summary = installBundle({
57
+ bundleName: 'personal-x',
58
+ bundles,
59
+ agents: ['claude-code'],
60
+ method: 'copy',
61
+ projectRoot,
62
+ scopeOverride: 'local',
63
+ });
64
+ // the skill was copied FROM rootB (its own root), not from the default
65
+ expect(summary.installed.some((l) => l.startsWith('px'))).toBe(true);
66
+ expect(summary.skipped.some((l) => l.includes('source missing'))).toBe(false);
67
+ });
68
+ });
@@ -0,0 +1,49 @@
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
+ function writeBundleRoot(root, bundleName, skillName) {
10
+ fs_1.default.mkdirSync(path_1.default.join(root, 'bundles', bundleName), { recursive: true });
11
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills', skillName), { recursive: true });
12
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills', skillName, 'SKILL.md'), `---\nname: ${skillName}\ndescription: d\n---\n`);
13
+ fs_1.default.writeFileSync(path_1.default.join(root, 'bundles', bundleName, 'bundle.json'), JSON.stringify({ name: bundleName, version: '1.0.0', scope: 'ambient', skills: [skillName] }));
14
+ fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
15
+ version: 1,
16
+ bundles: [{ name: bundleName, source: `./bundles/${bundleName}`, version: '1.0.0', scope: 'ambient' }],
17
+ }));
18
+ }
19
+ describe('bundle override resolution', () => {
20
+ let tmp;
21
+ let rootA;
22
+ let rootB;
23
+ beforeEach(() => {
24
+ tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bnd-ovr-'));
25
+ rootA = path_1.default.join(tmp, 'a');
26
+ rootB = path_1.default.join(tmp, 'b');
27
+ jest.resetModules();
28
+ });
29
+ afterEach(() => {
30
+ fs_1.default.rmSync(tmp, { recursive: true, force: true });
31
+ });
32
+ it('declared override: later root wins, contentRoot and overrode reflect it', () => {
33
+ writeBundleRoot(rootA, 'pack', 's1');
34
+ writeBundleRoot(rootB, 'pack', 's2');
35
+ fs_1.default.writeFileSync(path_1.default.join(rootB, 'awm-registry.json'), JSON.stringify({ overrides: ['pack'] }));
36
+ const { discoverAllBundles } = require('../../src/core/bundles');
37
+ const out = discoverAllBundles([rootA, rootB]);
38
+ expect(out).toHaveLength(1);
39
+ expect(out[0].contentRoot).toBe(rootB);
40
+ expect(out[0].overrode).toBe(rootA);
41
+ });
42
+ it('undeclared collision still throws naming both sources', () => {
43
+ writeBundleRoot(rootA, 'dup', 's1');
44
+ writeBundleRoot(rootB, 'dup', 's2');
45
+ const { discoverAllBundles } = require('../../src/core/bundles');
46
+ expect(() => discoverAllBundles([rootA, rootB])).toThrow(/dup/);
47
+ expect(() => discoverAllBundles([rootA, rootB])).toThrow(new RegExp(rootA.replace(/[/\\]/g, '.')));
48
+ });
49
+ });
@@ -0,0 +1,96 @@
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 os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const bundles_1 = require("../../src/core/bundles");
10
+ function makeFixture() {
11
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bundles-'));
12
+ const content = path_1.default.join(root, 'registry');
13
+ fs_1.default.mkdirSync(path_1.default.join(content, 'bundles', 'dev'), { recursive: true });
14
+ fs_1.default.mkdirSync(path_1.default.join(content, 'bundles', 'frontend'), { recursive: true });
15
+ fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
16
+ version: 1,
17
+ bundles: [
18
+ { name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' },
19
+ { name: 'frontend', source: './bundles/frontend', version: '1.0.0', scope: 'project' },
20
+ ],
21
+ }));
22
+ fs_1.default.writeFileSync(path_1.default.join(content, 'bundles', 'dev', 'bundle.json'), JSON.stringify({
23
+ name: 'dev', version: '1.0.0', description: 'Dev core', scope: 'baseline', dependsOn: [],
24
+ skills: ['brainstorming', { name: 'architecture-advisor', onSignal: true }],
25
+ workflows: ['development-process'], agents: ['development-process'],
26
+ }));
27
+ fs_1.default.writeFileSync(path_1.default.join(content, 'bundles', 'frontend', 'bundle.json'), JSON.stringify({
28
+ name: 'frontend', version: '1.0.0', description: 'Frontend', scope: 'project', dependsOn: ['dev'],
29
+ skills: ['impeccable'], workflows: [], agents: [],
30
+ }));
31
+ return content;
32
+ }
33
+ describe('readCatalog', () => {
34
+ it('reads catalog entries', () => {
35
+ const content = makeFixture();
36
+ const entries = (0, bundles_1.readCatalog)(content);
37
+ expect(entries.map((e) => e.name).sort()).toEqual(['dev', 'frontend']);
38
+ expect(entries.find((e) => e.name === 'dev').scope).toBe('baseline');
39
+ });
40
+ });
41
+ describe('discoverBundles', () => {
42
+ it('loads each bundle and normalizes skill refs (string | object)', () => {
43
+ const content = makeFixture();
44
+ const bundles = (0, bundles_1.discoverBundles)(content);
45
+ const dev = bundles.find((b) => b.name === 'dev');
46
+ expect(dev.skills).toEqual([
47
+ { name: 'brainstorming', onSignal: false },
48
+ { name: 'architecture-advisor', onSignal: true },
49
+ ]);
50
+ expect(dev.scope).toBe('baseline');
51
+ expect(dev.dependsOn).toEqual([]);
52
+ });
53
+ it('returns [] when catalog is missing', () => {
54
+ const empty = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-'));
55
+ expect((0, bundles_1.discoverBundles)(empty)).toEqual([]);
56
+ });
57
+ });
58
+ describe('resolveBundleSkills', () => {
59
+ it('follows dependsOn transitively and dedupes', () => {
60
+ const content = makeFixture();
61
+ const bundles = (0, bundles_1.discoverBundles)(content);
62
+ const names = (0, bundles_1.resolveBundleSkills)('frontend', bundles);
63
+ expect(names.sort()).toEqual(['architecture-advisor', 'brainstorming', 'impeccable']);
64
+ });
65
+ it('returns own skills when no deps', () => {
66
+ const content = makeFixture();
67
+ const bundles = (0, bundles_1.discoverBundles)(content);
68
+ expect((0, bundles_1.resolveBundleSkills)('dev', bundles).sort()).toEqual(['architecture-advisor', 'brainstorming']);
69
+ });
70
+ });
71
+ describe('defaultScopeForBundle', () => {
72
+ it('maps baseline and ambient to global, project to local', () => {
73
+ expect((0, bundles_1.defaultScopeForBundle)('baseline')).toBe('global');
74
+ expect((0, bundles_1.defaultScopeForBundle)('ambient')).toBe('global');
75
+ expect((0, bundles_1.defaultScopeForBundle)('project')).toBe('local');
76
+ });
77
+ });
78
+ describe('resolveBundleClosure', () => {
79
+ it('returns dependencies before the bundle, deduped, in deps-first order', () => {
80
+ const content = makeFixture();
81
+ const bundles = (0, bundles_1.discoverBundles)(content);
82
+ const closure = (0, bundles_1.resolveBundleClosure)('frontend', bundles);
83
+ expect(closure.map((b) => b.name)).toEqual(['dev', 'frontend']);
84
+ });
85
+ it('returns just the bundle when it has no dependencies', () => {
86
+ const content = makeFixture();
87
+ const bundles = (0, bundles_1.discoverBundles)(content);
88
+ const closure = (0, bundles_1.resolveBundleClosure)('dev', bundles);
89
+ expect(closure.map((b) => b.name)).toEqual(['dev']);
90
+ });
91
+ it('returns [] for an unknown bundle name', () => {
92
+ const content = makeFixture();
93
+ const bundles = (0, bundles_1.discoverBundles)(content);
94
+ expect((0, bundles_1.resolveBundleClosure)('nope', bundles)).toEqual([]);
95
+ });
96
+ });
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ describe('cliVersion / compareSemver', () => {
3
+ it('cliVersion devuelve la versión del package.json del CLI', () => {
4
+ const { cliVersion } = require('../../src/core/cli-version');
5
+ const pkg = require('../../package.json');
6
+ expect(cliVersion()).toBe(pkg.version);
7
+ });
8
+ it.each([
9
+ ['1.0.0', '1.0.0', 0],
10
+ ['2.0.0', '1.9.9', 1],
11
+ ['1.9.0', '1.10.0', -1], // orden numérico, no lexicográfico
12
+ ['1.0.1', '1.0.0', 1],
13
+ ])('compareSemver(%s, %s) → signo %i', (a, b, sign) => {
14
+ const { compareSemver } = require('../../src/core/versioning');
15
+ expect(Math.sign(compareSemver(a, b))).toBe(sign);
16
+ });
17
+ });
@@ -0,0 +1,46 @@
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
+ // cli/tests/core/context/materializer.test.ts
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const materializer_1 = require("../../../src/core/context/materializer");
11
+ const provider_1 = require("../../../src/core/context/provider");
12
+ function ctxOf(markdown) {
13
+ return { markdown, sourceVersion: '1.0.0', contentHash: (0, provider_1.sha256)(markdown) };
14
+ }
15
+ describe('globalContextPath', () => {
16
+ it('points under AWM_HOME/context', () => {
17
+ expect((0, materializer_1.globalContextPath)()).toContain(path_1.default.join('context', 'awm-context.md'));
18
+ });
19
+ });
20
+ describe('materialize', () => {
21
+ it('writes the content and returns a ref with the matching hash', () => {
22
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mat-'));
23
+ const abs = path_1.default.join(dir, 'awm-context.md');
24
+ const ctx = ctxOf('CONTENT-A');
25
+ const ref = (0, materializer_1.materialize)(ctx, abs, 'global');
26
+ expect(ref).toEqual({ absPath: abs, scope: 'global', contentHash: ctx.contentHash });
27
+ expect(fs_1.default.readFileSync(abs, 'utf-8')).toBe('CONTENT-A');
28
+ });
29
+ it('is a no-op when the on-disk hash already matches (mtime unchanged)', () => {
30
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mat-'));
31
+ const abs = path_1.default.join(dir, 'awm-context.md');
32
+ const ctx = ctxOf('CONTENT-A');
33
+ (0, materializer_1.materialize)(ctx, abs, 'global');
34
+ const mtime1 = fs_1.default.statSync(abs).mtimeMs;
35
+ (0, materializer_1.materialize)(ctx, abs, 'global'); // same content
36
+ expect(fs_1.default.statSync(abs).mtimeMs).toBe(mtime1);
37
+ });
38
+ it('rewrites when the content changed', () => {
39
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mat-'));
40
+ const abs = path_1.default.join(dir, 'awm-context.md');
41
+ (0, materializer_1.materialize)(ctxOf('CONTENT-A'), abs, 'global');
42
+ const ref = (0, materializer_1.materialize)(ctxOf('CONTENT-B'), abs, 'global');
43
+ expect(fs_1.default.readFileSync(abs, 'utf-8')).toBe('CONTENT-B');
44
+ expect(ref.contentHash).toBe((0, provider_1.sha256)('CONTENT-B'));
45
+ });
46
+ });