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.
@@ -270,8 +270,20 @@ async function runInit(opts = {}) {
270
270
  json: opts.json,
271
271
  });
272
272
  }
273
- // `result` mirrors the exit code, so a consumer can branch on one field
274
- // instead of correlating stdout with $?: ok → 0, degraded 1, failed → 2.
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 result === 'ok' ? 0 : 1;
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
- 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
+ }
@@ -109,14 +109,17 @@ describe('runInit', () => {
109
109
  expect(caveatCalls).toHaveLength(0);
110
110
  });
111
111
  });
112
- it('returns exit 1 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
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
- expect(code).toBe(1); // cache/hook/devCore siguen ausentes (syncCache no-op) degradado
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
- expect(code).toBe(1);
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "4.1.1",
3
+ "version": "5.0.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"