agentic-workflow-manager 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/init.js +15 -3
- package/dist/src/core/diagnostics/context.js +20 -3
- package/dist/src/core/diagnostics/provider-checks.js +22 -4
- package/dist/src/core/skill-integrity.js +33 -5
- package/dist/tests/commands/init.test.js +43 -3
- package/dist/tests/core/diagnostics/checks.test.js +3 -3
- package/dist/tests/core/diagnostics/context.test.js +2 -2
- package/dist/tests/core/diagnostics/provider-checks.test.js +3 -3
- package/dist/tests/core/diagnostics/provider-tier.test.js +8 -8
- package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
- package/dist/tests/core/init/steps.test.js +4 -4
- package/dist/tests/core/skill-integrity.test.js +1 -1
- package/dist/tests/core/usurped-skill-links.test.js +126 -0
- package/package.json +1 -1
|
@@ -270,8 +270,20 @@ async function runInit(opts = {}) {
|
|
|
270
270
|
json: opts.json,
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
|
-
// `result`
|
|
274
|
-
//
|
|
273
|
+
// `result` distinguishes `ok` from `degraded` in the JSON; the EXIT CODE does
|
|
274
|
+
// not, on purpose (D-008).
|
|
275
|
+
//
|
|
276
|
+
// `degraded` used to exit `1`. It does not mean init failed — it means the
|
|
277
|
+
// harness still has `pending` steps, which is the NORMAL outcome of a first run:
|
|
278
|
+
// `CONSTITUTION.md` and `AGENTS.md` are written by an agent session, not by the
|
|
279
|
+
// CLI. So a run where nothing broke reported failure, and `awm init --yes && …`
|
|
280
|
+
// died under `set -e` — in the one command whose entire job is bootstrapping a
|
|
281
|
+
// script. Three independent readers called it a bug before it was one; the docs
|
|
282
|
+
// had grown a warning box asking people to ignore the exit code.
|
|
283
|
+
//
|
|
284
|
+
// The exit code now answers "did init do its job?". "Is the harness fully
|
|
285
|
+
// healthy?" is `awm doctor`'s question, and `result` still carries it here for
|
|
286
|
+
// any consumer that wants to branch on it.
|
|
275
287
|
const result = outcome.after.overall === 'healthy' ? 'ok' : 'degraded';
|
|
276
288
|
if (opts.json) {
|
|
277
289
|
process.stdout.write(JSON.stringify({ result, ...outcome }, null, 2) + '\n');
|
|
@@ -279,7 +291,7 @@ async function runInit(opts = {}) {
|
|
|
279
291
|
else {
|
|
280
292
|
process.stdout.write(renderInitOutcome(outcome) + '\n');
|
|
281
293
|
}
|
|
282
|
-
return
|
|
294
|
+
return 0;
|
|
283
295
|
}
|
|
284
296
|
// ---------------------------------------------------------------------------
|
|
285
297
|
// Extension confirmation factory
|
|
@@ -16,6 +16,7 @@ const status_1 = require("../../commands/hooks/status");
|
|
|
16
16
|
const profile_1 = require("../profile");
|
|
17
17
|
const bundles_1 = require("../bundles");
|
|
18
18
|
const skill_integrity_1 = require("../skill-integrity");
|
|
19
|
+
const artifact_state_1 = require("../artifact-state");
|
|
19
20
|
const paths_1 = require("../paths");
|
|
20
21
|
const provider_checks_1 = require("./provider-checks");
|
|
21
22
|
// Estado de un artefacto en <dir>/<skill>: link vivo / symlink colgante / ausente.
|
|
@@ -250,10 +251,21 @@ function gatherMachine(bundles, agent = 'claude-code', projectRoot) {
|
|
|
250
251
|
ambient: { wanted, installed },
|
|
251
252
|
contextInjection: gatherContextInjection(),
|
|
252
253
|
globalSkills: skillsDir !== null
|
|
253
|
-
? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)())
|
|
254
|
-
: { valid: [], repairable: [], dead: [] },
|
|
254
|
+
? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState()))
|
|
255
|
+
: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
255
256
|
};
|
|
256
257
|
}
|
|
258
|
+
/** `readArtifactState` tira si el JSON esta roto — correcto para un comando que va a
|
|
259
|
+
* ESCRIBIR sobre ese estado, inaceptable para uno que solo diagnostica. Aca degrada a
|
|
260
|
+
* "sin ledger": se pierde la deteccion de usurpaciones, no el resto del reporte. */
|
|
261
|
+
function safeReadArtifactState() {
|
|
262
|
+
try {
|
|
263
|
+
return (0, artifact_state_1.readArtifactState)();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
257
269
|
function gatherProject(root, bundles, agent = 'claude-code') {
|
|
258
270
|
const profile = (0, profile_1.readProfile)(root);
|
|
259
271
|
const profilePresent = fs_1.default.existsSync(path_1.default.join(root, '.awm', 'profile.json'));
|
|
@@ -294,7 +306,12 @@ function gatherContext(opts = {}) {
|
|
|
294
306
|
const agent = opts.agent ?? 'claude-code';
|
|
295
307
|
const root = (0, profile_1.findProjectRoot)(cwd);
|
|
296
308
|
const agents = opts.agents ?? [agent];
|
|
297
|
-
|
|
309
|
+
// El ledger se lee UNA vez por corrida y se comparte con todos los dirs escaneados:
|
|
310
|
+
// es lo unico que distingue "el usuario puso este directorio" de "algo reemplazo
|
|
311
|
+
// nuestro symlink". Best-effort — un ledger ausente o corrupto degrada a "no puedo
|
|
312
|
+
// detectar usurpaciones", nunca revienta un comando de diagnostico.
|
|
313
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState());
|
|
314
|
+
const scanSkills = opts.scanSkills ?? ((dir) => (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)(), managed));
|
|
298
315
|
return {
|
|
299
316
|
machine: gatherMachine(bundles, agent, root ?? undefined),
|
|
300
317
|
project: root ? gatherProject(root, bundles, agent) : null,
|
|
@@ -108,13 +108,20 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
|
|
|
108
108
|
}
|
|
109
109
|
const shared = owners.length > 1;
|
|
110
110
|
const broken = integrity.repairable.length + integrity.dead.length;
|
|
111
|
+
// Usurpado ≠ roto: el link no cuelga, DESAPARECIO — otro instalador dejo un
|
|
112
|
+
// directorio real con el mismo nombre encima. El agente carga esa skill, no la
|
|
113
|
+
// nuestra, y hasta que esto se reporto el scan solo miraba symlinks, asi que el
|
|
114
|
+
// caso era literalmente invisible y `overall` decia `healthy`. No se auto-repara:
|
|
115
|
+
// borrar un directorio real con contenido de un tercero es destructivo y necesita
|
|
116
|
+
// que lo pida una persona.
|
|
117
|
+
const usurped = integrity.usurped.length;
|
|
111
118
|
// Broken links are checked BEFORE shared: 'shared' is a non-degrading/OK state
|
|
112
119
|
// (see checks.ts's DEGRADING_PROVIDER_STATES), so if it were set unconditionally
|
|
113
120
|
// for a shared dir it would silently mask real broken/dead symlinks — a green
|
|
114
121
|
// checkmark next to "N broken links → repair-global-skills" would contradict its
|
|
115
122
|
// own trailing text, and `overall` would never degrade despite real breakage.
|
|
116
123
|
let state;
|
|
117
|
-
if (broken > 0) {
|
|
124
|
+
if (broken > 0 || usurped > 0) {
|
|
118
125
|
state = 'broken';
|
|
119
126
|
}
|
|
120
127
|
else if (shared) {
|
|
@@ -131,8 +138,18 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
|
|
|
131
138
|
state,
|
|
132
139
|
target: dir,
|
|
133
140
|
owners: shared ? owners : undefined,
|
|
134
|
-
detail:
|
|
135
|
-
|
|
141
|
+
detail: [
|
|
142
|
+
broken > 0 ? `${broken} broken links` : null,
|
|
143
|
+
usurped > 0
|
|
144
|
+
? `${usurped} replaced by non-AWM content (${integrity.usurped.join(', ')})`
|
|
145
|
+
: null,
|
|
146
|
+
].filter(Boolean).join('; ') || undefined,
|
|
147
|
+
// Una usurpacion NO la arregla `repair-global-skills` (solo toca symlinks
|
|
148
|
+
// colgantes), asi que ofrecer ese remedio seria mandar al usuario a un comando
|
|
149
|
+
// que no cambia nada. Reinstalar el bundle es lo que la resuelve.
|
|
150
|
+
remediationCode: usurped > 0
|
|
151
|
+
? 'reinstall-usurped-skills'
|
|
152
|
+
: broken > 0 ? 'repair-global-skills' : undefined,
|
|
136
153
|
};
|
|
137
154
|
}
|
|
138
155
|
/** R8: verify the Codex `.toml` agents this run's renderer would have produced still parse. */
|
|
@@ -334,7 +351,8 @@ function gatherProviderChecks(agents, scanSkills, projectRoot) {
|
|
|
334
351
|
const provider = (0, providers_1.providerFor)(agent);
|
|
335
352
|
const dir = provider.skill.global;
|
|
336
353
|
const owners = (dir !== null ? ownersByDir.get(dir) : undefined) ?? [agent];
|
|
337
|
-
const integrity = (dir !== null ? scansByDir.get(dir) : undefined)
|
|
354
|
+
const integrity = (dir !== null ? scansByDir.get(dir) : undefined)
|
|
355
|
+
?? { valid: [], repairable: [], dead: [], usurped: [] };
|
|
338
356
|
const checks = [
|
|
339
357
|
binaryVersionCheck(agent),
|
|
340
358
|
skillsGlobalCheck(dir, owners, integrity, provider.skill.renderer),
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.managedLinkTargets = managedLinkTargets;
|
|
6
7
|
exports.classifySkillLinks = classifySkillLinks;
|
|
7
8
|
exports.repairSkillLinks = repairSkillLinks;
|
|
8
9
|
exports.reconcileAllSkillLinks = reconcileAllSkillLinks;
|
|
@@ -19,12 +20,34 @@ function findRegistrySkillPath(registryContentDirs, name) {
|
|
|
19
20
|
}
|
|
20
21
|
return null;
|
|
21
22
|
}
|
|
23
|
+
/** Normaliza una ruta para comparar contra el ledger: absoluta siempre, y en
|
|
24
|
+
* Windows tambien case-insensitive, porque NTFS lo es y el ledger guarda la
|
|
25
|
+
* ruta con el casing que tuvo al instalarse, no el que tiene al leerse. */
|
|
26
|
+
function pathKey(p) {
|
|
27
|
+
const resolved = path_1.default.resolve(p);
|
|
28
|
+
return (0, paths_1.isWindowsNative)() ? resolved.toLowerCase() : resolved;
|
|
29
|
+
}
|
|
30
|
+
/** Targets que el ledger de artefactos declara instalados con el renderer `link`,
|
|
31
|
+
* normalizados con `pathKey` para comparar contra rutas del filesystem.
|
|
32
|
+
*
|
|
33
|
+
* Solo `link`: para un renderer que produce archivos reales (`cursor-mdc`,
|
|
34
|
+
* `copilot-instructions`) "no es un symlink" es lo esperado, no una usurpacion. */
|
|
35
|
+
function managedLinkTargets(records) {
|
|
36
|
+
return new Set(records.filter((r) => r.renderer === 'link').map((r) => pathKey(r.targetPath)));
|
|
37
|
+
}
|
|
22
38
|
/** Clasifica cada entrada de `skillsDir` (read-only, no muta nada).
|
|
23
39
|
* Agnostico al alcance: `skillsDir` es global (`provider.skill.global`) o de
|
|
24
40
|
* proyecto (`<projectRoot>/<provider.skill.local>`) — el nombre decia "Global" y
|
|
25
|
-
* eso basto para que nadie lo apuntara nunca a un dir de proyecto.
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
* eso basto para que nadie lo apuntara nunca a un dir de proyecto.
|
|
42
|
+
*
|
|
43
|
+
* `managedTargets` (opcional, ver `managedLinkTargets`) es lo que distingue
|
|
44
|
+
* "un directorio que el usuario puso ahi" de "un directorio que reemplazo un
|
|
45
|
+
* link nuestro". Sin el, el scan solo mira symlinks y una usurpacion es
|
|
46
|
+
* literalmente invisible: `awm doctor` reporta `healthy` mientras el agente
|
|
47
|
+
* carga la skill de otro. Por defecto vacio, para que los llamadores que no
|
|
48
|
+
* tienen ledger a mano conserven el comportamiento anterior. */
|
|
49
|
+
function classifySkillLinks(skillsDir, registryContentDirs, managedTargets = new Set()) {
|
|
50
|
+
const out = { valid: [], repairable: [], dead: [], usurped: [] };
|
|
28
51
|
let entries;
|
|
29
52
|
try {
|
|
30
53
|
entries = fs_1.default.readdirSync(skillsDir);
|
|
@@ -41,8 +64,13 @@ function classifySkillLinks(skillsDir, registryContentDirs) {
|
|
|
41
64
|
catch {
|
|
42
65
|
continue;
|
|
43
66
|
}
|
|
44
|
-
if (!lst.isSymbolicLink())
|
|
45
|
-
|
|
67
|
+
if (!lst.isSymbolicLink()) {
|
|
68
|
+
// Un dir/archivo real que puso el usuario no es nuestro problema. Uno
|
|
69
|
+
// que el ledger declara nuestro SI lo es — ahi hubo un reemplazo.
|
|
70
|
+
if (managedTargets.has(pathKey(p)))
|
|
71
|
+
out.usurped.push(name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
46
74
|
if (fs_1.default.existsSync(p)) {
|
|
47
75
|
out.valid.push(name);
|
|
48
76
|
continue;
|
|
@@ -109,14 +109,17 @@ describe('runInit', () => {
|
|
|
109
109
|
expect(caveatCalls).toHaveLength(0);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
-
it('returns exit
|
|
112
|
+
it('returns exit 0 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
|
|
113
113
|
const { runInit } = require('../../src/commands/init');
|
|
114
114
|
const code = await runInit({
|
|
115
115
|
cwd: tmpHome,
|
|
116
116
|
yes: true,
|
|
117
117
|
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
118
118
|
});
|
|
119
|
-
|
|
119
|
+
// Degradado (cache/hook/devCore siguen ausentes con syncCache no-op) pero NADA
|
|
120
|
+
// fallo, y el exit code de `init` responde por init, no por la salud del
|
|
121
|
+
// harness — ver D-008. Esto salia 1 y rompia `awm init --yes && …` bajo `set -e`.
|
|
122
|
+
expect(code).toBe(0);
|
|
120
123
|
});
|
|
121
124
|
it('--json emits a parseable InitOutcome', async () => {
|
|
122
125
|
const { runInit } = require('../../src/commands/init');
|
|
@@ -131,7 +134,10 @@ describe('runInit', () => {
|
|
|
131
134
|
expect(Array.isArray(parsed.steps)).toBe(true);
|
|
132
135
|
expect(parsed.after.overall).toBe('degraded');
|
|
133
136
|
expect(parsed.result).toBe('degraded'); // success envelope is self-describing too
|
|
134
|
-
|
|
137
|
+
// El exit code deja de duplicar `result`: la distincion ok/degradado sigue
|
|
138
|
+
// disponible para quien la quiera, en el campo, no en `$?` (D-008).
|
|
139
|
+
expect(parsed.failed).toBe(0);
|
|
140
|
+
expect(code).toBe(0);
|
|
135
141
|
});
|
|
136
142
|
const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
|
|
137
143
|
const readPreferences = () => JSON.parse(fs_1.default.readFileSync(prefsFile(), 'utf-8'));
|
|
@@ -239,6 +245,40 @@ describe('runInit', () => {
|
|
|
239
245
|
.toEqual(['claude-code', 'codex', 'opencode']);
|
|
240
246
|
});
|
|
241
247
|
// -----------------------------------------------------------------------
|
|
248
|
+
// D-008 — el exit code de `init` responde por init, no por la salud del harness
|
|
249
|
+
// -----------------------------------------------------------------------
|
|
250
|
+
//
|
|
251
|
+
// El contrato entero en un solo lugar, porque el sitio que lo rompia era el
|
|
252
|
+
// ausente: ningun test preguntaba "¿un script puede encadenar `awm init &&`?".
|
|
253
|
+
// Habia dos assertions de `toBe(1)`, y las dos *documentaban* el bug en vez de
|
|
254
|
+
// detenerlo. Tres lectores independientes lo reportaron como fallo antes de que
|
|
255
|
+
// lo fuera, y la doc habia crecido un recuadro pidiendo ignorar el exit code.
|
|
256
|
+
it('a degraded-but-successful run exits 0, so `awm init && next` survives set -e', async () => {
|
|
257
|
+
const { runInit } = require('../../src/commands/init');
|
|
258
|
+
const code = await runInit({
|
|
259
|
+
cwd: tmpHome,
|
|
260
|
+
yes: true,
|
|
261
|
+
json: true,
|
|
262
|
+
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
263
|
+
});
|
|
264
|
+
const parsed = JSON.parse(writeSpy.mock.calls.map((c) => c[0]).join(''));
|
|
265
|
+
// Las tres afirmaciones juntas son el punto: degradado, sin nada fallado, exit 0.
|
|
266
|
+
// Cualquiera de las tres sola se puede satisfacer sin el fix.
|
|
267
|
+
expect(parsed.result).toBe('degraded');
|
|
268
|
+
expect(parsed.failed).toBe(0);
|
|
269
|
+
expect(code).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
it('still refuses with exit 2 when a gate rejects — 0 does not mean "always succeed"', async () => {
|
|
272
|
+
const { runInit } = require('../../src/commands/init');
|
|
273
|
+
const code = await runInit({
|
|
274
|
+
cwd: tmpHome,
|
|
275
|
+
agent: 'codex',
|
|
276
|
+
yes: true,
|
|
277
|
+
assertProviderSupported: () => { throw new Error('requires Codex >= 0.145.0'); },
|
|
278
|
+
});
|
|
279
|
+
expect(code).toBe(2);
|
|
280
|
+
});
|
|
281
|
+
// -----------------------------------------------------------------------
|
|
242
282
|
// Step 1 — gate ordering, backup/rollback atomicity, Codex/Claude coexistence
|
|
243
283
|
// -----------------------------------------------------------------------
|
|
244
284
|
it('gates Codex before preferences or provider writes', async () => {
|
|
@@ -8,7 +8,7 @@ function healthyMachine() {
|
|
|
8
8
|
devCore: { present: true, brokenLinks: [] },
|
|
9
9
|
ambient: { wanted: [], installed: [] },
|
|
10
10
|
contextInjection: [],
|
|
11
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
11
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
function healthyProject() {
|
|
@@ -173,12 +173,12 @@ describe('machineChecks — global skill integrity', () => {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
it('ok when no broken global skill links', () => {
|
|
176
|
-
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [] }));
|
|
176
|
+
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [], usurped: [] }));
|
|
177
177
|
const row = report.results.find((r) => r.id === 'machine.globalSkills');
|
|
178
178
|
expect(row?.status).toBe('ok');
|
|
179
179
|
});
|
|
180
180
|
it('warns with awm init remedy when there are broken links', () => {
|
|
181
|
-
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'] }));
|
|
181
|
+
const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] }));
|
|
182
182
|
const row = report.results.find((r) => r.id === 'machine.globalSkills');
|
|
183
183
|
expect(row?.status).toBe('warn');
|
|
184
184
|
expect(row?.detail).toContain('2'); // 1 repairable + 1 dead
|
|
@@ -240,7 +240,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
|
|
|
240
240
|
process.env.AWM_HOME = originalAwmHome;
|
|
241
241
|
});
|
|
242
242
|
function healthySharedSkills() {
|
|
243
|
-
return { valid: ['development-process'], repairable: [], dead: [] };
|
|
243
|
+
return { valid: ['development-process'], repairable: [], dead: [], usurped: [] };
|
|
244
244
|
}
|
|
245
245
|
it('reports shared skills for both owners without scanning twice', () => {
|
|
246
246
|
// OpenCode and Codex both read/write ~/.agents/skills (providers/index.ts) —
|
|
@@ -252,7 +252,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
|
|
|
252
252
|
expect(report.providers.every((provider) => provider.checks.some((check) => check.state === 'shared'))).toBe(true);
|
|
253
253
|
});
|
|
254
254
|
it('marks skills.global healthy (not shared) for a single unshared provider', () => {
|
|
255
|
-
const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
255
|
+
const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
256
256
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
257
257
|
const report = gatherContext({ cwd: tmpHome, bundles: [], agents: ['claude-code'], scanSkills: scan });
|
|
258
258
|
expect(scan).toHaveBeenCalledTimes(1);
|
|
@@ -177,7 +177,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
|
|
|
177
177
|
const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
|
|
178
178
|
// OpenCode and Codex share ~/.agents/skills (providers/index.ts). Stub the
|
|
179
179
|
// scan to report 2 broken/dead links on that shared directory.
|
|
180
|
-
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'] }));
|
|
180
|
+
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'], usurped: [] }));
|
|
181
181
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
|
|
182
182
|
expect(scanSkills).toHaveBeenCalledTimes(1);
|
|
183
183
|
for (const provider of ctx.providers) {
|
|
@@ -192,7 +192,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
|
|
|
192
192
|
it('still reports shared (healthy, non-degrading) when the shared dir has no broken links', () => {
|
|
193
193
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
194
194
|
const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
|
|
195
|
-
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [] }));
|
|
195
|
+
const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [], usurped: [] }));
|
|
196
196
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
|
|
197
197
|
for (const provider of ctx.providers) {
|
|
198
198
|
const skillsCheck = provider.checks.find((c) => c.id === 'skills.global');
|
|
@@ -237,7 +237,7 @@ describe('gatherProviderChecks — agents.native reports broken on a malformed C
|
|
|
237
237
|
fs_1.default.mkdirSync(agentsDir, { recursive: true });
|
|
238
238
|
fs_1.default.writeFileSync(path_1.default.join(agentsDir, 'development-process.toml'), 'name = "development-process"\n# missing the developer_instructions key entirely\n');
|
|
239
239
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
240
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
240
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
241
241
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['codex'], scanSkills });
|
|
242
242
|
const codex = ctx.providers.find((p) => p.id === 'codex');
|
|
243
243
|
const agentsNative = codex.checks.find((c) => c.id === 'agents.native');
|
|
@@ -51,7 +51,7 @@ describe('contextGlobalCheck — scope-aware (Task 4.4 / deferred Task 4.2 findi
|
|
|
51
51
|
return root;
|
|
52
52
|
}
|
|
53
53
|
function scanSkillsStub() {
|
|
54
|
-
return jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
54
|
+
return jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
55
55
|
}
|
|
56
56
|
beforeEach(() => {
|
|
57
57
|
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-home-'));
|
|
@@ -177,7 +177,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
177
177
|
// classifySkillLinks only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
|
|
178
178
|
// — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
|
|
179
179
|
// test proves the FIX (renderer-gating), not an accident of what classifySkillLinks does.
|
|
180
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
180
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
181
181
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
182
182
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
183
183
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -216,7 +216,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
216
216
|
});
|
|
217
217
|
const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
|
|
218
218
|
expect(fs_1.default.existsSync(path_1.default.join(rulesDir, 'using-awm.mdc'))).toBe(true);
|
|
219
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
219
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
220
220
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
221
221
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
222
222
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -225,7 +225,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
225
225
|
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
226
226
|
});
|
|
227
227
|
it('non-link renderer with an empty/missing dir reports absent, not a false healthy', () => {
|
|
228
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
228
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
229
229
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
230
230
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
231
231
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -234,7 +234,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
234
234
|
it('link renderer (claude-code) behavior is completely unchanged — regression', () => {
|
|
235
235
|
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
236
236
|
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
237
|
-
const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [] }));
|
|
237
|
+
const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [], usurped: [] }));
|
|
238
238
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
239
239
|
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
240
240
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -244,7 +244,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
244
244
|
it('link renderer (claude-code) still reports broken links — regression', () => {
|
|
245
245
|
const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
|
|
246
246
|
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
247
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [] }));
|
|
247
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [], usurped: [] }));
|
|
248
248
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
249
249
|
const facts = gatherProviderChecks(['claude-code'], scanSkills);
|
|
250
250
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -263,7 +263,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
263
263
|
// neither ends in `.mdc`, so neither is AWM-shaped evidence.
|
|
264
264
|
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
265
265
|
fs_1.default.mkdirSync(path_1.default.join(rulesDir, 'some-user-dir'));
|
|
266
|
-
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
|
|
266
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
267
267
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
268
268
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
269
269
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -274,7 +274,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
|
|
|
274
274
|
fs_1.default.mkdirSync(rulesDir, { recursive: true });
|
|
275
275
|
fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
|
|
276
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: [] }));
|
|
277
|
+
const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
278
278
|
const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
|
|
279
279
|
const facts = gatherProviderChecks(['cursor'], scanSkills);
|
|
280
280
|
const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
|
|
@@ -61,7 +61,7 @@ describe('stepCache — registry sync error results', () => {
|
|
|
61
61
|
devCore: { present: true, brokenLinks: [] },
|
|
62
62
|
ambient: { wanted: [], installed: [] },
|
|
63
63
|
contextInjection: [],
|
|
64
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
64
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
65
65
|
},
|
|
66
66
|
project: null,
|
|
67
67
|
};
|
|
@@ -23,7 +23,7 @@ function machine() {
|
|
|
23
23
|
devCore: { present: true, brokenLinks: [] },
|
|
24
24
|
ambient: { wanted: [], installed: [] },
|
|
25
25
|
contextInjection: [],
|
|
26
|
-
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
26
|
+
globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
function project(over = {}) {
|
|
@@ -429,7 +429,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
429
429
|
const a = spies();
|
|
430
430
|
a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: ['c'], failed: [] }));
|
|
431
431
|
const m = machine();
|
|
432
|
-
m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'] };
|
|
432
|
+
m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] };
|
|
433
433
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a));
|
|
434
434
|
expect(r.action).toBe('applied');
|
|
435
435
|
expect(a.repairGlobalSkills).toHaveBeenCalledTimes(1);
|
|
@@ -440,7 +440,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
440
440
|
const a = spies();
|
|
441
441
|
a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: [], failed: [] }));
|
|
442
442
|
const m = machine();
|
|
443
|
-
m.globalSkills = { valid: [], repairable: ['b'], dead: [] };
|
|
443
|
+
m.globalSkills = { valid: [], repairable: ['b'], dead: [], usurped: [] };
|
|
444
444
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'opencode' }));
|
|
445
445
|
expect(r.action).toBe('applied');
|
|
446
446
|
expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
|
|
@@ -451,7 +451,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
451
451
|
const m = machine();
|
|
452
452
|
// Broken-count is nonzero, so the ONLY thing that can make this skip is the
|
|
453
453
|
// null-global-dir guard itself, not the "nothing broken" early return above.
|
|
454
|
-
m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'] };
|
|
454
|
+
m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'], usurped: [] };
|
|
455
455
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'copilot' }));
|
|
456
456
|
expect(r.action).toBe('skipped');
|
|
457
457
|
expect(a.repairGlobalSkills).not.toHaveBeenCalled();
|
|
@@ -71,7 +71,7 @@ describe('classifySkillLinks', () => {
|
|
|
71
71
|
});
|
|
72
72
|
it('returns empty arrays when the skills dir does not exist', () => {
|
|
73
73
|
const result = (0, skill_integrity_1.classifySkillLinks)('/nonexistent/dir', ['/also/nonexistent']);
|
|
74
|
-
expect(result).toEqual({ valid: [], repairable: [], dead: [] });
|
|
74
|
+
expect(result).toEqual({ valid: [], repairable: [], dead: [], usurped: [] });
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
describe('reconcileAllSkillLinks (#4 — awm update, all providers)', () => {
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
// Un symlink gestionado por AWM que otro instalador reemplaza por un directorio real
|
|
7
|
+
// era INVISIBLE para todo el sistema de diagnostico.
|
|
8
|
+
//
|
|
9
|
+
// Encontrado corriendo el playbook `agent-matrix` para `claude-code` contra el binario
|
|
10
|
+
// publicado: `awm init` dejo `~/.claude/skills/mermaid-diagrams` como symlink al registry
|
|
11
|
+
// baseline y lo anoto en `state/artifacts.json`; la primera sesion real de Claude Code
|
|
12
|
+
// materializo su propia skill `mermaid-diagrams` (bundled, mismo nombre) encima, pisando
|
|
13
|
+
// el symlink con un directorio real distinto — otra `description`, sin `version`, con un
|
|
14
|
+
// README.md que la nuestra no tiene.
|
|
15
|
+
//
|
|
16
|
+
// Despues de eso:
|
|
17
|
+
// - `awm doctor -a claude-code` reportaba `skills.global: healthy`, `overall: healthy`,
|
|
18
|
+
// exit 0;
|
|
19
|
+
// - `awm sync` no lo tocaba;
|
|
20
|
+
// - el agente cargaba la skill del tercero, no la instalada.
|
|
21
|
+
//
|
|
22
|
+
// La causa es una sola linea de `classifySkillLinks`: `if (!lst.isSymbolicLink()) continue`.
|
|
23
|
+
// Correcta para una skill que el usuario puso a mano — AWM no debe tocarla — y equivocada
|
|
24
|
+
// cuando el ledger de artefactos dice que esa ruta exacta es nuestra. El clasificador nunca
|
|
25
|
+
// consultaba el ledger, asi que no podia distinguir los dos casos.
|
|
26
|
+
const fs_1 = __importDefault(require("fs"));
|
|
27
|
+
const path_1 = __importDefault(require("path"));
|
|
28
|
+
const skill_integrity_1 = require("../../src/core/skill-integrity");
|
|
29
|
+
const tmp_1 = require("../support/tmp");
|
|
30
|
+
describe('a managed skill link replaced by third-party content is detected', () => {
|
|
31
|
+
let registry;
|
|
32
|
+
let skillsDir;
|
|
33
|
+
const made = [];
|
|
34
|
+
const record = (targetPath, renderer = 'link') => ({
|
|
35
|
+
name: path_1.default.basename(targetPath),
|
|
36
|
+
type: 'skill',
|
|
37
|
+
scope: 'global',
|
|
38
|
+
targetPath,
|
|
39
|
+
sourcePath: path_1.default.join(registry, 'skills', path_1.default.basename(targetPath)),
|
|
40
|
+
renderer,
|
|
41
|
+
owners: ['claude-code'],
|
|
42
|
+
});
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
registry = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-reg-');
|
|
45
|
+
skillsDir = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-skills-');
|
|
46
|
+
made.push(registry, skillsDir);
|
|
47
|
+
fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), { recursive: true });
|
|
48
|
+
fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams', 'SKILL.md'), '# awm\n');
|
|
49
|
+
});
|
|
50
|
+
afterAll(() => { for (const d of made)
|
|
51
|
+
fs_1.default.rmSync(d, { recursive: true, force: true }); });
|
|
52
|
+
/** Lo que hace el tercero: borra nuestro symlink y deja su propio directorio. */
|
|
53
|
+
function usurp(name) {
|
|
54
|
+
const p = path_1.default.join(skillsDir, name);
|
|
55
|
+
fs_1.default.rmSync(p, { recursive: true, force: true });
|
|
56
|
+
fs_1.default.mkdirSync(p, { recursive: true });
|
|
57
|
+
fs_1.default.writeFileSync(path_1.default.join(p, 'SKILL.md'), '# someone else\n');
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
it('reports the replaced entry as usurped, not as healthy', () => {
|
|
61
|
+
const target = path_1.default.join(skillsDir, 'mermaid-diagrams');
|
|
62
|
+
fs_1.default.symlinkSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), target);
|
|
63
|
+
// Antes de la usurpacion: un link vivo y nada mas.
|
|
64
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)([record(target)]);
|
|
65
|
+
const before = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
|
|
66
|
+
expect(before.valid).toEqual(['mermaid-diagrams']);
|
|
67
|
+
expect(before.usurped).toEqual([]);
|
|
68
|
+
usurp('mermaid-diagrams');
|
|
69
|
+
const after = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
|
|
70
|
+
expect(after.usurped).toEqual(['mermaid-diagrams']);
|
|
71
|
+
// Y no se cuela por ninguna de las categorias existentes: `valid` es lo que hace
|
|
72
|
+
// que `doctor` diga `healthy`, y `repairable`/`dead` mandarian a un remedio
|
|
73
|
+
// (`repair-global-skills`) que solo toca symlinks y aca no cambiaria nada.
|
|
74
|
+
expect(after.valid).toEqual([]);
|
|
75
|
+
expect(after.repairable).toEqual([]);
|
|
76
|
+
expect(after.dead).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
it('leaves a directory the user created alone — it is not in the ledger', () => {
|
|
79
|
+
usurp('my-own-skill');
|
|
80
|
+
const scan = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], (0, skill_integrity_1.managedLinkTargets)([]));
|
|
81
|
+
expect(scan.usurped).toEqual([]);
|
|
82
|
+
expect(scan.valid).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it('does not flag a rendered artifact: a real file is what `cursor-mdc` produces', () => {
|
|
85
|
+
// El ledger tambien anota artefactos renderizados (`.mdc`, `.instructions.md`).
|
|
86
|
+
// Para esos, "no es un symlink" es el estado correcto — contarlos como usurpados
|
|
87
|
+
// pintaria de rojo cada instalacion sana de Cursor y Copilot.
|
|
88
|
+
const target = path_1.default.join(skillsDir, 'rendered.mdc');
|
|
89
|
+
fs_1.default.writeFileSync(target, '---\ndescription: x\n---\n');
|
|
90
|
+
const managed = (0, skill_integrity_1.managedLinkTargets)([record(target, 'cursor-mdc')]);
|
|
91
|
+
expect(managed.size).toBe(0);
|
|
92
|
+
expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed).usurped).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
it('defaults to no detection when no ledger is passed (callers without one are unchanged)', () => {
|
|
95
|
+
usurp('mermaid-diagrams');
|
|
96
|
+
expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry]).usurped).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
// El hallazgo del playbook no fue "el clasificador no ve X" — fue "doctor dice healthy".
|
|
100
|
+
// Detectarlo en `classifySkillLinks` no sirve de nada si el check que lo consume lo
|
|
101
|
+
// descarta, asi que la superficie que reporto el bug se asserta por separado.
|
|
102
|
+
describe('awm doctor degrades on a usurped global skill', () => {
|
|
103
|
+
const { gatherProviderChecks } = require('../../src/core/diagnostics/provider-checks');
|
|
104
|
+
function checkFor(usurped, repairable = []) {
|
|
105
|
+
const scan = jest.fn(() => ({ valid: [], repairable, dead: [], usurped }));
|
|
106
|
+
const facts = gatherProviderChecks(['claude-code'], scan);
|
|
107
|
+
return facts[0].checks.find((c) => c.id === 'skills.global');
|
|
108
|
+
}
|
|
109
|
+
it('is broken, names what was replaced, and does not offer a remedy that cannot fix it', () => {
|
|
110
|
+
const check = checkFor(['mermaid-diagrams']);
|
|
111
|
+
expect(check.state).toBe('broken');
|
|
112
|
+
expect(check.detail).toContain('mermaid-diagrams');
|
|
113
|
+
// `repair-global-skills` solo re-linkea symlinks colgantes: mandaria al usuario a
|
|
114
|
+
// un comando que corre limpio y no cambia nada.
|
|
115
|
+
expect(check.remediationCode).toBe('reinstall-usurped-skills');
|
|
116
|
+
});
|
|
117
|
+
it('still reports plain broken links the old way when nothing was usurped', () => {
|
|
118
|
+
const check = checkFor([], ['gone']);
|
|
119
|
+
expect(check.state).toBe('broken');
|
|
120
|
+
expect(check.detail).toBe('1 broken links');
|
|
121
|
+
expect(check.remediationCode).toBe('repair-global-skills');
|
|
122
|
+
});
|
|
123
|
+
it('is healthy when neither happened', () => {
|
|
124
|
+
expect(checkFor([]).state).not.toBe('broken');
|
|
125
|
+
});
|
|
126
|
+
});
|