agentic-workflow-manager 9.17.0 → 9.19.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/env/index.js +192 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/registry/doctor.js +428 -0
- package/dist/src/commands/registry/index.js +52 -0
- package/dist/src/commands/registry/scaffold.js +140 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +23 -60
- package/dist/src/commands/watch/index.js +3 -0
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/src/core/environments/resolve.js +122 -0
- package/dist/src/core/environments/store.js +323 -0
- package/dist/src/core/facts/catalog.js +138 -0
- package/dist/src/core/facts/needs.js +57 -0
- package/dist/src/core/facts/types.js +21 -0
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/env/index.test.js +261 -0
- package/dist/tests/commands/registry/doctor.test.js +283 -0
- package/dist/tests/commands/registry/scaffold.test.js +186 -0
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/commands/watch/reopened-196.test.js +9 -2
- package/dist/tests/core/admission/index.test.js +60 -0
- package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- package/dist/tests/core/environments/resolve.test.js +347 -0
- package/dist/tests/core/environments/store.test.js +248 -0
- package/dist/tests/core/facts/catalog.test.js +179 -0
- package/dist/tests/core/facts/needs.test.js +88 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/dist/tests/structural/operator-environments-docs-contract.test.js +70 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
exports.readSkillNeeds = readSkillNeeds;
|
|
7
|
+
exports.findOrphanNeeds = findOrphanNeeds;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
|
+
const frontmatter_1 = require("../frontmatter");
|
|
11
|
+
const types_1 = require("./types");
|
|
12
|
+
/**
|
|
13
|
+
* Read the `needs` list from a skill SKILL.md frontmatter.
|
|
14
|
+
* Absent → `[]`. Invalid shape → {@link SkillNeedsError}.
|
|
15
|
+
*/
|
|
16
|
+
function readSkillNeeds(skillMdPath) {
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs_1.default.lstatSync(skillMdPath);
|
|
20
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
21
|
+
throw new types_1.SkillNeedsError(`Skill path ${skillMdPath} must be a regular file`);
|
|
22
|
+
}
|
|
23
|
+
raw = fs_1.default.readFileSync(skillMdPath, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e instanceof types_1.SkillNeedsError)
|
|
27
|
+
throw e;
|
|
28
|
+
throw new types_1.SkillNeedsError(`Cannot read skill at ${skillMdPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
29
|
+
}
|
|
30
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
|
|
31
|
+
if (block === null)
|
|
32
|
+
return [];
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = js_yaml_1.default.load(block);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new types_1.SkillNeedsError(`Invalid skill frontmatter at ${skillMdPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
39
|
+
}
|
|
40
|
+
if (parsed === null || parsed === undefined)
|
|
41
|
+
return [];
|
|
42
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
43
|
+
throw new types_1.SkillNeedsError(`Invalid skill frontmatter at ${skillMdPath}: expected a mapping`);
|
|
44
|
+
}
|
|
45
|
+
const needs = parsed.needs;
|
|
46
|
+
if (needs === undefined)
|
|
47
|
+
return [];
|
|
48
|
+
if (!Array.isArray(needs) || needs.some((n) => typeof n !== 'string' || n === '')) {
|
|
49
|
+
throw new types_1.SkillNeedsError(`Invalid needs at ${skillMdPath}: needs must be a YAML list of non-empty strings`);
|
|
50
|
+
}
|
|
51
|
+
return needs;
|
|
52
|
+
}
|
|
53
|
+
/** Ids in `needs` that are absent from the catalog (fail-closed; never invent). */
|
|
54
|
+
function findOrphanNeeds(needs, catalog) {
|
|
55
|
+
// Own keys only — `in` would treat Object.prototype (toString, …) as catalog facts.
|
|
56
|
+
return needs.filter((id) => !Object.hasOwn(catalog.facts, id));
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SkillNeedsError = exports.FactsCatalogError = exports.FACT_ID_RE = exports.FACTS_MIN_CLI_VERSION = void 0;
|
|
4
|
+
/** First CLI version that understands registry facts / needs. Bump only with intentional release. */
|
|
5
|
+
exports.FACTS_MIN_CLI_VERSION = '9.18.0';
|
|
6
|
+
/** Dotted fact ids: `repos.root`, `aws.profile.dev`. */
|
|
7
|
+
exports.FACT_ID_RE = /^[a-z][a-z0-9]*(\.[a-z0-9]+)+$/;
|
|
8
|
+
class FactsCatalogError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'FactsCatalogError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.FactsCatalogError = FactsCatalogError;
|
|
15
|
+
class SkillNeedsError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'SkillNeedsError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
exports.SkillNeedsError = SkillNeedsError;
|
package/dist/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ const doctor_1 = require("./commands/doctor");
|
|
|
37
37
|
const backup_1 = require("./commands/backup");
|
|
38
38
|
const init_1 = require("./commands/init");
|
|
39
39
|
const registry_1 = require("./commands/registry");
|
|
40
|
+
const env_1 = require("./commands/env");
|
|
40
41
|
const pin_1 = require("./commands/pin");
|
|
41
42
|
const export_1 = require("./commands/export");
|
|
42
43
|
const agent_1 = require("./commands/agent");
|
|
@@ -743,6 +744,7 @@ miroCmd.command('sync <storyMapPath>')
|
|
|
743
744
|
(0, backup_1.registerBackupCommand)(exports.program);
|
|
744
745
|
(0, init_1.registerInitCommand)(exports.program);
|
|
745
746
|
(0, registry_1.registerRegistryCommand)(exports.program);
|
|
747
|
+
(0, env_1.registerEnvCommand)(exports.program);
|
|
746
748
|
(0, pin_1.registerPinCommands)(exports.program);
|
|
747
749
|
(0, export_1.registerExportCommand)(exports.program);
|
|
748
750
|
(0, agent_1.registerAgentCommand)(exports.program);
|
|
@@ -0,0 +1,261 @@
|
|
|
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 commander_1 = require("commander");
|
|
10
|
+
const env_1 = require("../../../src/commands/env");
|
|
11
|
+
const store_1 = require("../../../src/core/environments/store");
|
|
12
|
+
const catalog = {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
facts: {
|
|
15
|
+
'repos.root': {
|
|
16
|
+
valueType: 'path',
|
|
17
|
+
resolution: 'prompt',
|
|
18
|
+
durability: 'durable',
|
|
19
|
+
inject: 'reference',
|
|
20
|
+
},
|
|
21
|
+
'aws.profile': {
|
|
22
|
+
valueType: 'string',
|
|
23
|
+
resolution: 'prompt',
|
|
24
|
+
durability: 'durable',
|
|
25
|
+
inject: 'reference',
|
|
26
|
+
},
|
|
27
|
+
'env.kind': {
|
|
28
|
+
valueType: 'enum',
|
|
29
|
+
resolution: 'prompt',
|
|
30
|
+
durability: 'durable',
|
|
31
|
+
inject: 'reference',
|
|
32
|
+
values: ['dev', 'prod'],
|
|
33
|
+
},
|
|
34
|
+
'aws.token': {
|
|
35
|
+
valueType: 'string',
|
|
36
|
+
resolution: 'prompt',
|
|
37
|
+
durability: 'volatile',
|
|
38
|
+
inject: 'reference',
|
|
39
|
+
ttl: 1,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
function isolateHome() {
|
|
44
|
+
const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-env-cmd-'));
|
|
45
|
+
process.env.AWM_HOME = path_1.default.join(tmp, '.awm');
|
|
46
|
+
process.env.HOME = tmp;
|
|
47
|
+
return tmp;
|
|
48
|
+
}
|
|
49
|
+
async function run(argv, loadCatalog = () => catalog, extraDeps = {}) {
|
|
50
|
+
const prog = new commander_1.Command();
|
|
51
|
+
prog.exitOverride();
|
|
52
|
+
prog.configureOutput({ writeErr: () => undefined });
|
|
53
|
+
(0, env_1.registerEnvCommand)(prog, {
|
|
54
|
+
loadCatalog,
|
|
55
|
+
consultedRoots: () => ['/tmp/test-registry'],
|
|
56
|
+
stdinIsTTY: () => false,
|
|
57
|
+
...extraDeps,
|
|
58
|
+
});
|
|
59
|
+
const outSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
60
|
+
const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
61
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
62
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
63
|
+
try {
|
|
64
|
+
await prog.parseAsync(['node', 'awm', 'env', ...argv]);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
// keep spies until we collect
|
|
68
|
+
}
|
|
69
|
+
const out = outSpy.mock.calls.map((c) => String(c[0])).join('') +
|
|
70
|
+
logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n');
|
|
71
|
+
const err = errSpy.mock.calls.map((c) => String(c[0])).join('') +
|
|
72
|
+
errorSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n');
|
|
73
|
+
outSpy.mockRestore();
|
|
74
|
+
errSpy.mockRestore();
|
|
75
|
+
logSpy.mockRestore();
|
|
76
|
+
errorSpy.mockRestore();
|
|
77
|
+
return { out, err };
|
|
78
|
+
}
|
|
79
|
+
describe('awm env commands (RF-2, RF-3.3)', () => {
|
|
80
|
+
let tmp;
|
|
81
|
+
let prevHome;
|
|
82
|
+
let prevAwm;
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
prevHome = process.env.HOME;
|
|
85
|
+
prevAwm = process.env.AWM_HOME;
|
|
86
|
+
tmp = isolateHome();
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
90
|
+
if (prevHome === undefined)
|
|
91
|
+
delete process.env.HOME;
|
|
92
|
+
else
|
|
93
|
+
process.env.HOME = prevHome;
|
|
94
|
+
if (prevAwm === undefined)
|
|
95
|
+
delete process.env.AWM_HOME;
|
|
96
|
+
else
|
|
97
|
+
process.env.AWM_HOME = prevAwm;
|
|
98
|
+
});
|
|
99
|
+
it('set --value writes under environments/default/values.json without shelling out (RF-2.1, RF-3.3)', async () => {
|
|
100
|
+
const spawn = jest.spyOn(require('child_process'), 'spawnSync');
|
|
101
|
+
const execFile = jest.spyOn(require('child_process'), 'execFileSync');
|
|
102
|
+
await run(['set', 'repos.root', '--value', '/machine/repos']);
|
|
103
|
+
expect((0, store_1.readValues)('default')['repos.root'].value).toBe('/machine/repos');
|
|
104
|
+
expect(fs_1.default.existsSync((0, store_1.valuesPath)('default'))).toBe(true);
|
|
105
|
+
expect((0, store_1.valuesPath)('default')).toContain(path_1.default.join('.awm', 'environments', 'default', 'values.json'));
|
|
106
|
+
expect(spawn).not.toHaveBeenCalled();
|
|
107
|
+
expect(execFile).not.toHaveBeenCalled();
|
|
108
|
+
spawn.mockRestore();
|
|
109
|
+
execFile.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
it('status reports missing for unset facts and fresh after set (RF-2.5)', async () => {
|
|
112
|
+
let { out } = await run(['status']);
|
|
113
|
+
expect(out).toMatch(/repos\.root:\s*missing/);
|
|
114
|
+
expect(out).toMatch(/aws\.profile:\s*missing/);
|
|
115
|
+
await run(['set', 'repos.root', '--value', '/r']);
|
|
116
|
+
({ out } = await run(['status']));
|
|
117
|
+
expect(out).toMatch(/repos\.root:\s*fresh/);
|
|
118
|
+
// Unset sibling stays missing — no silent default invent for other ids.
|
|
119
|
+
expect(out).toMatch(/aws\.profile:\s*missing/);
|
|
120
|
+
expect(out).not.toMatch(/aws\.profile:\s*fresh/);
|
|
121
|
+
});
|
|
122
|
+
it('status reports stale for expired volatile fact (RF-2.5)', async () => {
|
|
123
|
+
(0, store_1.writeFact)('default', 'aws.token', {
|
|
124
|
+
value: 'tok',
|
|
125
|
+
status: 'fresh',
|
|
126
|
+
updatedAt: '2026-10-12T00:00:00.000Z',
|
|
127
|
+
}, { definition: catalog.facts['aws.token'] });
|
|
128
|
+
const { out } = await run(['status'], () => catalog, {
|
|
129
|
+
now: () => new Date('2026-10-12T00:00:02.000Z'),
|
|
130
|
+
});
|
|
131
|
+
expect(out).toMatch(/aws\.token:\s*stale/);
|
|
132
|
+
});
|
|
133
|
+
it('edit prints absolute store path', async () => {
|
|
134
|
+
const { out } = await run(['edit']);
|
|
135
|
+
expect(out).toContain((0, store_1.valuesPath)((0, store_1.getActiveEnvironmentName)()));
|
|
136
|
+
expect(path_1.default.isAbsolute(out.trim().split(/\s+/).find((t) => t.includes('values.json')) || (0, store_1.valuesPath)('default'))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('refresh --value updates an existing fact without shell (RF-3.3)', async () => {
|
|
139
|
+
await run(['set', 'aws.profile', '--value', 'old']);
|
|
140
|
+
await run(['refresh', 'aws.profile', '--value', 'new-profile']);
|
|
141
|
+
expect((0, store_1.readValues)('default')['aws.profile'].value).toBe('new-profile');
|
|
142
|
+
});
|
|
143
|
+
it('refresh without --value in non-interactive mode errors', async () => {
|
|
144
|
+
await run(['set', 'aws.profile', '--value', 'old']);
|
|
145
|
+
await expect(run(['refresh', 'aws.profile'])).rejects.toThrow(/--value|value/i);
|
|
146
|
+
});
|
|
147
|
+
it('use switches active environment (RF-2.2)', async () => {
|
|
148
|
+
await run(['use', 'staging']);
|
|
149
|
+
expect((0, store_1.getActiveEnvironmentName)()).toBe('staging');
|
|
150
|
+
await run(['set', 'repos.root', '--value', '/staging/repos']);
|
|
151
|
+
expect((0, store_1.valuesPath)('staging')).toContain(`${path_1.default.sep}staging${path_1.default.sep}values.json`);
|
|
152
|
+
expect((0, store_1.readValues)('staging')['repos.root'].value).toBe('/staging/repos');
|
|
153
|
+
});
|
|
154
|
+
it('set unknown id errors listing consulted roots', async () => {
|
|
155
|
+
await expect(run(['set', 'no.such.fact', '--value', 'x'])).rejects.toThrow(/no\.such\.fact/);
|
|
156
|
+
await expect(run(['set', 'no.such.fact', '--value', 'x'])).rejects.toThrow(/test-registry/);
|
|
157
|
+
expect((0, store_1.readValues)('default')).toEqual({});
|
|
158
|
+
});
|
|
159
|
+
it('set rejects empty path and leaves store unchanged', async () => {
|
|
160
|
+
await expect(run(['set', 'repos.root', '--value', ''])).rejects.toThrow(/path|empty|value/i);
|
|
161
|
+
expect((0, store_1.readValues)('default')).toEqual({});
|
|
162
|
+
});
|
|
163
|
+
it('set rejects invalid enum value', async () => {
|
|
164
|
+
await expect(run(['set', 'env.kind', '--value', 'qa'])).rejects.toThrow(/enum|dev|prod/i);
|
|
165
|
+
expect((0, store_1.readValues)('default')).toEqual({});
|
|
166
|
+
});
|
|
167
|
+
it('set never imports child_process for resolution (RF-3.3)', () => {
|
|
168
|
+
const src = fs_1.default.readFileSync(path_1.default.join(__dirname, '../../../src/commands/env/index.ts'), 'utf-8');
|
|
169
|
+
expect(src).not.toMatch(/child_process/);
|
|
170
|
+
expect(src).not.toMatch(/\bexecSync\b|\bspawnSync\b|\bexecFileSync\b/);
|
|
171
|
+
});
|
|
172
|
+
it('set without --value on TTY uses promptFn (RF-3.3)', async () => {
|
|
173
|
+
const prompts = [];
|
|
174
|
+
await run(['set', 'repos.root'], () => catalog, {
|
|
175
|
+
stdinIsTTY: () => true,
|
|
176
|
+
promptValue: (q) => {
|
|
177
|
+
prompts.push(q);
|
|
178
|
+
return '/prompted/repos';
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
expect(prompts.length).toBeGreaterThanOrEqual(1);
|
|
182
|
+
expect((0, store_1.readValues)('default')['repos.root'].value).toBe('/prompted/repos');
|
|
183
|
+
});
|
|
184
|
+
it('set without --value when not TTY keeps requireValue error (RF-3.3)', async () => {
|
|
185
|
+
await expect(run(['set', 'repos.root'], () => catalog, { stdinIsTTY: () => false })).rejects.toThrow(/--value|value/i);
|
|
186
|
+
});
|
|
187
|
+
it('refresh --all on TTY prompts each store id via promptFn', async () => {
|
|
188
|
+
await run(['set', 'repos.root', '--value', '/old']);
|
|
189
|
+
await run(['set', 'aws.profile', '--value', 'old-profile']);
|
|
190
|
+
const seen = [];
|
|
191
|
+
const answers = {
|
|
192
|
+
'repos.root': '/new',
|
|
193
|
+
'aws.profile': 'new-profile',
|
|
194
|
+
};
|
|
195
|
+
await run(['refresh', '--all'], () => catalog, {
|
|
196
|
+
stdinIsTTY: () => true,
|
|
197
|
+
promptValue: (q) => {
|
|
198
|
+
const id = Object.keys(answers).find((k) => q.includes(k));
|
|
199
|
+
if (!id)
|
|
200
|
+
throw new Error(`unexpected prompt: ${q}`);
|
|
201
|
+
seen.push(id);
|
|
202
|
+
return answers[id];
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(seen.sort()).toEqual(['aws.profile', 'repos.root']);
|
|
206
|
+
expect((0, store_1.readValues)('default')['repos.root'].value).toBe('/new');
|
|
207
|
+
expect((0, store_1.readValues)('default')['aws.profile'].value).toBe('new-profile');
|
|
208
|
+
});
|
|
209
|
+
it('refresh --all when not TTY keeps clear error', async () => {
|
|
210
|
+
await run(['set', 'repos.root', '--value', '/old']);
|
|
211
|
+
await expect(run(['refresh', '--all'], () => catalog, { stdinIsTTY: () => false })).rejects.toThrow(/--all|interactive|factId|--value|TTY/i);
|
|
212
|
+
});
|
|
213
|
+
it('inject prints buildInjection JSON (default reference)', async () => {
|
|
214
|
+
await run(['set', 'repos.root', '--value', '/r']);
|
|
215
|
+
const { out } = await run(['inject']);
|
|
216
|
+
const parsed = JSON.parse(out.trim());
|
|
217
|
+
expect(parsed.mode).toBe('reference');
|
|
218
|
+
expect(parsed.storePath).toContain('values.json');
|
|
219
|
+
expect(parsed.factIds).toEqual(expect.arrayContaining(['repos.root']));
|
|
220
|
+
expect(parsed.values).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
it('inject --mode context includes fresh context values when catalog allows', async () => {
|
|
223
|
+
const contextCatalog = {
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
facts: {
|
|
226
|
+
'team.color': {
|
|
227
|
+
valueType: 'string',
|
|
228
|
+
resolution: 'prompt',
|
|
229
|
+
durability: 'durable',
|
|
230
|
+
inject: 'context',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
await run(['set', 'team.color', '--value', 'blue'], () => contextCatalog);
|
|
235
|
+
const { out } = await run(['inject', '--mode', 'context'], () => contextCatalog);
|
|
236
|
+
const parsed = JSON.parse(out.trim());
|
|
237
|
+
expect(parsed.mode).toBe('context');
|
|
238
|
+
expect(parsed.values).toEqual({ 'team.color': 'blue' });
|
|
239
|
+
});
|
|
240
|
+
it('mergeFactDefinitions refuses to weaken volatile → durable (secret revive)', () => {
|
|
241
|
+
const into = {
|
|
242
|
+
'aws.token': {
|
|
243
|
+
valueType: 'string',
|
|
244
|
+
resolution: 'prompt',
|
|
245
|
+
durability: 'volatile',
|
|
246
|
+
inject: 'context',
|
|
247
|
+
ttl: 60,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
(0, env_1.mergeFactDefinitions)(into, {
|
|
251
|
+
'aws.token': {
|
|
252
|
+
valueType: 'string',
|
|
253
|
+
resolution: 'prompt',
|
|
254
|
+
durability: 'durable',
|
|
255
|
+
inject: 'context',
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
expect(into['aws.token'].durability).toBe('volatile');
|
|
259
|
+
expect(into['aws.token'].ttl).toBe(60);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
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 types_1 = require("../../../src/core/facts/types");
|
|
10
|
+
function writeJson(file, value) {
|
|
11
|
+
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
12
|
+
fs_1.default.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
function handbook(rows) {
|
|
15
|
+
const body = rows.length === 0 ? '' : rows.map((r) => `| ${r} |`).join('\n') + '\n';
|
|
16
|
+
return `# fixture
|
|
17
|
+
|
|
18
|
+
## Capabilities
|
|
19
|
+
| id | status | notes |
|
|
20
|
+
|----|--------|-------|
|
|
21
|
+
${body}
|
|
22
|
+
## Capability: facts
|
|
23
|
+
Facts chapter.
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
/** Minimal registry that passes structural doctor when facts are unused. */
|
|
27
|
+
function scaffoldBare(root, opts) {
|
|
28
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
|
|
29
|
+
writeJson(path_1.default.join(root, 'awm-registry.json'), {
|
|
30
|
+
overrides: [],
|
|
31
|
+
...(opts?.minCliVersion !== undefined ? { minCliVersion: opts.minCliVersion } : {}),
|
|
32
|
+
});
|
|
33
|
+
const catalogVersion = opts?.catalogVersion ?? '1.0.0';
|
|
34
|
+
if (opts?.withBundle !== false) {
|
|
35
|
+
writeJson(path_1.default.join(root, 'catalog.json'), {
|
|
36
|
+
version: 1,
|
|
37
|
+
bundles: [{ name: 'demo', source: 'bundles/demo', version: catalogVersion, scope: 'project' }],
|
|
38
|
+
});
|
|
39
|
+
writeJson(path_1.default.join(root, 'bundles', 'demo', 'bundle.json'), {
|
|
40
|
+
name: 'demo',
|
|
41
|
+
version: opts?.bundleVersion ?? catalogVersion,
|
|
42
|
+
skills: [],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
writeJson(path_1.default.join(root, 'catalog.json'), { version: 1, bundles: [] });
|
|
47
|
+
}
|
|
48
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'REGISTRY.md'), opts?.handbook ?? handbook(['facts | active | see chapter']));
|
|
49
|
+
if (opts?.facts !== undefined) {
|
|
50
|
+
writeJson(path_1.default.join(root, 'facts.json'), opts.facts);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
describe('doctorRegistry (RF-4.4–4.8, RF-5.1)', () => {
|
|
54
|
+
let tmp;
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-'));
|
|
57
|
+
jest.resetModules();
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
function loadDoctor() {
|
|
63
|
+
return require('../../../src/commands/registry/doctor');
|
|
64
|
+
}
|
|
65
|
+
it('reports missing REGISTRY.md as structural', () => {
|
|
66
|
+
const root = path_1.default.join(tmp, 'no-handbook');
|
|
67
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
68
|
+
fs_1.default.unlinkSync(path_1.default.join(root, 'REGISTRY.md'));
|
|
69
|
+
const { doctorRegistry } = loadDoctor();
|
|
70
|
+
const report = doctorRegistry(root);
|
|
71
|
+
expect(report.structural.some((m) => /missing REGISTRY\.md/i.test(m))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('reports missing Capabilities index as structural', () => {
|
|
74
|
+
const root = path_1.default.join(tmp, 'no-caps');
|
|
75
|
+
scaffoldBare(root, {
|
|
76
|
+
withBundle: false,
|
|
77
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
78
|
+
handbook: '# fixture\n\nNo capabilities table here.\n',
|
|
79
|
+
});
|
|
80
|
+
const { doctorRegistry } = loadDoctor();
|
|
81
|
+
const report = doctorRegistry(root);
|
|
82
|
+
expect(report.structural.some((m) => /Capabilities/i.test(m))).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it('reports catalog vs bundle.json version skew as structural (RF-4.7)', () => {
|
|
85
|
+
const root = path_1.default.join(tmp, 'skew');
|
|
86
|
+
scaffoldBare(root, {
|
|
87
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
88
|
+
catalogVersion: '1.0.0',
|
|
89
|
+
bundleVersion: '2.0.0',
|
|
90
|
+
});
|
|
91
|
+
const { doctorRegistry } = loadDoctor();
|
|
92
|
+
const report = doctorRegistry(root);
|
|
93
|
+
expect(report.structural.some((m) => /version/i.test(m) && /demo/i.test(m))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('reports environment store files under registry tree as structural (RF-4.5)', () => {
|
|
96
|
+
const root = path_1.default.join(tmp, 'store-in-tree');
|
|
97
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
98
|
+
const store = path_1.default.join(root, 'environments', 'default', 'values.json');
|
|
99
|
+
writeJson(store, { 'repos.root': { value: '/x', status: 'fresh', updatedAt: '2026-01-01T00:00:00Z' } });
|
|
100
|
+
const { doctorRegistry } = loadDoctor();
|
|
101
|
+
const report = doctorRegistry(root);
|
|
102
|
+
expect(report.structural.some((m) => /environments/i.test(m) || /values\.json/i.test(m))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
it('reports environments symlink into external store as structural (RF-4.5)', () => {
|
|
105
|
+
const root = path_1.default.join(tmp, 'store-symlink');
|
|
106
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
107
|
+
const external = path_1.default.join(tmp, 'external-env');
|
|
108
|
+
writeJson(path_1.default.join(external, 'default', 'values.json'), {
|
|
109
|
+
'repos.root': { value: '/x', status: 'fresh', updatedAt: '2026-01-01T00:00:00Z' },
|
|
110
|
+
});
|
|
111
|
+
fs_1.default.symlinkSync(external, path_1.default.join(root, 'environments'));
|
|
112
|
+
const { doctorRegistry } = loadDoctor();
|
|
113
|
+
const report = doctorRegistry(root);
|
|
114
|
+
expect(report.structural.some((m) => /environments/i.test(m) && /symlink|RF-4\.5/i.test(m))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
it('reports values.json / meta.json symlinks under environments as structural (RF-4.5)', () => {
|
|
117
|
+
const root = path_1.default.join(tmp, 'store-file-symlink');
|
|
118
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
119
|
+
const external = path_1.default.join(tmp, 'external-values.json');
|
|
120
|
+
fs_1.default.writeFileSync(external, '{"x":1}\n');
|
|
121
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'environments', 'default'), { recursive: true });
|
|
122
|
+
fs_1.default.symlinkSync(external, path_1.default.join(root, 'environments', 'default', 'values.json'));
|
|
123
|
+
const { doctorRegistry } = loadDoctor();
|
|
124
|
+
const report = doctorRegistry(root);
|
|
125
|
+
expect(report.structural.some((m) => /values\.json/i.test(m))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
it('treats unknown capability id widgets as advisory only; doctorExitCode is 0 (RF-4.4/4.6)', () => {
|
|
128
|
+
const root = path_1.default.join(tmp, 'widgets');
|
|
129
|
+
scaffoldBare(root, {
|
|
130
|
+
withBundle: false,
|
|
131
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
132
|
+
handbook: handbook(['widgets | planned | future']),
|
|
133
|
+
});
|
|
134
|
+
const { doctorRegistry, doctorExitCode } = loadDoctor();
|
|
135
|
+
const report = doctorRegistry(root);
|
|
136
|
+
expect(report.structural).toEqual([]);
|
|
137
|
+
expect(report.advisories.some((m) => /widgets/i.test(m))).toBe(true);
|
|
138
|
+
expect(doctorExitCode(report)).toBe(0);
|
|
139
|
+
expect(doctorExitCode({ structural: ['x'], advisories: [] })).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
it('fails structurally when facts present and minCliVersion below FACTS_MIN_CLI_VERSION (RF-4.8)', () => {
|
|
142
|
+
const root = path_1.default.join(tmp, 'old-cli');
|
|
143
|
+
scaffoldBare(root, {
|
|
144
|
+
withBundle: false,
|
|
145
|
+
minCliVersion: '9.17.0',
|
|
146
|
+
facts: { schemaVersion: 1, facts: {} },
|
|
147
|
+
});
|
|
148
|
+
const { doctorRegistry } = loadDoctor();
|
|
149
|
+
const report = doctorRegistry(root);
|
|
150
|
+
expect(report.structural.some((m) => /minCliVersion/i.test(m) || /9\.18\.0/.test(m))).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
it('advises on prose home-path hints like ~/Developments', () => {
|
|
153
|
+
const root = path_1.default.join(tmp, 'prose');
|
|
154
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
155
|
+
const skillDir = path_1.default.join(root, 'skills', 'demo');
|
|
156
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
157
|
+
fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: demo\ndescription: d\n---\nClone under ~/Developments/team.\n`);
|
|
158
|
+
const { doctorRegistry } = loadDoctor();
|
|
159
|
+
const report = doctorRegistry(root);
|
|
160
|
+
expect(report.advisories.some((m) => /~\/Developments|home path|machine path/i.test(m))).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
it('reports orphan needs as structural when facts.json exists (RF-1.3)', () => {
|
|
163
|
+
const root = path_1.default.join(tmp, 'orphan');
|
|
164
|
+
scaffoldBare(root, {
|
|
165
|
+
withBundle: false,
|
|
166
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
167
|
+
facts: {
|
|
168
|
+
schemaVersion: 1,
|
|
169
|
+
facts: { 'repos.root': { valueType: 'path' } },
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const skillDir = path_1.default.join(root, 'skills', 'demo');
|
|
173
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
174
|
+
fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: demo\ndescription: d\nneeds:\n - ghost.fact\n---\n# Demo\n`);
|
|
175
|
+
const { doctorRegistry } = loadDoctor();
|
|
176
|
+
const report = doctorRegistry(root);
|
|
177
|
+
expect(report.structural.some((m) => /ghost\.fact|orphan/i.test(m))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
it('reports needs without facts.json as structural (fail closed)', () => {
|
|
180
|
+
const root = path_1.default.join(tmp, 'needs-no-catalog');
|
|
181
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
182
|
+
const skillDir = path_1.default.join(root, 'skills', 'demo');
|
|
183
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
184
|
+
fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: demo\ndescription: d\nneeds:\n - repos.root\n---\n# Demo\n`);
|
|
185
|
+
const { doctorRegistry } = loadDoctor();
|
|
186
|
+
const report = doctorRegistry(root);
|
|
187
|
+
expect(report.structural.some((m) => /needs/i.test(m) && /facts\.json/i.test(m) && /missing/i.test(m))).toBe(true);
|
|
188
|
+
expect(report.structural.every((m) => !/invalid|unreadable/i.test(m) || !/needs/i.test(m))).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
it('reports needs + invalid facts.json as invalid/unreadable, not missing', () => {
|
|
191
|
+
const root = path_1.default.join(tmp, 'needs-bad-catalog');
|
|
192
|
+
scaffoldBare(root, {
|
|
193
|
+
withBundle: false,
|
|
194
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
195
|
+
facts: { schemaVersion: 1, facts: {} },
|
|
196
|
+
});
|
|
197
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'facts.json'), '{not-json');
|
|
198
|
+
const skillDir = path_1.default.join(root, 'skills', 'demo');
|
|
199
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
200
|
+
fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: demo\ndescription: d\nneeds:\n - repos.root\n---\n# Demo\n`);
|
|
201
|
+
const { doctorRegistry } = loadDoctor();
|
|
202
|
+
const report = doctorRegistry(root);
|
|
203
|
+
const needsMsg = report.structural.find((m) => /needs/i.test(m) && /facts\.json/i.test(m));
|
|
204
|
+
expect(needsMsg).toBeDefined();
|
|
205
|
+
expect(needsMsg).toMatch(/invalid|unreadable/i);
|
|
206
|
+
expect(needsMsg).not.toMatch(/\bmissing\b/i);
|
|
207
|
+
});
|
|
208
|
+
it('fails structurally when facts present and minCliVersion key absent (RF-4.8)', () => {
|
|
209
|
+
const root = path_1.default.join(tmp, 'facts-no-min');
|
|
210
|
+
scaffoldBare(root, {
|
|
211
|
+
withBundle: false,
|
|
212
|
+
facts: { schemaVersion: 1, facts: {} },
|
|
213
|
+
});
|
|
214
|
+
// scaffoldBare omits minCliVersion when not provided
|
|
215
|
+
const { doctorRegistry } = loadDoctor();
|
|
216
|
+
const report = doctorRegistry(root);
|
|
217
|
+
expect(report.structural.some((m) => /minCliVersion/i.test(m))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
it('rejects empty / non-string root with structural error before path.resolve', () => {
|
|
220
|
+
const { doctorRegistry } = loadDoctor();
|
|
221
|
+
expect(doctorRegistry('').structural.some((m) => /root|required|non-empty|string/i.test(m))).toBe(true);
|
|
222
|
+
expect(doctorRegistry(' ').structural.some((m) => /root|required|non-empty|string/i.test(m))).toBe(true);
|
|
223
|
+
expect(doctorRegistry(null).structural.some((m) => /root|required|non-empty|string/i.test(m))).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
it('reports symlink skill dirs and non-regular SKILL.md as structural (fail closed)', () => {
|
|
226
|
+
const root = path_1.default.join(tmp, 'skill-symlink');
|
|
227
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
228
|
+
const externalSkill = path_1.default.join(tmp, 'external-skill');
|
|
229
|
+
fs_1.default.mkdirSync(externalSkill, { recursive: true });
|
|
230
|
+
fs_1.default.writeFileSync(path_1.default.join(externalSkill, 'SKILL.md'), '---\nname: linked\ndescription: d\n---\n');
|
|
231
|
+
fs_1.default.symlinkSync(externalSkill, path_1.default.join(root, 'skills', 'linked'));
|
|
232
|
+
const regular = path_1.default.join(root, 'skills', 'regular');
|
|
233
|
+
fs_1.default.mkdirSync(regular, { recursive: true });
|
|
234
|
+
const externalMd = path_1.default.join(tmp, 'external-SKILL.md');
|
|
235
|
+
fs_1.default.writeFileSync(externalMd, '---\nname: regular\ndescription: d\n---\n');
|
|
236
|
+
fs_1.default.symlinkSync(externalMd, path_1.default.join(regular, 'SKILL.md'));
|
|
237
|
+
const { doctorRegistry } = loadDoctor();
|
|
238
|
+
const report = doctorRegistry(root);
|
|
239
|
+
expect(report.structural.some((m) => /linked/i.test(m) && /symlink|skill/i.test(m))).toBe(true);
|
|
240
|
+
expect(report.structural.some((m) => /regular/i.test(m) && /SKILL\.md|symlink|regular file/i.test(m))).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
// chmod(0) is a POSIX permission seam; on win32 NTFS ACLs often still allow
|
|
243
|
+
// the creating user to read, so the unreadable path is not reproducible there.
|
|
244
|
+
(process.platform === 'win32' ? it.skip : it)('reports unreadable SKILL.md as structural (fail closed)', () => {
|
|
245
|
+
const root = path_1.default.join(tmp, 'skill-unreadable');
|
|
246
|
+
scaffoldBare(root, { withBundle: false, minCliVersion: types_1.FACTS_MIN_CLI_VERSION });
|
|
247
|
+
const skillDir = path_1.default.join(root, 'skills', 'locked');
|
|
248
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
249
|
+
const skillMd = path_1.default.join(skillDir, 'SKILL.md');
|
|
250
|
+
fs_1.default.writeFileSync(skillMd, '---\nname: locked\ndescription: d\n---\n');
|
|
251
|
+
fs_1.default.chmodSync(skillMd, 0);
|
|
252
|
+
try {
|
|
253
|
+
const { doctorRegistry } = loadDoctor();
|
|
254
|
+
const report = doctorRegistry(root);
|
|
255
|
+
expect(report.structural.some((m) => /locked|SKILL\.md|Cannot read|unreadable/i.test(m))).toBe(true);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
fs_1.default.chmodSync(skillMd, 0o644);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
it('does not require facts chapter beyond Capabilities when no facts.json and no needs', () => {
|
|
262
|
+
const root = path_1.default.join(tmp, 'legacy');
|
|
263
|
+
scaffoldBare(root, {
|
|
264
|
+
withBundle: false,
|
|
265
|
+
handbook: handbook(['hooks | n/a | none']),
|
|
266
|
+
});
|
|
267
|
+
const { doctorRegistry } = loadDoctor();
|
|
268
|
+
const report = doctorRegistry(root);
|
|
269
|
+
expect(report.structural).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
it('doctor API is path-only and does not require writing-skills (RF-5.1)', () => {
|
|
272
|
+
const source = fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../src/commands/registry/doctor.ts'), 'utf8');
|
|
273
|
+
expect(source).toMatch(/export function doctorRegistry\(\s*root:\s*string/);
|
|
274
|
+
expect(source).not.toMatch(/writing-skills/);
|
|
275
|
+
expect(source).not.toMatch(/writeSkill|writeFileSync.*SKILL/);
|
|
276
|
+
});
|
|
277
|
+
it('wires awm registry doctor [name|path] via doctorExitCode', () => {
|
|
278
|
+
const indexSource = fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../src/commands/registry/index.ts'), 'utf8');
|
|
279
|
+
expect(indexSource).toMatch(/doctorRegistry/);
|
|
280
|
+
expect(indexSource).toMatch(/doctorExitCode/);
|
|
281
|
+
expect(indexSource).toMatch(/process\.exit\(doctorExitCode\(/);
|
|
282
|
+
});
|
|
283
|
+
});
|