agentic-workflow-manager 5.0.0 → 5.0.2

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.
@@ -129,18 +129,24 @@ function computeCodexHookStatus(agent) {
129
129
  const settingsEntry = checkCodexSettingsEntry(config.settingsPath, config.scriptsDir, config.matcher, config.eventName);
130
130
  const allOk = sessionStartScript.ok && settingsEntry.ok;
131
131
  const settingsOnlyMissing = !settingsEntry.ok && sessionStartScript.ok;
132
- let overall;
133
- if (allOk)
134
- overall = 'HEALTHY';
135
- else if (settingsOnlyMissing)
136
- overall = 'NOT_INSTALLED';
137
- else
138
- overall = 'DEGRADED';
139
132
  // Trust only makes sense once the AWM entry is actually present in
140
133
  // hooks.json — otherwise there's nothing installed to trust.
141
134
  const trust = settingsEntry.ok
142
135
  ? computeCodexTrust(sessionStartScript, scriptPath, heartbeatPath)
143
136
  : undefined;
137
+ // `overall` IGNORABA `trust` por completo, asi que `awm hooks status` imprimia
138
+ // `Status: HEALTHY` dos lineas debajo de `Trust: … pending-trust`. Una corrida real
139
+ // del playbook leyo esa salida y concluyo que el hook andaba. `doctor --json` decia
140
+ // la verdad; la vista humana, no — y es la que mira una persona. Ver D-010.
141
+ let overall;
142
+ if (!allOk)
143
+ overall = settingsOnlyMissing ? 'NOT_INSTALLED' : 'DEGRADED';
144
+ else if (trust === 'stale')
145
+ overall = 'DEGRADED';
146
+ else if (trust === 'pending-trust')
147
+ overall = 'PENDING_TRUST';
148
+ else
149
+ overall = 'HEALTHY';
144
150
  return {
145
151
  overall,
146
152
  trust,
@@ -13,6 +13,16 @@ const status_1 = require("./status");
13
13
  const providers_1 = require("../../providers");
14
14
  const registries_1 = require("../../core/registries");
15
15
  const HOOK_TARGETS = providers_1.AGENT_TARGETS.filter((a) => (0, providers_1.getHookConfig)(a));
16
+ /**
17
+ * `hooks` nacio con `-t, --target` y el resto del CLI (`add`, `remove`, `sync`,
18
+ * `update`, `doctor`) usa `-a, --agent`. La asimetria se cobro una corrida real del
19
+ * playbook: `awm hooks status --agent codex` fallaba con `unknown option`. Se acepta
20
+ * `--agent` como alias en vez de renombrar, para no romper a quien ya scriptea
21
+ * `--target`. Ver D-010.
22
+ */
23
+ function resolveHookTarget(options) {
24
+ return (options.agent ?? options.target ?? 'claude-code');
25
+ }
16
26
  function targetOptionDescription() {
17
27
  return `Target harness (${HOOK_TARGETS.join('|')})`;
18
28
  }
@@ -21,9 +31,10 @@ function registerHooksCommand(program) {
21
31
  hooks.command('install')
22
32
  .description('Install the AWM bootstrap hook into the target harness')
23
33
  .option('-t, --target <target>', targetOptionDescription(), 'claude-code')
34
+ .option('-a, --agent <agent>', 'Alias de --target, por simetria con el resto del CLI')
24
35
  .option('-y, --yes', 'Skip interactive confirmations', false)
25
36
  .action(async (options) => {
26
- const agent = (options.target ?? 'claude-code');
37
+ const agent = resolveHookTarget(options);
27
38
  const prefs = (0, config_1.getPreferences)();
28
39
  const hooksRoot = (0, registries_1.capabilityRoot)('hooks');
29
40
  if (!hooksRoot) {
@@ -69,9 +80,10 @@ function registerHooksCommand(program) {
69
80
  hooks.command('uninstall')
70
81
  .description('Remove the AWM bootstrap hook')
71
82
  .option('-t, --target <target>', targetOptionDescription(), 'claude-code')
83
+ .option('-a, --agent <agent>', 'Alias de --target, por simetria con el resto del CLI')
72
84
  .option('-y, --yes', 'Skip interactive confirmations', false)
73
85
  .action(async (options) => {
74
- const agent = (options.target ?? 'claude-code');
86
+ const agent = resolveHookTarget(options);
75
87
  if (!options.yes && process.stdin.isTTY) {
76
88
  const ok = await (0, prompts_1.confirm)({ message: 'Remove AWM bootstrap hook from settings.json?' });
77
89
  if ((0, prompts_1.isCancel)(ok) || ok !== true) {
@@ -98,8 +110,9 @@ function registerHooksCommand(program) {
98
110
  hooks.command('status')
99
111
  .description('Check the bootstrap hook installation status')
100
112
  .option('-t, --target <target>', targetOptionDescription(), 'claude-code')
113
+ .option('-a, --agent <agent>', 'Alias de --target, por simetria con el resto del CLI')
101
114
  .action((options) => {
102
- const agent = (options.target ?? 'claude-code');
115
+ const agent = resolveHookTarget(options);
103
116
  try {
104
117
  const result = (0, status_1.computeHookStatus)(agent);
105
118
  const symbol = (ok) => ok ? picocolors_1.default.green('✓') : picocolors_1.default.red('✗');
@@ -118,10 +131,17 @@ function registerHooksCommand(program) {
118
131
  }
119
132
  console.log('');
120
133
  const overall = result.overall === 'HEALTHY' ? picocolors_1.default.green(result.overall) :
121
- result.overall === 'NOT_INSTALLED' ? picocolors_1.default.yellow(result.overall) :
122
- picocolors_1.default.red(result.overall);
134
+ result.overall === 'NOT_INSTALLED' || result.overall === 'PENDING_TRUST'
135
+ ? picocolors_1.default.yellow(result.overall)
136
+ : picocolors_1.default.red(result.overall);
123
137
  console.log(` Status: ${overall}`);
124
- if (result.overall !== 'HEALTHY') {
138
+ if (result.overall === 'PENDING_TRUST') {
139
+ console.log(picocolors_1.default.dim(' El hook esta instalado y bien formado, pero nunca se lo vio correr.'));
140
+ console.log(picocolors_1.default.dim(' Abri una sesion del agente; si sigue igual, no se esta disparando.'));
141
+ }
142
+ // PENDING_TRUST no sale 1: no hay nada roto que arreglar, y salir 1 en
143
+ // toda instalacion recien hecha convertiria el comando en ruido.
144
+ if (result.overall !== 'HEALTHY' && result.overall !== 'PENDING_TRUST') {
125
145
  process.exit(1);
126
146
  }
127
147
  }
@@ -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
- const integrity = (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)());
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: broken > 0 ? `${broken} broken link(s)` : undefined,
218
- remediationCode: broken > 0 ? 'awm-init' : undefined,
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
- return { id: 'agents.native', state: 'healthy', target: dir };
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
- io.run('git', ['push', 'origin', opts.branch]);
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
+ }
@@ -167,6 +167,27 @@ describe('installHook / computeHookStatus / uninstallHook — Codex adapter', ()
167
167
  const { computeHookStatus } = require('../../../src/commands/hooks/status');
168
168
  expect(computeHookStatus('codex').trust).toBe(expected); // verifies R18
169
169
  });
170
+ // `overall` NUNCA se assertaba — solo `.trust` — y por eso el bug vivio: se calculaba
171
+ // ignorando trust por completo, asi que `awm hooks status` imprimia `Status: HEALTHY`
172
+ // dos lineas debajo de `Trust: … pending-trust`. Una corrida real del playbook leyo esa
173
+ // salida y concluyo que el hook andaba. Ver D-010.
174
+ it.each([
175
+ [false, 'PENDING_TRUST'],
176
+ [true, 'HEALTHY'],
177
+ ])('overall refleja el trust, no solo la presencia de archivos (heartbeat=%s)', (heartbeat, expected) => {
178
+ installCodexFixture({ heartbeat: heartbeat });
179
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
180
+ expect(computeHookStatus('codex').overall).toBe(expected);
181
+ });
182
+ it('un heartbeat obsoleto degrada: el script cambio desde la ultima corrida confirmada', () => {
183
+ installCodexFixture({ heartbeat: true });
184
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'session-start'), '#!/usr/bin/env bash\necho "changed"', { mode: 0o755 });
185
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
186
+ const status = computeHookStatus('codex');
187
+ expect(status.trust).toBe('stale');
188
+ // Esto era `HEALTHY`: la rotura mas clara de las tres, reportada en verde.
189
+ expect(status.overall).toBe('DEGRADED');
190
+ });
170
191
  it('reports stale trust when the heartbeat hash no longer matches the installed script', () => {
171
192
  installCodexFixture({ heartbeat: true });
172
193
  fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'session-start'), '#!/usr/bin/env bash\necho "changed"', { mode: 0o755 });
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"