agentic-workflow-manager 4.1.1 → 5.0.1
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/checks.js +8 -0
- package/dist/src/core/diagnostics/context.js +6 -1
- package/dist/src/core/diagnostics/provider-checks.js +44 -5
- package/dist/src/release/orchestrator.js +47 -1
- package/dist/tests/commands/init.test.js +43 -3
- package/dist/tests/core/diagnostics/checks.test.js +1 -1
- package/dist/tests/core/init/steps.test.js +1 -1
- package/dist/tests/core/usurped-skill-links.test.js +70 -0
- package/dist/tests/release/orchestrator.test.js +54 -0
- package/dist/tests/structural/managed-dir-scans-consult-the-ledger.test.js +94 -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
|
|
@@ -125,6 +125,14 @@ function projectChecks(p) {
|
|
|
125
125
|
detail: `${p.orphanLinks.repairable.length} repairable, ${p.orphanLinks.dead.length} dead`,
|
|
126
126
|
remedy: cmd('awm sync') });
|
|
127
127
|
}
|
|
128
|
+
// Fila aparte de `project.orphans`, y con OTRO remedio: `awm sync` solo toca
|
|
129
|
+
// symlinks, asi que sobre un directorio real que piso nuestro link corre limpio sin
|
|
130
|
+
// cambiar nada. Mandar ahi al usuario seria peor que no ofrecer remedio (D-007).
|
|
131
|
+
if (p.orphanLinks.usurped.length > 0) {
|
|
132
|
+
out.push({ id: 'project.usurped', level: 'project', label: 'skill links replaced by non-AWM content',
|
|
133
|
+
status: 'warn', detail: p.orphanLinks.usurped.join(', '),
|
|
134
|
+
remedy: cmd(`awm remove <bundle> --yes && awm add <bundle> --yes`) });
|
|
135
|
+
}
|
|
128
136
|
// project.sensors
|
|
129
137
|
out.push(p.sensors.present
|
|
130
138
|
? { id: 'project.sensors', level: 'project', label: 'sensors', status: 'ok', remedy: none }
|
|
@@ -280,10 +280,15 @@ function gatherProject(root, bundles, agent = 'claude-code') {
|
|
|
280
280
|
// Huerfanos: symlinks colgantes que el profile ya no reclama. Se clasifican sobre
|
|
281
281
|
// el dir REAL, no sobre la lista esperada — que es justamente lo que `linked`/
|
|
282
282
|
// `broken` de arriba no pueden ver. `awm sync` los cura o los poda.
|
|
283
|
-
const orphanScan = (0, skill_integrity_1.classifySkillLinks)(localSkillsDir, (0, registries_1.contentRoots)());
|
|
283
|
+
const orphanScan = (0, skill_integrity_1.classifySkillLinks)(localSkillsDir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState()));
|
|
284
284
|
const orphanLinks = {
|
|
285
285
|
repairable: orphanScan.repairable.filter((n) => !expected.includes(n)),
|
|
286
286
|
dead: orphanScan.dead.filter((n) => !expected.includes(n)),
|
|
287
|
+
// Usurpados NO se filtran contra `expected`: lo huerfano es lo que sobra, y esto
|
|
288
|
+
// es lo contrario — una skill que el profile SI reclama, cuyo link reemplazo un
|
|
289
|
+
// tercero por contenido suyo. Se reporta aparte porque el remedio difiere: `awm
|
|
290
|
+
// sync` cura symlinks colgantes y sobre un directorio real no hace nada.
|
|
291
|
+
usurped: orphanScan.usurped,
|
|
287
292
|
};
|
|
288
293
|
let context = { present: false };
|
|
289
294
|
if (fs_1.default.existsSync(path_1.default.join(root, 'CLAUDE.md')))
|
|
@@ -18,6 +18,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
18
18
|
const path_1 = __importDefault(require("path"));
|
|
19
19
|
const providers_1 = require("../../providers");
|
|
20
20
|
const skill_integrity_1 = require("../skill-integrity");
|
|
21
|
+
const artifact_state_1 = require("../artifact-state");
|
|
21
22
|
const provider_version_1 = require("../provider-version");
|
|
22
23
|
const status_1 = require("../../commands/hooks/status");
|
|
23
24
|
const orchestrator_1 = require("../context/orchestrator");
|
|
@@ -208,14 +209,24 @@ function workflowsGlobalCheck(agent) {
|
|
|
208
209
|
// Los workflows se instalan con el renderer `link`, asi que un symlink colgante es
|
|
209
210
|
// exactamente la misma clase de rotura que en skills — y se clasifica con la misma
|
|
210
211
|
// funcion, no con una copia local que pueda divergir.
|
|
211
|
-
|
|
212
|
+
//
|
|
213
|
+
// El ledger va SIEMPRE que se escanee un dir gestionado. Cuando se agrego la
|
|
214
|
+
// deteccion de usurpaciones (D-007) se cableo en el seam `scanSkills`, que alimenta
|
|
215
|
+
// `skills.global` — y este sitio, que llama al clasificador directo, quedo afuera:
|
|
216
|
+
// detectaba links colgantes y no un workflow reemplazado por contenido ajeno. Es el
|
|
217
|
+
// mismo hermano-sin-tratar que ya aparecio varias veces en este archivo.
|
|
218
|
+
const integrity = (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeArtifactState()));
|
|
212
219
|
const broken = integrity.repairable.length + integrity.dead.length;
|
|
220
|
+
const usurped = integrity.usurped.length;
|
|
213
221
|
return {
|
|
214
222
|
id: 'workflows.global',
|
|
215
|
-
state: broken > 0 ? 'broken' : 'healthy',
|
|
223
|
+
state: broken > 0 || usurped > 0 ? 'broken' : 'healthy',
|
|
216
224
|
target: dir,
|
|
217
|
-
detail:
|
|
218
|
-
|
|
225
|
+
detail: [
|
|
226
|
+
broken > 0 ? `${broken} broken link(s)` : null,
|
|
227
|
+
usurped > 0 ? `${usurped} replaced by non-AWM content (${integrity.usurped.join(', ')})` : null,
|
|
228
|
+
].filter(Boolean).join('; ') || undefined,
|
|
229
|
+
remediationCode: usurped > 0 ? 'reinstall-usurped-skills' : broken > 0 ? 'awm-init' : undefined,
|
|
219
230
|
};
|
|
220
231
|
}
|
|
221
232
|
function agentsNativeCheck(agent) {
|
|
@@ -253,7 +264,35 @@ function agentsNativeCheck(agent) {
|
|
|
253
264
|
remediationCode: broken > 0 ? 'reinstall-native-agents' : undefined,
|
|
254
265
|
};
|
|
255
266
|
}
|
|
256
|
-
|
|
267
|
+
// Renderer `link` (claude-code, `~/.claude/agents`): esto devolvia `healthy` fijo
|
|
268
|
+
// con solo mirar que el dir no estuviera vacio. Un symlink colgante ahi adentro
|
|
269
|
+
// pasaba, y un artefacto reemplazado por contenido de un tercero tambien — el
|
|
270
|
+
// agente cargaba el ajeno mientras doctor daba verde. El dir es gestionado igual
|
|
271
|
+
// que el de skills, asi que se verifica igual.
|
|
272
|
+
const integrity = (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeArtifactState()));
|
|
273
|
+
const broken = integrity.repairable.length + integrity.dead.length;
|
|
274
|
+
const usurped = integrity.usurped.length;
|
|
275
|
+
return {
|
|
276
|
+
id: 'agents.native',
|
|
277
|
+
state: broken > 0 || usurped > 0 ? 'broken' : 'healthy',
|
|
278
|
+
target: dir,
|
|
279
|
+
detail: [
|
|
280
|
+
broken > 0 ? `${broken} broken link(s)` : null,
|
|
281
|
+
usurped > 0 ? `${usurped} replaced by non-AWM content (${integrity.usurped.join(', ')})` : null,
|
|
282
|
+
].filter(Boolean).join('; ') || undefined,
|
|
283
|
+
remediationCode: usurped > 0 ? 'reinstall-usurped-skills' : broken > 0 ? 'reinstall-native-agents' : undefined,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** El ledger de propiedad, best-effort: un archivo ausente o corrupto degrada a "no
|
|
287
|
+
* puedo detectar usurpaciones", nunca revienta un comando de diagnostico. Mismo
|
|
288
|
+
* criterio que `safeReadArtifactState` en context.ts. */
|
|
289
|
+
function safeArtifactState() {
|
|
290
|
+
try {
|
|
291
|
+
return (0, artifact_state_1.readArtifactState)();
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
257
296
|
}
|
|
258
297
|
function hookTrustCheck(agent) {
|
|
259
298
|
const provider = (0, providers_1.providerFor)(agent);
|
|
@@ -109,8 +109,54 @@ function release(opts, io) {
|
|
|
109
109
|
throw publishError;
|
|
110
110
|
}
|
|
111
111
|
if (opts.push) {
|
|
112
|
-
|
|
112
|
+
// El TAG primero, y a proposito.
|
|
113
|
+
//
|
|
114
|
+
// Despues de un publish exitoso ya no hay vuelta atras: npm tiene la version. A
|
|
115
|
+
// partir de aca todo fallo de git deja a npm adelantado respecto del repo, y lo
|
|
116
|
+
// unico que decide cuan grave es eso es CUANTO alcanzo a quedar registrado.
|
|
117
|
+
//
|
|
118
|
+
// Un tag no puede entrar en conflicto — es una ref nueva —, asi que se empuja
|
|
119
|
+
// antes: en el peor caso queda el tag que identifica exactamente que se publico,
|
|
120
|
+
// y lo unico que falta es el commit de bump, que una persona puede reponer. Al
|
|
121
|
+
// reves (main primero) un push rechazado se lleva puesta tambien la identidad de
|
|
122
|
+
// la version, que es lo que paso con la 4.0.1: publicada, sin tag, sin CHANGELOG.
|
|
113
123
|
io.run('git', ['push', 'origin', `v${version}`]);
|
|
124
|
+
// El push de `main` SI puede perder una carrera. Se reintenta rebaseando: el commit
|
|
125
|
+
// de bump toca solo `cli/package.json` y `CHANGELOG.md`, y lleva `[skip ci]`.
|
|
126
|
+
pushBranchWithRebase(io, opts.branch, version);
|
|
114
127
|
}
|
|
115
128
|
return { released: true, version };
|
|
116
129
|
}
|
|
130
|
+
const PUSH_ATTEMPTS = 3;
|
|
131
|
+
/**
|
|
132
|
+
* Empuja `branch` reintentando con `pull --rebase` ante un rechazo. Si agota los
|
|
133
|
+
* intentos tira un error que NOMBRA el estado real del mundo — npm publicado, git a
|
|
134
|
+
* medias — en vez de un "failed to push some refs" que no dice que hacer.
|
|
135
|
+
*
|
|
136
|
+
* El `concurrency` de release.yml deberia hacer que esto no llegue a usarse nunca.
|
|
137
|
+
* Esta igual porque serializar evita la carrera CONOCIDA, y este es el unico punto del
|
|
138
|
+
* pipeline donde un fallo es irreversible a medias.
|
|
139
|
+
*/
|
|
140
|
+
function pushBranchWithRebase(io, branch, version) {
|
|
141
|
+
for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) {
|
|
142
|
+
try {
|
|
143
|
+
io.run('git', ['push', 'origin', branch]);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (attempt === PUSH_ATTEMPTS) {
|
|
148
|
+
throw new Error(`v${version} YA SE PUBLICO en npm, pero el commit de bump no se pudo ` +
|
|
149
|
+
`empujar a '${branch}' despues de ${PUSH_ATTEMPTS} intentos. El tag ` +
|
|
150
|
+
`v${version} si esta en el remoto. Para reconciliar: rebasar el commit ` +
|
|
151
|
+
`'chore(release): v${version}' sobre origin/${branch} y empujarlo a mano. ` +
|
|
152
|
+
`NO re-publicar. Causa: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
// Un fallo de red no lo arregla el rebase, pero tampoco lo empeora: el
|
|
155
|
+
// siguiente intento reintenta el push igual.
|
|
156
|
+
try {
|
|
157
|
+
io.run('git', ['pull', '--rebase', 'origin', branch]);
|
|
158
|
+
}
|
|
159
|
+
catch { /* el retry decide */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -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 () => {
|
|
@@ -16,7 +16,7 @@ function healthyProject() {
|
|
|
16
16
|
root: '/repo/belanz',
|
|
17
17
|
profile: { present: true, extensions: ['frontend'] },
|
|
18
18
|
activeBundles: { expected: ['frontend-craft'], linked: ['frontend-craft'], broken: [] },
|
|
19
|
-
orphanLinks: { repairable: [], dead: [] },
|
|
19
|
+
orphanLinks: { repairable: [], dead: [], usurped: [] },
|
|
20
20
|
sensors: { present: true },
|
|
21
21
|
constitution: { present: true },
|
|
22
22
|
context: { present: true, file: 'CLAUDE.md' },
|
|
@@ -31,7 +31,7 @@ function project(over = {}) {
|
|
|
31
31
|
root: '/repo',
|
|
32
32
|
profile: { present: true, extensions: [] },
|
|
33
33
|
activeBundles: { expected: [], linked: [], broken: [] },
|
|
34
|
-
orphanLinks: { repairable: [], dead: [] },
|
|
34
|
+
orphanLinks: { repairable: [], dead: [], usurped: [] },
|
|
35
35
|
sensors: { present: true },
|
|
36
36
|
constitution: { present: true },
|
|
37
37
|
context: { present: true, file: 'CLAUDE.md' },
|
|
@@ -124,3 +124,73 @@ describe('awm doctor degrades on a usurped global skill', () => {
|
|
|
124
124
|
expect(checkFor([]).state).not.toBe('broken');
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Los hermanos: `agents.native` y `workflows.global` escanean dirs igual de
|
|
129
|
+
// gestionados, y no recibieron el tratamiento cuando se agrego la deteccion.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
//
|
|
132
|
+
// Encontrado validando la v5.0.0: una sonda apunto sin querer a
|
|
133
|
+
// `~/.claude/agents/development-process.md`, lo reemplazo por un directorio ajeno, y
|
|
134
|
+
// `doctor` siguio diciendo `healthy`. `agents.native` con renderer `link` devolvia
|
|
135
|
+
// `healthy` fijo con solo mirar que el dir no estuviera vacio — ni siquiera detectaba
|
|
136
|
+
// un symlink colgante. Tres hermanos, uno tratado.
|
|
137
|
+
describe('agents.native gets the same treatment as skills.global', () => {
|
|
138
|
+
let tmpHome;
|
|
139
|
+
let originalHome;
|
|
140
|
+
let originalAwmHome;
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
tmpHome = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-agents-');
|
|
143
|
+
originalHome = process.env.HOME;
|
|
144
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
145
|
+
process.env.HOME = tmpHome;
|
|
146
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
147
|
+
// registries.ts cachea AWM_HOME al require: hay que re-requerir por test.
|
|
148
|
+
jest.resetModules();
|
|
149
|
+
});
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
152
|
+
if (originalHome === undefined)
|
|
153
|
+
delete process.env.HOME;
|
|
154
|
+
else
|
|
155
|
+
process.env.HOME = originalHome;
|
|
156
|
+
if (originalAwmHome === undefined)
|
|
157
|
+
delete process.env.AWM_HOME;
|
|
158
|
+
else
|
|
159
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
160
|
+
});
|
|
161
|
+
/** Deja en `~/.claude/agents` una entrada real (no symlink) y la declara nuestra. */
|
|
162
|
+
function seedUsurpedAgent(name) {
|
|
163
|
+
const dir = path_1.default.join(tmpHome, '.claude', 'agents');
|
|
164
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
165
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, name), { recursive: true });
|
|
166
|
+
const stateDir = path_1.default.join(tmpHome, '.awm', 'state');
|
|
167
|
+
fs_1.default.mkdirSync(stateDir, { recursive: true });
|
|
168
|
+
fs_1.default.writeFileSync(path_1.default.join(stateDir, 'artifacts.json'), JSON.stringify([{
|
|
169
|
+
name, type: 'agent', scope: 'global',
|
|
170
|
+
targetPath: path_1.default.join(dir, name),
|
|
171
|
+
sourcePath: '/registry/agents/' + name,
|
|
172
|
+
renderer: 'link', owners: ['claude-code'],
|
|
173
|
+
}], null, 2));
|
|
174
|
+
}
|
|
175
|
+
function agentsCheck() {
|
|
176
|
+
const { gatherProviderChecks } = require('../../src/core/diagnostics/provider-checks');
|
|
177
|
+
const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
|
|
178
|
+
const facts = gatherProviderChecks(['claude-code'], scan);
|
|
179
|
+
return facts[0].checks.find((c) => c.id === 'agents.native');
|
|
180
|
+
}
|
|
181
|
+
it('reports broken and names the replaced agent', () => {
|
|
182
|
+
seedUsurpedAgent('development-process');
|
|
183
|
+
const check = agentsCheck();
|
|
184
|
+
expect(check.state).toBe('broken');
|
|
185
|
+
expect(check.detail).toContain('development-process');
|
|
186
|
+
expect(check.remediationCode).toBe('reinstall-usurped-skills');
|
|
187
|
+
});
|
|
188
|
+
it('stays healthy when the directory holds only entries AWM never claimed', () => {
|
|
189
|
+
// Lo que el usuario puso a mano sigue sin ser problema nuestro — el mismo
|
|
190
|
+
// limite que en skills.global. Sin ledger no hay usurpacion posible.
|
|
191
|
+
const dir = path_1.default.join(tmpHome, '.claude', 'agents');
|
|
192
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
193
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, 'my-own-agent'), { recursive: true });
|
|
194
|
+
expect(agentsCheck().state).toBe('healthy');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -186,3 +186,57 @@ describe('parseArgs', () => {
|
|
|
186
186
|
expect(() => (0, index_1.parseArgs)(['--branch'])).toThrow(/--branch requiere/i);
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// D-009 — despues del publish, un fallo de git no puede quedar en silencio
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
//
|
|
193
|
+
// La 4.0.1 existe en npm y no existe en git: sin tag, sin CHANGELOG. Dos corridas
|
|
194
|
+
// de release concurrentes (mergear un PR mientras el release del anterior estaba en
|
|
195
|
+
// vuelo) dejaron a la segunda adelantando `main`, y la primera murio en
|
|
196
|
+
// `git push origin main` con non-fast-forward DESPUES de publicar.
|
|
197
|
+
//
|
|
198
|
+
// El publish es irreversible; el push no. Ningun test cubria el orden entre ambos ni
|
|
199
|
+
// que pasa si el push falla — el happy path afirmaba que las dos lineas existen, no
|
|
200
|
+
// en que orden ni con que recuperacion.
|
|
201
|
+
describe('release — el push despues de un publish exitoso', () => {
|
|
202
|
+
it('empuja el TAG antes que la rama: en el peor caso queda la identidad de lo publicado', () => {
|
|
203
|
+
const { io, calls } = makeIO({ commits: `feat: x${core_1.US}${core_1.RS}` });
|
|
204
|
+
(0, orchestrator_1.release)(opts(), io);
|
|
205
|
+
const tagPush = calls.findIndex((c) => c === 'git push origin v2.2.0');
|
|
206
|
+
const branchPush = calls.findIndex((c) => c === 'git push origin main');
|
|
207
|
+
expect(tagPush).toBeGreaterThanOrEqual(0);
|
|
208
|
+
expect(branchPush).toBeGreaterThanOrEqual(0);
|
|
209
|
+
expect(tagPush).toBeLessThan(branchPush);
|
|
210
|
+
});
|
|
211
|
+
it('reintenta con rebase cuando la rama se movio bajo los pies', () => {
|
|
212
|
+
let branchPushes = 0;
|
|
213
|
+
const { io, calls } = makeIO({ commits: `feat: x${core_1.US}${core_1.RS}` });
|
|
214
|
+
const inner = io.run;
|
|
215
|
+
io.run = (cmd, args) => {
|
|
216
|
+
if (cmd === 'git' && args[0] === 'push' && args[2] === 'main') {
|
|
217
|
+
branchPushes++;
|
|
218
|
+
// Exactamente el fallo real: la primera vez rechazado, despues del rebase pasa.
|
|
219
|
+
if (branchPushes === 1)
|
|
220
|
+
throw new Error('! [rejected] main -> main (non-fast-forward)');
|
|
221
|
+
}
|
|
222
|
+
return inner(cmd, args);
|
|
223
|
+
};
|
|
224
|
+
expect(() => (0, orchestrator_1.release)(opts(), io)).not.toThrow();
|
|
225
|
+
expect(branchPushes).toBe(2);
|
|
226
|
+
expect(calls.join('\n')).toMatch(/git pull --rebase origin main/);
|
|
227
|
+
});
|
|
228
|
+
it('si el push no se recupera, el error NOMBRA que npm ya tiene la version', () => {
|
|
229
|
+
const { io } = makeIO({ commits: `feat: x${core_1.US}${core_1.RS}` });
|
|
230
|
+
const inner = io.run;
|
|
231
|
+
io.run = (cmd, args) => {
|
|
232
|
+
if (cmd === 'git' && args[0] === 'push' && args[2] === 'main') {
|
|
233
|
+
throw new Error('! [rejected] main -> main (non-fast-forward)');
|
|
234
|
+
}
|
|
235
|
+
return inner(cmd, args);
|
|
236
|
+
};
|
|
237
|
+
// Un "failed to push some refs" pelado no le dice a nadie que npm quedo adelantado
|
|
238
|
+
// ni que NO hay que re-publicar. Eso es lo que se asserta, no que tire.
|
|
239
|
+
expect(() => (0, orchestrator_1.release)(opts(), io)).toThrow(/YA SE PUBLICO en npm/);
|
|
240
|
+
expect(() => (0, orchestrator_1.release)(opts(), io)).toThrow(/NO re-publicar/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
// `classifySkillLinks` sin el ledger no puede ver una usurpacion. Ese tercer
|
|
7
|
+
// argumento es opcional a proposito — hay llamadores legitimos que no tienen ledger a
|
|
8
|
+
// mano — y por eso mismo es facil de omitir sin que nada se queje.
|
|
9
|
+
//
|
|
10
|
+
// Ya paso: cuando se agrego la deteccion (D-007) se cableo en el seam `scanSkills`,
|
|
11
|
+
// que alimenta `skills.global`, y los otros dos checks del mismo archivo que llaman al
|
|
12
|
+
// clasificador directo — `workflows.global` y `agents.native` — quedaron afuera. Tres
|
|
13
|
+
// hermanos, uno tratado. Es el patron que mas se repitio en este repo, y la unica
|
|
14
|
+
// contramedida que funciona es que el compilador o un test lo cuenten por vos.
|
|
15
|
+
//
|
|
16
|
+
// Este guard es deliberadamente estrecho: solo mira `core/diagnostics/`, que es donde
|
|
17
|
+
// vive el reporte de salud. Un llamador de otro modulo puede omitir el ledger sin que
|
|
18
|
+
// esto se queje; lo que no puede pasar es que un CHECK diga `healthy` sobre un
|
|
19
|
+
// directorio gestionado que nunca comparo contra el ledger.
|
|
20
|
+
const fs_1 = __importDefault(require("fs"));
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const DIAGNOSTICS = path_1.default.join(__dirname, '..', '..', 'src', 'core', 'diagnostics');
|
|
23
|
+
function sourceFiles(dir) {
|
|
24
|
+
return fs_1.default.readdirSync(dir)
|
|
25
|
+
.filter((f) => f.endsWith('.ts'))
|
|
26
|
+
.map((f) => path_1.default.join(dir, f));
|
|
27
|
+
}
|
|
28
|
+
/** Cada `classifySkillLinks(...)` del archivo, con su lista de argumentos en crudo.
|
|
29
|
+
* Contar comas de nivel superior alcanza: los argumentos reales son identificadores y
|
|
30
|
+
* llamadas simples, sin literales de objeto ni genericos con coma. */
|
|
31
|
+
function classifyCalls(source) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const needle = 'classifySkillLinks(';
|
|
34
|
+
let from = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const at = source.indexOf(needle, from);
|
|
37
|
+
if (at === -1)
|
|
38
|
+
return out;
|
|
39
|
+
from = at + needle.length;
|
|
40
|
+
let depth = 1;
|
|
41
|
+
let i = from;
|
|
42
|
+
while (i < source.length && depth > 0) {
|
|
43
|
+
if (source[i] === '(')
|
|
44
|
+
depth++;
|
|
45
|
+
else if (source[i] === ')')
|
|
46
|
+
depth--;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
out.push({
|
|
50
|
+
args: source.slice(from, i - 1),
|
|
51
|
+
line: source.slice(0, at).split('\n').length,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function topLevelArgCount(args) {
|
|
56
|
+
if (args.trim() === '')
|
|
57
|
+
return 0;
|
|
58
|
+
let depth = 0;
|
|
59
|
+
let count = 1;
|
|
60
|
+
for (const ch of args) {
|
|
61
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
62
|
+
depth++;
|
|
63
|
+
else if (ch === ')' || ch === ']' || ch === '}')
|
|
64
|
+
depth--;
|
|
65
|
+
else if (ch === ',' && depth === 0)
|
|
66
|
+
count++;
|
|
67
|
+
}
|
|
68
|
+
return count;
|
|
69
|
+
}
|
|
70
|
+
describe('every diagnostics scan of a managed directory consults the ownership ledger', () => {
|
|
71
|
+
it('no classifySkillLinks call under core/diagnostics/ omits the managed-targets argument', () => {
|
|
72
|
+
const offenders = [];
|
|
73
|
+
for (const file of sourceFiles(DIAGNOSTICS)) {
|
|
74
|
+
const source = fs_1.default.readFileSync(file, 'utf8');
|
|
75
|
+
for (const call of classifyCalls(source)) {
|
|
76
|
+
// La definicion importada no es una llamada; el parser solo ve `nombre(`.
|
|
77
|
+
if (topLevelArgCount(call.args) < 3) {
|
|
78
|
+
offenders.push(`${path_1.default.basename(file)}:${call.line} — ${call.args.trim()}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
expect(offenders).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it('the guard can actually see a violation (it is not vacuously green)', () => {
|
|
85
|
+
// Si el parser se rompiera, el test de arriba pasaria para siempre sin mirar nada.
|
|
86
|
+
expect(topLevelArgCount('dir, contentRoots()')).toBe(2);
|
|
87
|
+
expect(topLevelArgCount('dir, contentRoots(), managedLinkTargets(state())')).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
it('there is at least one call to guard, so the sweep is not scanning an empty set', () => {
|
|
90
|
+
const total = sourceFiles(DIAGNOSTICS)
|
|
91
|
+
.reduce((n, f) => n + classifyCalls(fs_1.default.readFileSync(f, 'utf8')).length, 0);
|
|
92
|
+
expect(total).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
});
|