agentic-workflow-manager 3.11.0 → 3.13.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.
- package/dist/src/commands/doctor.js +1 -1
- package/dist/src/commands/preflight/checks.js +63 -5
- package/dist/src/commands/preflight/index.js +6 -1
- package/dist/src/commands/sensors/baseline.js +4 -3
- package/dist/src/core/context/materializer.js +7 -0
- package/dist/src/core/context/orchestrator.js +26 -6
- package/dist/src/core/context/strategies/codex-agents.js +69 -15
- package/dist/src/core/diagnostics/context.js +11 -6
- package/dist/src/core/diagnostics/provider-checks.js +92 -11
- package/dist/src/core/init/mutation-targets.js +18 -2
- package/dist/src/core/init/provider-facts.js +5 -4
- package/dist/src/core/init/steps.js +16 -2
- package/dist/src/core/install-planner.js +56 -6
- package/dist/src/core/install-transaction.js +55 -6
- package/dist/src/core/provider-artifacts.js +1 -1
- package/dist/src/core/renderers/copilot-instructions.js +28 -0
- package/dist/src/core/renderers/cursor-mdc.js +49 -0
- package/dist/src/core/renderers/skill-source.js +50 -0
- package/dist/src/core/skill-integrity.js +1 -1
- package/dist/src/index.js +8 -0
- package/dist/src/providers/index.js +69 -2
- package/dist/tests/commands/add.test.js +96 -0
- package/dist/tests/commands/doctor.test.js +25 -0
- package/dist/tests/commands/init.test.js +56 -0
- package/dist/tests/commands/preflight/preflight.test.js +95 -0
- package/dist/tests/core/bundle-install.test.js +63 -0
- package/dist/tests/core/context/materializer.test.js +8 -0
- package/dist/tests/core/context/orchestrator.test.js +51 -0
- package/dist/tests/core/context/strategies/codex-agents.test.js +157 -20
- package/dist/tests/core/diagnostics/checks.test.js +1 -0
- package/dist/tests/core/diagnostics/provider-tier.test.js +292 -0
- package/dist/tests/core/init/mutation-targets.test.js +63 -0
- package/dist/tests/core/init/provider-facts.test.js +16 -0
- package/dist/tests/core/init/steps.test.js +37 -0
- package/dist/tests/core/install-planner.test.js +118 -0
- package/dist/tests/core/install-transaction.test.js +109 -0
- package/dist/tests/core/provider-artifacts.test.js +11 -0
- package/dist/tests/core/renderers/copilot-instructions.test.js +47 -0
- package/dist/tests/core/renderers/cursor-mdc.test.js +137 -0
- package/dist/tests/core/skill-integrity.test.js +18 -0
- package/dist/tests/providers/index.test.js +45 -1
- package/dist/tests/providers/injection-config.test.js +16 -0
- package/package.json +1 -1
|
@@ -38,9 +38,7 @@ describe('CodexAgentsStrategy', () => {
|
|
|
38
38
|
fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.codex'), { recursive: true });
|
|
39
39
|
const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
|
|
40
40
|
fs_1.default.writeFileSync(file, '# Personal\n\nDo not delete.\n');
|
|
41
|
-
const result = new codex_agents_1.CodexAgentsStrategy().injectGlobal({
|
|
42
|
-
markdown: '# AWM\n\nUse `development-process`.',
|
|
43
|
-
});
|
|
41
|
+
const result = new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: '# AWM\n\nUse `development-process`.' }, codexProvider(file));
|
|
44
42
|
expect(result).toBe('injected');
|
|
45
43
|
const written = fs_1.default.readFileSync(file, 'utf8');
|
|
46
44
|
expect(written).toContain('# Personal\n\nDo not delete.\n');
|
|
@@ -48,18 +46,20 @@ describe('CodexAgentsStrategy', () => {
|
|
|
48
46
|
});
|
|
49
47
|
it('creates the global file and returns unchanged on an idempotent repeat', () => {
|
|
50
48
|
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
49
|
+
const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
|
|
50
|
+
const provider = codexProvider(file);
|
|
51
51
|
const context = { markdown: '# AWM\n\nBootstrap.' };
|
|
52
|
-
expect(strategy.injectGlobal(context)).toBe('injected');
|
|
53
|
-
expect(strategy.injectGlobal(context)).toBe('unchanged');
|
|
54
|
-
expect(fs_1.default.existsSync(
|
|
52
|
+
expect(strategy.injectGlobal(context, provider)).toBe('injected');
|
|
53
|
+
expect(strategy.injectGlobal(context, provider)).toBe('unchanged');
|
|
54
|
+
expect(fs_1.default.existsSync(file)).toBe(true);
|
|
55
55
|
});
|
|
56
56
|
it('uses HOME at call time', () => {
|
|
57
57
|
const firstHome = tmpHome;
|
|
58
58
|
const secondHome = path_1.default.join(tmpWork, 'second-home');
|
|
59
59
|
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
60
|
-
strategy.injectGlobal({ markdown: 'first' });
|
|
60
|
+
strategy.injectGlobal({ markdown: 'first' }, codexProvider(path_1.default.join(firstHome, '.codex/AGENTS.md')));
|
|
61
61
|
process.env.HOME = secondHome;
|
|
62
|
-
strategy.injectGlobal({ markdown: 'second' });
|
|
62
|
+
strategy.injectGlobal({ markdown: 'second' }, codexProvider(path_1.default.join(secondHome, '.codex/AGENTS.md')));
|
|
63
63
|
expect(fs_1.default.readFileSync(path_1.default.join(firstHome, '.codex/AGENTS.md'), 'utf8')).toContain('first');
|
|
64
64
|
expect(fs_1.default.readFileSync(path_1.default.join(secondHome, '.codex/AGENTS.md'), 'utf8')).toContain('second');
|
|
65
65
|
});
|
|
@@ -68,9 +68,10 @@ describe('CodexAgentsStrategy', () => {
|
|
|
68
68
|
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
69
69
|
fs_1.default.writeFileSync(file, 'before\n');
|
|
70
70
|
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
71
|
-
|
|
71
|
+
const provider = codexProvider(file);
|
|
72
|
+
strategy.injectGlobal({ markdown: 'old' }, provider);
|
|
72
73
|
fs_1.default.appendFileSync(file, 'after\n');
|
|
73
|
-
strategy.injectGlobal({ markdown: 'new' });
|
|
74
|
+
strategy.injectGlobal({ markdown: 'new' }, provider);
|
|
74
75
|
expect(fs_1.default.readFileSync(file, 'utf8')).toBe('before\n\n<!-- AWM:START -->\n<!-- AWM:BOUNDARY prefix=1 suffix=1 -->\nnew\n<!-- AWM:END -->\nafter\n');
|
|
75
76
|
});
|
|
76
77
|
it('fails on ambiguous global markers without changing the file', () => {
|
|
@@ -78,7 +79,7 @@ describe('CodexAgentsStrategy', () => {
|
|
|
78
79
|
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
79
80
|
const ambiguous = '<!-- AWM:START -->\nuser';
|
|
80
81
|
fs_1.default.writeFileSync(file, ambiguous);
|
|
81
|
-
expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' })).toThrow('unmatched');
|
|
82
|
+
expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' }, codexProvider(file))).toThrow('unmatched');
|
|
82
83
|
expect(fs_1.default.readFileSync(file, 'utf8')).toBe(ambiguous);
|
|
83
84
|
});
|
|
84
85
|
it('rejects inline marker examples without changing the global file', () => {
|
|
@@ -86,7 +87,7 @@ describe('CodexAgentsStrategy', () => {
|
|
|
86
87
|
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
87
88
|
const ambiguous = '`<!-- AWM:START -->` example and `<!-- AWM:END -->` example';
|
|
88
89
|
fs_1.default.writeFileSync(file, ambiguous);
|
|
89
|
-
expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' })).toThrow('standalone');
|
|
90
|
+
expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' }, codexProvider(file))).toThrow('standalone');
|
|
90
91
|
expect(fs_1.default.readFileSync(file, 'utf8')).toBe(ambiguous);
|
|
91
92
|
});
|
|
92
93
|
it.each([
|
|
@@ -117,7 +118,7 @@ describe('CodexAgentsStrategy', () => {
|
|
|
117
118
|
fs_1.default.mkdirSync(project, { recursive: true });
|
|
118
119
|
fs_1.default.writeFileSync(path_1.default.join(project, 'CONSTITUTION.md'), '# Rules\n');
|
|
119
120
|
fs_1.default.writeFileSync(path_1.default.join(project, 'AGENTS.md'), '# Repo-owned rules\n');
|
|
120
|
-
const result = new codex_agents_1.CodexAgentsStrategy().injectProject(project);
|
|
121
|
+
const result = new codex_agents_1.CodexAgentsStrategy().injectProject(project, codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md')));
|
|
121
122
|
expect(result).toBe('injected');
|
|
122
123
|
const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
|
|
123
124
|
expect(written).toContain('# Repo-owned rules');
|
|
@@ -127,11 +128,74 @@ describe('CodexAgentsStrategy', () => {
|
|
|
127
128
|
const project = path_1.default.join(tmpWork, 'repo');
|
|
128
129
|
fs_1.default.mkdirSync(project, { recursive: true });
|
|
129
130
|
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
130
|
-
|
|
131
|
-
expect(strategy.injectProject(project)).toBe('
|
|
131
|
+
const provider = codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md'));
|
|
132
|
+
expect(strategy.injectProject(project, provider)).toBe('injected');
|
|
133
|
+
expect(strategy.injectProject(project, provider)).toBe('unchanged');
|
|
132
134
|
expect(fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8'))
|
|
133
135
|
.toContain('when that file exists');
|
|
134
136
|
});
|
|
137
|
+
it('injectProject writes a redundant .cursor/rules/awm.mdc carrier (alwaysApply: true) for Cursor, and skips its own AGENTS.md write (owned by inject(), see collision regression below)', () => {
|
|
138
|
+
const project = path_1.default.join(tmpWork, 'cursor-repo');
|
|
139
|
+
fs_1.default.mkdirSync(project, { recursive: true });
|
|
140
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
141
|
+
const result = strategy.injectProject(project, cursorProvider(), 'cursor');
|
|
142
|
+
expect(result).toBe('injected');
|
|
143
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(false);
|
|
144
|
+
const mdc = fs_1.default.readFileSync(path_1.default.join(project, '.cursor/rules/awm.mdc'), 'utf8');
|
|
145
|
+
expect(mdc).toContain('alwaysApply: true');
|
|
146
|
+
expect(mdc).toContain('Read and obey `CONSTITUTION.md` before work');
|
|
147
|
+
});
|
|
148
|
+
it('injectProject writes only AGENTS.md (no .cursor/rules) for a provider whose context injection is a SEPARATE (global) file', () => {
|
|
149
|
+
const project = path_1.default.join(tmpWork, 'codex-project-repo');
|
|
150
|
+
fs_1.default.mkdirSync(project, { recursive: true });
|
|
151
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
152
|
+
strategy.injectProject(project, codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md')));
|
|
153
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(true);
|
|
154
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, '.cursor'))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it('injectProject writes NEITHER AGENTS.md NOR a carrier for Copilot (local-context provider, no carrier mechanism)', () => {
|
|
157
|
+
const project = path_1.default.join(tmpWork, 'copilot-repo');
|
|
158
|
+
fs_1.default.mkdirSync(project, { recursive: true });
|
|
159
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
160
|
+
const result = strategy.injectProject(project, copilotProvider(), 'copilot');
|
|
161
|
+
expect(result).toBe('unchanged');
|
|
162
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(false);
|
|
163
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, '.cursor'))).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
it('regression: local-scope context injection + project constitution injection no longer collide on the same AGENTS.md managed block (R4 QA blocker)', () => {
|
|
166
|
+
// Before the fix: inject() (context) and injectProject() (constitution) both wrote
|
|
167
|
+
// the SAME single-slot managed block in <projectRoot>/AGENTS.md for a local-scope
|
|
168
|
+
// provider (Cursor/Copilot) — whichever ran second silently discarded the other's
|
|
169
|
+
// content. stepContextInjection runs before stepConstitutionInjection in the real
|
|
170
|
+
// init orchestrator (init/orchestrator.ts), so constitution always won, and the real
|
|
171
|
+
// AWM skill/context guidance never survived a real `awm init --agent cursor` run.
|
|
172
|
+
const project = path_1.default.join(tmpWork, 'collision-repo');
|
|
173
|
+
fs_1.default.mkdirSync(project, { recursive: true });
|
|
174
|
+
const materialized = path_1.default.join(project, '.awm/context/awm-context.md');
|
|
175
|
+
const contextMarkdown = '# AWM\n\nUse `development-process`. MUST invoke skills per policy.';
|
|
176
|
+
fs_1.default.mkdirSync(path_1.default.dirname(materialized), { recursive: true });
|
|
177
|
+
fs_1.default.writeFileSync(materialized, contextMarkdown);
|
|
178
|
+
const provider = cursorProvider();
|
|
179
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
180
|
+
const input = {
|
|
181
|
+
ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(contextMarkdown) },
|
|
182
|
+
registryRoot: '/registry',
|
|
183
|
+
installMethod: 'copy',
|
|
184
|
+
agent: 'cursor',
|
|
185
|
+
scope: 'local',
|
|
186
|
+
projectRoot: project,
|
|
187
|
+
};
|
|
188
|
+
expect(strategy.inject(input, provider)).toBe('injected');
|
|
189
|
+
expect(strategy.injectProject(project, provider, 'cursor')).toBe('injected'); // carrier only
|
|
190
|
+
const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
|
|
191
|
+
expect(written).toContain('MUST invoke skills per policy');
|
|
192
|
+
expect(written).toContain('Read and obey `CONSTITUTION.md` before work');
|
|
193
|
+
expect(strategy.status(input, provider)).toBe('injected');
|
|
194
|
+
// Idempotent: a second full pass (context re-inject, then constitution/carrier) changes nothing.
|
|
195
|
+
expect(strategy.inject(input, provider)).toBe('unchanged');
|
|
196
|
+
expect(strategy.injectProject(project, provider, 'cursor')).toBe('unchanged');
|
|
197
|
+
expect(fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8')).toBe(written);
|
|
198
|
+
});
|
|
135
199
|
it('implements global status and remove while preserving user bytes', () => {
|
|
136
200
|
const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
|
|
137
201
|
const materialized = path_1.default.join(tmpWork, 'awm-context.md');
|
|
@@ -155,15 +219,70 @@ describe('CodexAgentsStrategy', () => {
|
|
|
155
219
|
});
|
|
156
220
|
it('validates public inputs and never writes outside the configured roots', () => {
|
|
157
221
|
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
158
|
-
|
|
159
|
-
expect(() => strategy.injectGlobal(
|
|
160
|
-
expect(() => strategy.
|
|
222
|
+
const provider = codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md'));
|
|
223
|
+
expect(() => strategy.injectGlobal({ markdown: '' }, provider)).toThrow('markdown');
|
|
224
|
+
expect(() => strategy.injectGlobal(null, provider)).toThrow('context');
|
|
225
|
+
expect(() => strategy.injectProject('', provider)).toThrow('projectRoot');
|
|
161
226
|
const open = jest.spyOn(fs_1.default, 'openSync');
|
|
162
|
-
strategy.injectGlobal({ markdown: 'safe' });
|
|
163
|
-
strategy.injectProject(path_1.default.join(tmpWork, 'safe-project'));
|
|
227
|
+
strategy.injectGlobal({ markdown: 'safe' }, provider);
|
|
228
|
+
strategy.injectProject(path_1.default.join(tmpWork, 'safe-project'), provider);
|
|
164
229
|
const opened = open.mock.calls.map((call) => String(call[0]));
|
|
165
230
|
expect(opened.every((file) => file.startsWith(tmpHome) || file.startsWith(tmpWork))).toBe(true);
|
|
166
231
|
});
|
|
232
|
+
it('regression: Codex (non-null globalPath) still requires global scope — unchanged behavior', () => {
|
|
233
|
+
const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
|
|
234
|
+
const materialized = path_1.default.join(tmpWork, 'awm-context.md');
|
|
235
|
+
const markdown = '# AWM\n\nExpected.';
|
|
236
|
+
fs_1.default.writeFileSync(materialized, markdown);
|
|
237
|
+
const provider = codexProvider(file);
|
|
238
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
239
|
+
const localInput = {
|
|
240
|
+
ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(markdown) },
|
|
241
|
+
registryRoot: '/registry',
|
|
242
|
+
installMethod: 'copy',
|
|
243
|
+
agent: 'codex',
|
|
244
|
+
scope: 'local',
|
|
245
|
+
projectRoot: tmpWork,
|
|
246
|
+
};
|
|
247
|
+
expect(() => strategy.inject(localInput, provider)).toThrow('supports only global injection');
|
|
248
|
+
expect(fs_1.default.existsSync(file)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
it('Copilot-shaped provider (null globalPath): inject() at scope local writes <projectRoot>/AGENTS.md, not any global path', () => {
|
|
251
|
+
const project = path_1.default.join(tmpWork, 'copilot-local-repo');
|
|
252
|
+
fs_1.default.mkdirSync(project, { recursive: true });
|
|
253
|
+
const materialized = path_1.default.join(tmpWork, 'awm-context.md');
|
|
254
|
+
const markdown = '# AWM\n\nCopilot body.';
|
|
255
|
+
fs_1.default.writeFileSync(materialized, markdown);
|
|
256
|
+
const provider = copilotProvider();
|
|
257
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
258
|
+
const input = {
|
|
259
|
+
ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(markdown) },
|
|
260
|
+
registryRoot: '/registry',
|
|
261
|
+
installMethod: 'copy',
|
|
262
|
+
agent: 'copilot',
|
|
263
|
+
scope: 'local',
|
|
264
|
+
projectRoot: project,
|
|
265
|
+
};
|
|
266
|
+
expect(strategy.inject(input, provider)).toBe('injected');
|
|
267
|
+
const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
|
|
268
|
+
expect(written).toContain('Copilot body.');
|
|
269
|
+
expect(strategy.status(input, provider)).toBe('injected');
|
|
270
|
+
});
|
|
271
|
+
it('Copilot-shaped provider (null globalPath): calling at scope global throws (required scope is local)', () => {
|
|
272
|
+
const materialized = path_1.default.join(tmpWork, 'awm-context.md');
|
|
273
|
+
const markdown = '# AWM\n\nBody.';
|
|
274
|
+
fs_1.default.writeFileSync(materialized, markdown);
|
|
275
|
+
const provider = copilotProvider();
|
|
276
|
+
const strategy = new codex_agents_1.CodexAgentsStrategy();
|
|
277
|
+
const globalInput = {
|
|
278
|
+
ref: { absPath: materialized, scope: 'global', contentHash: (0, provider_1.sha256)(markdown) },
|
|
279
|
+
registryRoot: '/registry',
|
|
280
|
+
installMethod: 'copy',
|
|
281
|
+
agent: 'copilot',
|
|
282
|
+
scope: 'global',
|
|
283
|
+
};
|
|
284
|
+
expect(() => strategy.inject(globalInput, provider)).toThrow('supports only local injection');
|
|
285
|
+
});
|
|
167
286
|
});
|
|
168
287
|
function codexProvider(globalPath) {
|
|
169
288
|
return {
|
|
@@ -174,6 +293,24 @@ function codexProvider(globalPath) {
|
|
|
174
293
|
injection: { type: 'managed-agents-md', globalPath, localFile: 'AGENTS.md' },
|
|
175
294
|
};
|
|
176
295
|
}
|
|
296
|
+
function copilotProvider() {
|
|
297
|
+
return {
|
|
298
|
+
label: 'Copilot',
|
|
299
|
+
skill: { global: null, local: '.github/instructions', renderer: 'link' },
|
|
300
|
+
workflow: null,
|
|
301
|
+
agent: null,
|
|
302
|
+
injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function cursorProvider() {
|
|
306
|
+
return {
|
|
307
|
+
label: 'Cursor',
|
|
308
|
+
skill: { global: '', local: '.cursor/rules', renderer: 'link' },
|
|
309
|
+
workflow: null,
|
|
310
|
+
agent: null,
|
|
311
|
+
injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
177
314
|
function injectionInput(absPath, markdown) {
|
|
178
315
|
return {
|
|
179
316
|
ref: { absPath, scope: 'global', contentHash: (0, provider_1.sha256)(markdown) },
|
|
@@ -0,0 +1,292 @@
|
|
|
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/diagnostics/provider-tier.test.ts
|
|
7
|
+
//
|
|
8
|
+
// Task 4.4 — capability tier. Three concerns:
|
|
9
|
+
//
|
|
10
|
+
// 1. `providerTier` — a pure structural classification derived from each
|
|
11
|
+
// provider's config shape (hooks / injection / neither).
|
|
12
|
+
// 2. `contextGlobalCheck`'s scope-awareness fix (deferred Task 4.2 finding):
|
|
13
|
+
// a `managed-agents-md` provider with `injection.globalPath === null`
|
|
14
|
+
// (Cursor, Copilot) operates at LOCAL scope, not global — asking
|
|
15
|
+
// `contextStatus` about 'global' for these providers always resolved to
|
|
16
|
+
// 'absent' regardless of whether the local injection actually succeeded.
|
|
17
|
+
// 3. `skillsGlobalCheck`'s renderer-awareness fix (deferred Task 4.3 finding):
|
|
18
|
+
// `classifyGlobalSkills` only ever sees symlinks, so a rendered format
|
|
19
|
+
// (cursor-mdc, copilot-instructions) always scanned as "0 broken" and
|
|
20
|
+
// reported a false-green 'healthy' regardless of what was actually on
|
|
21
|
+
// disk. Non-'link' renderers now report presence-only, honestly.
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const providers_1 = require("../../../src/providers");
|
|
26
|
+
const provider_checks_1 = require("../../../src/core/diagnostics/provider-checks");
|
|
27
|
+
describe('providerTier — pure structural classification', () => {
|
|
28
|
+
const expected = {
|
|
29
|
+
antigravity: 'context-only',
|
|
30
|
+
opencode: 'config-managed',
|
|
31
|
+
'claude-code': 'hooks-native',
|
|
32
|
+
codex: 'hooks-native',
|
|
33
|
+
cursor: 'agents-md-managed',
|
|
34
|
+
copilot: 'agents-md-managed',
|
|
35
|
+
};
|
|
36
|
+
it.each(providers_1.AGENT_TARGETS)('%s', (agent) => {
|
|
37
|
+
expect((0, provider_checks_1.providerTier)((0, providers_1.providers)()[agent])).toBe(expected[agent]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('contextGlobalCheck — scope-aware (Task 4.4 / deferred Task 4.2 finding)', () => {
|
|
41
|
+
let tmpHome;
|
|
42
|
+
let originalHome;
|
|
43
|
+
let originalAwmHome;
|
|
44
|
+
const projectRoots = [];
|
|
45
|
+
function seedRegistry() {
|
|
46
|
+
const root = path_1.default.join(tmpHome, '.awm/registries/baseline');
|
|
47
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills/using-awm'), { recursive: true });
|
|
48
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
49
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
|
|
50
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.invalid/baseline.git' }], null, 2));
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
function scanSkillsStub() {
|
|
54
|
+
return jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
55
|
+
}
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-home-'));
|
|
58
|
+
originalHome = process.env.HOME;
|
|
59
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
60
|
+
process.env.HOME = tmpHome;
|
|
61
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
62
|
+
// registries.ts caches AWM_HOME as a module-level const AT REQUIRE TIME (see its own
|
|
63
|
+
// top comment). A static top-level import of gatherProviderChecks (which transitively
|
|
64
|
+
// requires registries.ts) would bake in whatever AWM_HOME was set BEFORE this
|
|
65
|
+
// beforeEach ever ran, silently resolving capabilityRoot() against the real machine's
|
|
66
|
+
// ~/.awm instead of tmpHome. Every module that (transitively) touches AWM_HOME/HOME
|
|
67
|
+
// must therefore be require()'d fresh, per test, after the env vars above are set —
|
|
68
|
+
// same pattern as tests/core/diagnostics/provider-checks.test.ts.
|
|
69
|
+
jest.resetModules();
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
73
|
+
for (const p of projectRoots.splice(0))
|
|
74
|
+
fs_1.default.rmSync(p, { recursive: true, force: true });
|
|
75
|
+
if (originalHome === undefined)
|
|
76
|
+
delete process.env.HOME;
|
|
77
|
+
else
|
|
78
|
+
process.env.HOME = originalHome;
|
|
79
|
+
if (originalAwmHome === undefined)
|
|
80
|
+
delete process.env.AWM_HOME;
|
|
81
|
+
else
|
|
82
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
83
|
+
});
|
|
84
|
+
it('Cursor (local scope, globalPath === null) reports delivered when local injection actually succeeded', () => {
|
|
85
|
+
const contentDir = seedRegistry();
|
|
86
|
+
const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-project-'));
|
|
87
|
+
projectRoots.push(projectRoot);
|
|
88
|
+
const { InjectionOrchestrator } = require('../../../src/core/context/orchestrator');
|
|
89
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
90
|
+
new InjectionOrchestrator().installContext({
|
|
91
|
+
agent: 'cursor',
|
|
92
|
+
scope: 'local',
|
|
93
|
+
registryRoot: contentDir,
|
|
94
|
+
installMethod: 'symlink',
|
|
95
|
+
profileExtensions: [],
|
|
96
|
+
projectRoot,
|
|
97
|
+
});
|
|
98
|
+
const facts = gatherProviderChecks(['cursor'], scanSkillsStub(), projectRoot);
|
|
99
|
+
const contextCheck = facts[0].checks.find((c) => c.id === 'context.global');
|
|
100
|
+
expect(contextCheck).toMatchObject({ id: 'context.global', state: 'delivered' });
|
|
101
|
+
});
|
|
102
|
+
it('Copilot (local scope, globalPath === null) reports delivered when local injection actually succeeded', () => {
|
|
103
|
+
const contentDir = seedRegistry();
|
|
104
|
+
const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-project-'));
|
|
105
|
+
projectRoots.push(projectRoot);
|
|
106
|
+
const { InjectionOrchestrator } = require('../../../src/core/context/orchestrator');
|
|
107
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
108
|
+
new InjectionOrchestrator().installContext({
|
|
109
|
+
agent: 'copilot',
|
|
110
|
+
scope: 'local',
|
|
111
|
+
registryRoot: contentDir,
|
|
112
|
+
installMethod: 'symlink',
|
|
113
|
+
profileExtensions: [],
|
|
114
|
+
projectRoot,
|
|
115
|
+
});
|
|
116
|
+
const facts = gatherProviderChecks(['copilot'], scanSkillsStub(), projectRoot);
|
|
117
|
+
const contextCheck = facts[0].checks.find((c) => c.id === 'context.global');
|
|
118
|
+
expect(contextCheck).toMatchObject({ id: 'context.global', state: 'delivered' });
|
|
119
|
+
});
|
|
120
|
+
it('Cursor without a resolvable projectRoot falls back to absent, not a crash', () => {
|
|
121
|
+
seedRegistry();
|
|
122
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
123
|
+
const facts = gatherProviderChecks(['cursor'], scanSkillsStub(), undefined);
|
|
124
|
+
const contextCheck = facts[0].checks.find((c) => c.id === 'context.global');
|
|
125
|
+
expect(contextCheck).toMatchObject({ id: 'context.global', state: 'absent', remediationCode: 'awm-init' });
|
|
126
|
+
});
|
|
127
|
+
it('Codex (global scope, unchanged) still resolves correctly — regression', () => {
|
|
128
|
+
const contentDir = seedRegistry();
|
|
129
|
+
const { InjectionOrchestrator } = require('../../../src/core/context/orchestrator');
|
|
130
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
131
|
+
new InjectionOrchestrator().installContext({
|
|
132
|
+
agent: 'codex',
|
|
133
|
+
scope: 'global',
|
|
134
|
+
registryRoot: contentDir,
|
|
135
|
+
installMethod: 'symlink',
|
|
136
|
+
profileExtensions: [],
|
|
137
|
+
});
|
|
138
|
+
const facts = gatherProviderChecks(['codex'], scanSkillsStub());
|
|
139
|
+
const contextCheck = facts[0].checks.find((c) => c.id === 'context.global');
|
|
140
|
+
expect(contextCheck).toMatchObject({ id: 'context.global', state: 'delivered' });
|
|
141
|
+
});
|
|
142
|
+
it('Codex with nothing installed reports absent — regression (pre-existing behavior)', () => {
|
|
143
|
+
seedRegistry();
|
|
144
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
145
|
+
const facts = gatherProviderChecks(['codex'], scanSkillsStub());
|
|
146
|
+
const contextCheck = facts[0].checks.find((c) => c.id === 'context.global');
|
|
147
|
+
expect(contextCheck).toMatchObject({ id: 'context.global', state: 'absent', remediationCode: 'awm-init' });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 finding)', () => {
|
|
151
|
+
let tmpHome;
|
|
152
|
+
let originalHome;
|
|
153
|
+
let originalAwmHome;
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-skills-'));
|
|
156
|
+
originalHome = process.env.HOME;
|
|
157
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
158
|
+
process.env.HOME = tmpHome;
|
|
159
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
160
|
+
jest.resetModules(); // see contextGlobalCheck describe above for why
|
|
161
|
+
});
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
164
|
+
if (originalHome === undefined)
|
|
165
|
+
delete process.env.HOME;
|
|
166
|
+
else
|
|
167
|
+
process.env.HOME = originalHome;
|
|
168
|
+
if (originalAwmHome === undefined)
|
|
169
|
+
delete process.env.AWM_HOME;
|
|
170
|
+
else
|
|
171
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
172
|
+
});
|
|
173
|
+
it('non-link renderer (cursor-mdc) with real rendered files reports presence-only, not healthy', () => {
|
|
174
|
+
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
175
|
+
fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
|
176
|
+
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'development-process.mdc'), '---\ndescription: dev process\nalwaysApply: true\n---\n\nBody.');
|
|
177
|
+
// classifyGlobalSkills only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
|
|
178
|
+
// — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
|
|
179
|
+
// test proves the FIX (renderer-gating), not an accident of what classifyGlobalSkills does.
|
|
180
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
181
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
182
|
+
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
183
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
184
|
+
expect(skillsCheck?.state).not.toBe('healthy');
|
|
185
|
+
expect(skillsCheck?.state).toBe('supported');
|
|
186
|
+
expect(skillsCheck?.detail).toContain('not verified');
|
|
187
|
+
});
|
|
188
|
+
it('Gap B — non-link renderer (cursor-mdc) against REAL renderer/pipeline output, not a hand-written approximation', () => {
|
|
189
|
+
// The test above hand-writes a `.mdc` file whose frontmatter shape is only an
|
|
190
|
+
// approximation of what the real cursor-mdc renderer emits. This drives the
|
|
191
|
+
// REAL default `installBundle`/`applyInstallPlan` pipeline (core/bundle-install.ts,
|
|
192
|
+
// the same one `awm init`/`awm add` use) end-to-end for a global-scope Cursor
|
|
193
|
+
// skill, so the file skillsGlobalCheck inspects here is exactly what the
|
|
194
|
+
// renderer actually produces — not a fixture that merely resembles it.
|
|
195
|
+
const { discoverBundles } = require('../../../src/core/bundles');
|
|
196
|
+
const { installBundle } = require('../../../src/core/bundle-install');
|
|
197
|
+
const content = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-registry-'));
|
|
198
|
+
fs_1.default.mkdirSync(path_1.default.join(content, 'bundles', 'dev-core'), { recursive: true });
|
|
199
|
+
fs_1.default.mkdirSync(path_1.default.join(content, 'skills', 'using-awm'), { recursive: true });
|
|
200
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'skills', 'using-awm', 'SKILL.md'), '---\nname: using-awm\ndescription: Use when starting any development conversation\n---\n\nMUST invoke skills.\n');
|
|
201
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
|
|
202
|
+
version: 1,
|
|
203
|
+
bundles: [{ name: 'dev-core', source: './bundles/dev-core', version: '1.0.0', scope: 'baseline' }],
|
|
204
|
+
}));
|
|
205
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'bundles', 'dev-core', 'bundle.json'), JSON.stringify({
|
|
206
|
+
name: 'dev-core', version: '1.0.0', description: '', scope: 'baseline',
|
|
207
|
+
dependsOn: [], skills: ['using-awm'], workflows: [], agents: [],
|
|
208
|
+
}));
|
|
209
|
+
installBundle({
|
|
210
|
+
bundleName: 'dev-core',
|
|
211
|
+
bundles: discoverBundles(content),
|
|
212
|
+
agents: ['cursor'],
|
|
213
|
+
method: 'symlink',
|
|
214
|
+
projectRoot: tmpHome, // irrelevant for a global-scope install
|
|
215
|
+
contentDir: content,
|
|
216
|
+
});
|
|
217
|
+
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
218
|
+
expect(fs_1.default.existsSync(path_1.default.join(rulesDir, 'using-awm.mdc'))).toBe(true);
|
|
219
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
220
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
221
|
+
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
222
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
223
|
+
expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'supported', target: rulesDir });
|
|
224
|
+
expect(skillsCheck?.detail).toContain('not verified');
|
|
225
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
226
|
+
});
|
|
227
|
+
it('non-link renderer with an empty/missing dir reports absent, not a false healthy', () => {
|
|
228
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
229
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
230
|
+
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
231
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
232
|
+
expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent', remediationCode: 'awm-init' });
|
|
233
|
+
});
|
|
234
|
+
it('link renderer (claude-code) behavior is completely unchanged — regression', () => {
|
|
235
|
+
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
236
|
+
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
237
|
+
const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [] }));
|
|
238
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
239
|
+
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
240
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
241
|
+
expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'healthy', target: skillsDir });
|
|
242
|
+
expect(skillsCheck?.detail).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
it('link renderer (claude-code) still reports broken links — regression', () => {
|
|
245
|
+
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
246
|
+
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
247
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [] }));
|
|
248
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
249
|
+
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
250
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
251
|
+
expect(skillsCheck).toMatchObject({
|
|
252
|
+
id: 'skills.global',
|
|
253
|
+
state: 'broken',
|
|
254
|
+
detail: '1 broken links',
|
|
255
|
+
remediationCode: 'repair-global-skills',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
describe('false-positive fix — an unrelated file must not read as an AWM install', () => {
|
|
259
|
+
it('cursor: a dir containing ONLY an unrelated non-.mdc file reports absent, not supported', () => {
|
|
260
|
+
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
261
|
+
fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
|
262
|
+
// A user's own pre-existing file, or a directory they created themselves —
|
|
263
|
+
// neither ends in `.mdc`, so neither is AWM-shaped evidence.
|
|
264
|
+
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
265
|
+
fs_1.default.mkdirSync(path_1.default.join(rulesDir, 'some-user-dir'));
|
|
266
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
267
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
268
|
+
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
269
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
270
|
+
expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent', remediationCode: 'awm-init' });
|
|
271
|
+
});
|
|
272
|
+
it('cursor: a dir containing a real *.mdc file reports supported', () => {
|
|
273
|
+
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
274
|
+
fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
|
275
|
+
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
276
|
+
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'foo.mdc'), '---\ndescription: foo\nglobs:\nalwaysApply: false\n---\n\nBody.');
|
|
277
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
278
|
+
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
279
|
+
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
280
|
+
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
281
|
+
expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'supported' });
|
|
282
|
+
});
|
|
283
|
+
// NOTE: no copilot companion case here — copilot's `skill.global` is `null`
|
|
284
|
+
// (no user-level skill discovery mechanism at all, providers/index.ts), so
|
|
285
|
+
// `skillsGlobalCheck` returns `null` for it and `gatherProviderChecks` drops
|
|
286
|
+
// the `skills.global` row entirely before the renderer-extension gate this
|
|
287
|
+
// describe block exercises is ever reached. There is no real directory for
|
|
288
|
+
// a copilot-shaped false positive to occur against. The null-global-dir
|
|
289
|
+
// branch itself (the guard that makes this row vanish for copilot) is
|
|
290
|
+
// covered separately as part of Gap C's null-skip coverage.
|
|
291
|
+
});
|
|
292
|
+
});
|