agentic-workflow-manager 3.10.0 → 3.12.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 +27 -0
- package/dist/src/commands/sensors/formatters/mypy.js +30 -0
- package/dist/src/commands/sensors/formatters/ruff.js +45 -0
- package/dist/src/commands/sensors/formatters/shellcheck.js +45 -0
- package/dist/src/commands/sensors/index.js +11 -4
- package/dist/src/commands/sensors/init.js +80 -15
- package/dist/src/commands/sensors/run.js +36 -5
- package/dist/src/commands/sensors/status.js +8 -1
- 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 +49 -14
- package/dist/tests/commands/sensors/formatters/mypy.test.js +60 -0
- package/dist/tests/commands/sensors/formatters/ruff.test.js +92 -0
- package/dist/tests/commands/sensors/formatters/shellcheck.test.js +65 -0
- package/dist/tests/commands/sensors/init.test.js +159 -4
- package/dist/tests/commands/sensors/run.test.js +91 -0
- package/dist/tests/commands/sensors/status.test.js +29 -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
|
@@ -86,6 +86,79 @@ describe('runSensors', () => {
|
|
|
86
86
|
expect(sec.status).toBe('skipped');
|
|
87
87
|
expect(sec.skipReason).toBe('disabled');
|
|
88
88
|
});
|
|
89
|
+
it('dispatches by the formatter field, not the sensor name — ruff on a `lint` sensor', async () => {
|
|
90
|
+
// The whole point of the `formatter` field: a `lint` sensor is eslint for
|
|
91
|
+
// js-ts but ruff for python. Old name-based dispatch (lint -> eslint parser)
|
|
92
|
+
// would choke on ruff's flat JSON array (no `.messages` — TypeError) instead
|
|
93
|
+
// of parsing it. Real captured `ruff --output-format json` shape.
|
|
94
|
+
const pythonManifest = {
|
|
95
|
+
pack: 'python',
|
|
96
|
+
sensors: {
|
|
97
|
+
lint: { cmd: 'ruff check . --output-format json', fast: true, formatter: 'ruff' },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
fs_1.default.writeFileSync(path.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify(pythonManifest));
|
|
101
|
+
const ruffJson = JSON.stringify([{
|
|
102
|
+
code: 'F401',
|
|
103
|
+
filename: path.join(tmpDir, 'bad.py'),
|
|
104
|
+
location: { row: 1, column: 8 },
|
|
105
|
+
message: '`os` imported but unused',
|
|
106
|
+
}]);
|
|
107
|
+
mockRunCommand.mockResolvedValueOnce(exited(1, ruffJson));
|
|
108
|
+
const { runSensors } = load();
|
|
109
|
+
const result = await runSensors({ fast: true, cwd: tmpDir });
|
|
110
|
+
const lint = result.sensors.find((s) => s.name === 'lint');
|
|
111
|
+
expect(lint.status).toBe('fail');
|
|
112
|
+
expect(lint.errors[0].rule).toBe('F401');
|
|
113
|
+
expect(lint.errors[0].message).toMatch('SENSOR[lint]');
|
|
114
|
+
expect(lint.errors[0].message).toMatch('imported but unused');
|
|
115
|
+
});
|
|
116
|
+
it('falls back to name-based dispatch when formatter is absent (pre-existing manifest)', async () => {
|
|
117
|
+
// A manifest written before the `formatter` field existed must keep working
|
|
118
|
+
// exactly as before: `lint` -> eslint parser, no `formatter` key anywhere.
|
|
119
|
+
mockRunCommand
|
|
120
|
+
.mockResolvedValueOnce(exited(1, 'src/a.ts(1,1): error TS0001: Bad type.'))
|
|
121
|
+
.mockResolvedValueOnce(exited(1, JSON.stringify([{ filePath: '/x/a.js', messages: [{ ruleId: 'no-unused-vars', severity: 2, message: 'unused', line: 1, column: 1 }] }])));
|
|
122
|
+
const { runSensors } = load();
|
|
123
|
+
const result = await runSensors({ fast: true, cwd: tmpDir });
|
|
124
|
+
const lint = result.sensors.find((s) => s.name === 'lint');
|
|
125
|
+
expect(lint.status).toBe('fail');
|
|
126
|
+
expect(lint.errors[0].rule).toBe('no-unused-vars');
|
|
127
|
+
});
|
|
128
|
+
it('degrades to the generic formatter (never a wrong-shape misparse) for an unrecognized formatter value', async () => {
|
|
129
|
+
// Regression for Finding 5: `formatter: 'bandit'` is present but not one of the
|
|
130
|
+
// 8 known values. The OLD behavior fell through to name-based dispatch — sensor
|
|
131
|
+
// name 'security' -> parseSemgrepOutput — which would silently misparse
|
|
132
|
+
// bandit's differently-shaped `results` entries (no `path`/`start`/`check_id`
|
|
133
|
+
// keys) into garbage findings (`file: undefined, line: 0`). The fix must use
|
|
134
|
+
// parseGenericOutput instead: an honest raw-wrap, never a wrong-shape guess.
|
|
135
|
+
const banditManifest = {
|
|
136
|
+
pack: 'python',
|
|
137
|
+
sensors: {
|
|
138
|
+
security: { cmd: 'bandit -r . -f json', fast: false, formatter: 'bandit' },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
fs_1.default.writeFileSync(path.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify(banditManifest));
|
|
142
|
+
// Real bandit JSON shape (fields semgrep's parser does not know how to read).
|
|
143
|
+
const banditJson = JSON.stringify({
|
|
144
|
+
results: [
|
|
145
|
+
{ filename: 'app.py', line_number: 12, test_id: 'B105', issue_text: 'hardcoded password' },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
mockRunCommand.mockResolvedValueOnce(exited(1, banditJson));
|
|
149
|
+
const { runSensors } = load();
|
|
150
|
+
const result = await runSensors({ all: true, cwd: tmpDir });
|
|
151
|
+
const sec = result.sensors.find((s) => s.name === 'security');
|
|
152
|
+
expect(sec.status).toBe('fail');
|
|
153
|
+
expect(sec.errors).toHaveLength(1);
|
|
154
|
+
// Never the semgrep-misparse shape (file: undefined, line: 0, rule: undefined).
|
|
155
|
+
expect(sec.errors[0].file).toBeUndefined();
|
|
156
|
+
expect(sec.errors[0].line).toBeUndefined();
|
|
157
|
+
expect(sec.errors[0].rule).toBeUndefined();
|
|
158
|
+
// The honest generic raw-wrap: the raw JSON, verbatim, inside one message.
|
|
159
|
+
expect(sec.errors[0].message).toMatch('SENSOR[raw]');
|
|
160
|
+
expect(sec.errors[0].message).toContain('B105');
|
|
161
|
+
});
|
|
89
162
|
const tcError = () => exited(1, 'src/a.ts(1,1): error TS0001: Bad type.');
|
|
90
163
|
it('baseline suppresses accepted findings — sensor passes on no NEW findings', async () => {
|
|
91
164
|
const { runSensors } = load();
|
|
@@ -160,6 +233,24 @@ describe('runSensors — missing tool is a fail, not a skip', () => {
|
|
|
160
233
|
expect(out.overall).toBe('fail');
|
|
161
234
|
});
|
|
162
235
|
});
|
|
236
|
+
describe('runSensors — sensors: null in a hand-edited manifest', () => {
|
|
237
|
+
// Regression for Finding 3: `checkManifest` (preflight) already guards
|
|
238
|
+
// `manifest.sensors ?? {}` — runSensors' `Object.entries(activeManifest.sensors)`
|
|
239
|
+
// needs the same guard, or a corrupted/hand-edited `.awm/sensors.json` with
|
|
240
|
+
// `"sensors": null` crashes `Object.entries(null)`, taking down the whole run.
|
|
241
|
+
it('degrades gracefully instead of throwing when sensors is null', async () => {
|
|
242
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-null-sensors-'));
|
|
243
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
244
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack: 'python', sensors: null }));
|
|
245
|
+
try {
|
|
246
|
+
const result = await (0, run_1.runSensors)({ cwd: dir, all: true });
|
|
247
|
+
expect(result.sensors).toEqual([]);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
fs_1.default.rmSync(dir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
163
254
|
describe('runSensors — not_certified + auto-discovery', () => {
|
|
164
255
|
let tmpDir;
|
|
165
256
|
beforeEach(() => {
|
|
@@ -109,6 +109,35 @@ describe('computeSensorStatus', () => {
|
|
|
109
109
|
expect(result.checks.security.ok).toBe(true);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
it('is DEGRADED (never HEALTHY) when the manifest has zero sensor entries', () => {
|
|
113
|
+
// `Object.values({}).every(...)` is vacuously true — guard against reading an
|
|
114
|
+
// empty manifest (the honest floor when the registry had no pack.json for the
|
|
115
|
+
// detected stack) as a clean run that found nothing wrong.
|
|
116
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
117
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
118
|
+
pack: 'python',
|
|
119
|
+
sensors: {},
|
|
120
|
+
}));
|
|
121
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
122
|
+
expect(result.overall).toBe('DEGRADED');
|
|
123
|
+
expect(result.pack).toBe('python');
|
|
124
|
+
});
|
|
125
|
+
it('degrades gracefully (never throws) when sensors is null in a hand-edited manifest', () => {
|
|
126
|
+
// Regression for Finding 3: `checkManifest` (preflight) already guards
|
|
127
|
+
// `manifest.sensors ?? {}` — computeSensorStatus needs the same guard, or a
|
|
128
|
+
// corrupted/hand-edited manifest with `"sensors": null` crashes
|
|
129
|
+
// `Object.entries(null)`.
|
|
130
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
131
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
132
|
+
pack: 'python',
|
|
133
|
+
sensors: null,
|
|
134
|
+
}));
|
|
135
|
+
expect(() => (0, status_1.computeSensorStatus)(tmpDir)).not.toThrow();
|
|
136
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
137
|
+
expect(result.overall).toBe('DEGRADED');
|
|
138
|
+
expect(result.pack).toBe('python');
|
|
139
|
+
expect(result.checks).toEqual({});
|
|
140
|
+
});
|
|
112
141
|
it('marks disabled sensors as ok', () => {
|
|
113
142
|
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
114
143
|
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
@@ -248,6 +248,69 @@ describe('installBundle', () => {
|
|
|
248
248
|
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.awm/profile.json'))).toBe(true);
|
|
249
249
|
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.gitignore'))).toBe(true);
|
|
250
250
|
});
|
|
251
|
+
it('materializes a skill as a rendered Cursor .mdc via the real default applyInstallPlan (Task 4.3 e2e)', () => {
|
|
252
|
+
const { content, projectRoot, bundles } = makeFixture();
|
|
253
|
+
// s-base's fixture SKILL.md (makeFixture, above) has no `description`
|
|
254
|
+
// field — real for a bare skill fixture, but cursor-mdc.ts's renderer
|
|
255
|
+
// requires one (parseSkillSource). Seed a real description here so
|
|
256
|
+
// this is a genuine end-to-end render, not just a source-existence check.
|
|
257
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'skills', 's-base', 'SKILL.md'), '---\nname: s-base\ndescription: Base skill for bundle-install fixtures\n---\n\nDo the base thing.\n');
|
|
258
|
+
const base = bundles.find((bundle) => bundle.name === 'base');
|
|
259
|
+
const result = (0, bundle_install_1.installBundle)({
|
|
260
|
+
bundleName: 'base',
|
|
261
|
+
bundles: [base],
|
|
262
|
+
agents: ['cursor'],
|
|
263
|
+
method: 'symlink',
|
|
264
|
+
projectRoot,
|
|
265
|
+
contentDir: content,
|
|
266
|
+
});
|
|
267
|
+
const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc');
|
|
268
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base'))).toBe(false);
|
|
269
|
+
expect(fs_1.default.existsSync(mdcPath)).toBe(true);
|
|
270
|
+
const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
|
|
271
|
+
expect(rendered).toContain('description: Base skill for bundle-install fixtures');
|
|
272
|
+
expect(rendered).toContain('alwaysApply: false');
|
|
273
|
+
expect(rendered).toContain('Do the base thing.');
|
|
274
|
+
expect(result.transactionId).toBeTruthy();
|
|
275
|
+
expect(result.modifiedFiles).toContain(mdcPath);
|
|
276
|
+
});
|
|
277
|
+
it('materializes a skill as rendered Copilot .instructions.md via the real default applyInstallPlan (Task 4.3 e2e)', () => {
|
|
278
|
+
const { content, projectRoot, bundles } = makeFixture();
|
|
279
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'skills', 's-base', 'SKILL.md'), '---\nname: s-base\ndescription: Base skill for bundle-install fixtures\n---\n\nDo the base thing.\n');
|
|
280
|
+
const base = bundles.find((bundle) => bundle.name === 'base');
|
|
281
|
+
const result = (0, bundle_install_1.installBundle)({
|
|
282
|
+
bundleName: 'base',
|
|
283
|
+
bundles: [base],
|
|
284
|
+
agents: ['copilot'],
|
|
285
|
+
method: 'symlink',
|
|
286
|
+
projectRoot,
|
|
287
|
+
contentDir: content,
|
|
288
|
+
});
|
|
289
|
+
const instructionsPath = path_1.default.join(projectRoot, '.github/instructions/s-base.instructions.md');
|
|
290
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.github/instructions/s-base'))).toBe(false);
|
|
291
|
+
expect(fs_1.default.existsSync(instructionsPath)).toBe(true);
|
|
292
|
+
const rendered = fs_1.default.readFileSync(instructionsPath, 'utf8');
|
|
293
|
+
expect(rendered).toContain('applyTo: "**"');
|
|
294
|
+
expect(rendered).toContain('Do the base thing.');
|
|
295
|
+
expect(result.transactionId).toBeTruthy();
|
|
296
|
+
expect(result.modifiedFiles).toContain(instructionsPath);
|
|
297
|
+
});
|
|
298
|
+
it('applies real filesystem changes for Cursor + Copilot via addBundle (awm add e2e, no applyPlan override)', () => {
|
|
299
|
+
const { content, projectRoot, bundles } = makeFixture();
|
|
300
|
+
fs_1.default.writeFileSync(path_1.default.join(content, 'skills', 's-base', 'SKILL.md'), '---\nname: s-base\ndescription: Base skill for bundle-install fixtures\n---\n\nDo the base thing.\n');
|
|
301
|
+
const cursorResult = (0, bundle_install_1.addBundle)({
|
|
302
|
+
bundleName: 'base', bundles, agents: ['cursor'],
|
|
303
|
+
method: 'symlink', projectRoot, contentDir: content,
|
|
304
|
+
});
|
|
305
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc'))).toBe(true);
|
|
306
|
+
expect(cursorResult.recordedExtension).toBe('base');
|
|
307
|
+
const copilotResult = (0, bundle_install_1.addBundle)({
|
|
308
|
+
bundleName: 'base', bundles, agents: ['copilot'],
|
|
309
|
+
method: 'symlink', projectRoot, contentDir: content,
|
|
310
|
+
});
|
|
311
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.github/instructions/s-base.instructions.md'))).toBe(true);
|
|
312
|
+
expect(copilotResult.recordedExtension).toBe('base');
|
|
313
|
+
});
|
|
251
314
|
it('installs a skill shared by two agents (OpenCode + Codex) with exactly one replaceArtifact call, but the summary contains both providers', () => {
|
|
252
315
|
const { content, projectRoot, bundles } = makeFixture();
|
|
253
316
|
const base = bundles.find((bundle) => bundle.name === 'base');
|
|
@@ -17,6 +17,14 @@ describe('globalContextPath', () => {
|
|
|
17
17
|
expect((0, materializer_1.globalContextPath)()).toContain(path_1.default.join('context', 'awm-context.md'));
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
|
+
describe('projectContextPath', () => {
|
|
21
|
+
it('points under <projectRoot>/.awm/context, mirroring globalContextPath\'s shape', () => {
|
|
22
|
+
expect((0, materializer_1.projectContextPath)('/repo')).toBe(path_1.default.join('/repo', '.awm', 'context', 'awm-context.md'));
|
|
23
|
+
});
|
|
24
|
+
it('differs from globalContextPath (distinct materialized-source paths per scope)', () => {
|
|
25
|
+
expect((0, materializer_1.projectContextPath)('/repo')).not.toBe((0, materializer_1.globalContextPath)());
|
|
26
|
+
});
|
|
27
|
+
});
|
|
20
28
|
describe('materialize', () => {
|
|
21
29
|
it('writes the content and returns a ref with the matching hash', () => {
|
|
22
30
|
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mat-'));
|
|
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const orchestrator_1 = require("../../../src/core/context/orchestrator");
|
|
11
|
+
const materializer_1 = require("../../../src/core/context/materializer");
|
|
11
12
|
jest.mock('../../../src/commands/hooks/install', () => ({ installHook: jest.fn() }));
|
|
12
13
|
jest.mock('../../../src/commands/hooks/uninstall', () => ({ uninstallHook: jest.fn() }));
|
|
13
14
|
jest.mock('../../../src/commands/hooks/status', () => ({
|
|
@@ -170,3 +171,53 @@ describe('InjectionOrchestrator (codex, managed AGENTS.md strategy)', () => {
|
|
|
170
171
|
expect(fs_1.default.readFileSync(agentsPath, 'utf8')).toBe('# User rules\n');
|
|
171
172
|
});
|
|
172
173
|
});
|
|
174
|
+
describe('InjectionOrchestrator (local scope materializes under the project, not ~/.awm)', () => {
|
|
175
|
+
let tmpHome;
|
|
176
|
+
let projectRoot;
|
|
177
|
+
let registryRoot;
|
|
178
|
+
let originalHome;
|
|
179
|
+
let originalAwmHome;
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-home-'));
|
|
182
|
+
projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-proj-'));
|
|
183
|
+
registryRoot = tmpRegistry();
|
|
184
|
+
originalHome = process.env.HOME;
|
|
185
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
186
|
+
process.env.HOME = tmpHome;
|
|
187
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
188
|
+
});
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
if (originalHome === undefined)
|
|
191
|
+
delete process.env.HOME;
|
|
192
|
+
else
|
|
193
|
+
process.env.HOME = originalHome;
|
|
194
|
+
if (originalAwmHome === undefined)
|
|
195
|
+
delete process.env.AWM_HOME;
|
|
196
|
+
else
|
|
197
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
198
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
199
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
200
|
+
fs_1.default.rmSync(registryRoot, { recursive: true, force: true });
|
|
201
|
+
});
|
|
202
|
+
it('materializes to projectContextPath(projectRoot) for scope local, never to globalContextPath()', () => {
|
|
203
|
+
const orch = new orchestrator_1.InjectionOrchestrator({
|
|
204
|
+
providerOverride: {
|
|
205
|
+
label: 'Copilot', skill: { global: null, local: '.github/instructions', renderer: 'link' }, workflow: null, agent: null,
|
|
206
|
+
injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const op = {
|
|
210
|
+
agent: 'copilot',
|
|
211
|
+
scope: 'local',
|
|
212
|
+
registryRoot,
|
|
213
|
+
installMethod: 'copy',
|
|
214
|
+
profileExtensions: [],
|
|
215
|
+
projectRoot,
|
|
216
|
+
};
|
|
217
|
+
orch.installContext(op);
|
|
218
|
+
expect(fs_1.default.existsSync((0, materializer_1.projectContextPath)(projectRoot))).toBe(true);
|
|
219
|
+
expect(fs_1.default.existsSync((0, materializer_1.globalContextPath)())).toBe(false);
|
|
220
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, 'AGENTS.md'))).toBe(true);
|
|
221
|
+
expect(orch.contextStatus(op)).toBe('injected');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -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) },
|