agentic-workflow-manager 3.3.0 → 3.4.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.
@@ -49,6 +49,7 @@ const bundles_1 = require("../core/bundles");
49
49
  const registries_1 = require("../core/registries");
50
50
  const orchestrator_1 = require("../core/init/orchestrator");
51
51
  const steps_1 = require("../core/init/steps");
52
+ const failure_1 = require("../core/init/failure");
52
53
  const mutation_targets_1 = require("../core/init/mutation-targets");
53
54
  const provider_facts_1 = require("../core/init/provider-facts");
54
55
  const install_transaction_1 = require("../core/install-transaction");
@@ -94,6 +95,35 @@ function renderInitOutcome(o) {
94
95
  lines.push(`status: ${status} · ${pendingCount} steps require an agent (skills above)`);
95
96
  return lines.join('\n');
96
97
  }
98
+ // ---------------------------------------------------------------------------
99
+ // Failure reporting
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Single exit point for every failed `awm init`. Honours `--json`'s contract on
103
+ * the error path — the whole point of the flag for a headless bootstrap is to
104
+ * learn WHICH step failed — and, in human mode, renders the same evidence
105
+ * through the normal init dashboard instead of discarding it.
106
+ *
107
+ * stdout carries the machine-readable document (JSON mode) or the dashboard
108
+ * (human mode); stderr always carries the one-line summary plus the
109
+ * transaction verdict, so `2>&1`-free scripts still see a reason.
110
+ */
111
+ function reportInitFailure(o) {
112
+ const payload = (0, failure_1.buildInitFailureOutput)({
113
+ error: o.error,
114
+ outcome: o.outcome,
115
+ transaction: o.transaction,
116
+ });
117
+ if (o.json) {
118
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
119
+ }
120
+ else if (o.outcome) {
121
+ process.stdout.write(renderInitOutcome(o.outcome) + '\n');
122
+ }
123
+ process.stderr.write(`awm init: ${payload.error}\n`);
124
+ process.stderr.write(`awm init: ${payload.transaction.note}\n`);
125
+ return 2;
126
+ }
97
127
  async function runInit(opts = {}) {
98
128
  const cwd = opts.cwd ?? process.cwd();
99
129
  const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
@@ -125,6 +155,10 @@ async function runInit(opts = {}) {
125
155
  // and would incorrectly be flagged as a violation.
126
156
  const beforeClaudeFacts = agent === 'claude-code' ? null : (0, provider_facts_1.gatherProviderFacts)('claude-code');
127
157
  let outcome;
158
+ // Populated as soon as each becomes available, so the failure reporter can
159
+ // emit whatever evidence THIS run got far enough to produce.
160
+ let pipelineOutcome;
161
+ let transaction;
128
162
  try {
129
163
  const mergedActions = {
130
164
  ...steps_1.defaultActions,
@@ -155,7 +189,11 @@ async function runInit(opts = {}) {
155
189
  (0, config_1.savePreferences)(nextPreferences);
156
190
  (0, registries_1.seedBaselineRegistry)();
157
191
  if ((0, registries_1.listRegistries)().some((r) => !fs_1.default.existsSync(r.contentRoot))) {
158
- await mergedActions.syncCache();
192
+ // `syncRegistries()` reports per-registry failures as RESULTS,
193
+ // never as throws (core/registries.ts) — swallowing them here
194
+ // let an unavailable registry degrade silently into some later
195
+ // step's failure instead of being reported as its own cause.
196
+ (0, registries_1.assertSyncedRegistriesUsable)((await mergedActions.syncCache()) ?? []);
159
197
  }
160
198
  const bundles = (0, bundles_1.discoverAllBundles)();
161
199
  const ctx = (0, context_1.gatherContext)({ cwd, bundles, agent });
@@ -177,8 +215,13 @@ async function runInit(opts = {}) {
177
215
  confirmExtensions,
178
216
  actions: mergedActions,
179
217
  });
218
+ pipelineOutcome = outcome;
180
219
  if (outcome.failed > 0) {
181
- throw new Error('one or more init steps failed');
220
+ // Typed so the outcome the only record of WHICH step failed
221
+ // and why — survives the rollback below and reaches the
222
+ // reporter. A bare Error here is what made `--json` emit
223
+ // nothing on the one path that most needed it.
224
+ throw new failure_1.InitStepsFailedError(outcome);
182
225
  }
183
226
  if (beforeClaudeFacts) {
184
227
  (0, provider_facts_1.assertClaudeBaselinePreserved)(beforeClaudeFacts, (0, provider_facts_1.gatherProviderFacts)('claude-code'));
@@ -188,21 +231,49 @@ async function runInit(opts = {}) {
188
231
  outcome.modifiedFiles = backup.targetPaths;
189
232
  }
190
233
  catch (error) {
191
- backup.rollback();
234
+ // Rollback is best-effort and must never mask the original failure
235
+ // (same policy as applyInstallPlan's own rollback loop): the
236
+ // operator needs the step that failed, not the restore that also did.
237
+ let rollbackError;
238
+ try {
239
+ backup.rollback();
240
+ }
241
+ catch (e) {
242
+ rollbackError = e instanceof Error ? e.message : String(e);
243
+ }
244
+ transaction = {
245
+ committed: false,
246
+ rolledBack: rollbackError === undefined,
247
+ transactionId: backup.transactionId,
248
+ restoredFiles: backup.targetPaths,
249
+ ...(rollbackError === undefined ? {} : { rollbackError }),
250
+ note: (0, failure_1.transactionNote)(rollbackError === undefined),
251
+ };
192
252
  throw error;
193
253
  }
194
254
  }
195
255
  catch (err) {
196
- process.stderr.write(`awm init: internal error: ${err.message}\n`);
197
- return 2;
256
+ // The typed error carries its own outcome (so it stays self-sufficient
257
+ // for any caller of runInit); `pipelineOutcome` covers the other way a
258
+ // run can fail *after* the pipeline produced one — e.g. the R19
259
+ // Claude-baseline assertion.
260
+ return reportInitFailure({
261
+ error: err,
262
+ outcome: err instanceof failure_1.InitStepsFailedError ? err.outcome : pipelineOutcome,
263
+ transaction,
264
+ json: opts.json,
265
+ });
198
266
  }
267
+ // `result` mirrors the exit code, so a consumer can branch on one field
268
+ // instead of correlating stdout with $?: ok → 0, degraded → 1, failed → 2.
269
+ const result = outcome.after.overall === 'healthy' ? 'ok' : 'degraded';
199
270
  if (opts.json) {
200
- process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
271
+ process.stdout.write(JSON.stringify({ result, ...outcome }, null, 2) + '\n');
201
272
  }
202
273
  else {
203
274
  process.stdout.write(renderInitOutcome(outcome) + '\n');
204
275
  }
205
- return outcome.after.overall === 'healthy' ? 0 : 1;
276
+ return result === 'ok' ? 0 : 1;
206
277
  }
207
278
  // ---------------------------------------------------------------------------
208
279
  // Extension confirmation factory
@@ -240,7 +311,7 @@ function registerInitCommand(program) {
240
311
  .option('-y, --yes', 'Skip confirmation prompts')
241
312
  .option('-a, --agent <agent>', 'Target agent (default: claude-code)')
242
313
  .option('--machine-only', 'Only run machine-level steps (skip project steps)')
243
- .option('--json', 'Emit the InitOutcome as JSON')
314
+ .option('--json', 'Emit the InitOutcome as JSON — on success and on failure (failed steps + rollback)')
244
315
  .action(async (options) => {
245
316
  (0, paths_1.warnIfUnsupportedPlatform)((m) => console.warn(picocolors_1.default.yellow(`⚠ ${m}`)));
246
317
  const code = await runInit({
@@ -47,7 +47,7 @@ function registerLedgerCommand(program) {
47
47
  });
48
48
  ledger
49
49
  .command('recurring')
50
- .description('print signature clusters with count >= min (recurrence signal)')
50
+ .description('print recurrence clusters with count >= min (exact signature repeats and cross-reviewer convergence)')
51
51
  .option('--min <n>', 'minimum occurrences', '2')
52
52
  .option('--branch <branch>', 'override branch (default: git current branch)')
53
53
  .action((opts) => {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFERENCE_LINE = void 0;
4
+ exports.stripIntraRegistryPaths = stripIntraRegistryPaths;
4
5
  exports.claudeAiTransform = claudeAiTransform;
5
6
  // cli/src/core/export/transform.ts
6
7
  //
@@ -9,6 +10,53 @@ exports.claudeAiTransform = claudeAiTransform;
9
10
  // línea) — sin parser YAML a propósito (YAGNI, cero deps).
10
11
  const DEFERENCE_LINE = (skillName) => `In environments with AWM installed (Claude Code), defer to the registry's ${skillName} skill — this port is for environments without filesystem access.`;
11
12
  exports.DEFERENCE_LINE = DEFERENCE_LINE;
13
+ // Paths intra-registry: resuelven en Claude Code (donde el registry está en
14
+ // disco) y nunca en claude.ai, donde solo se sube la skill portable. Se limpian
15
+ // en el artefacto exportado en vez de editar el SKILL.md canónico, que en Claude
16
+ // Code sí los necesita.
17
+ const SKILL_NAME = '[a-z0-9][a-z0-9-]*';
18
+ const REF_FILE = '[A-Za-z0-9._-]+';
19
+ const PATH_SRC = '(?:skills\\/' + SKILL_NAME + '\\/references\\/' + REF_FILE + '\\.md'
20
+ + '|skills\\/' + SKILL_NAME + '\\/SKILL\\.md)';
21
+ /** Reconoce, en una sola pasada sobre el body ORIGINAL, tanto el caso
22
+ * "paréntesis cuyo único contenido es un path" (grupo 1) como el path suelto
23
+ * en cualquier otra posición (grupo 2). Una sola pasada evita un bug real de
24
+ * splicing encontrado en code review: si se borrara el paréntesis en una
25
+ * pasada separada, un path inmediatamente siguiente podría quedar pegado a
26
+ * texto que antes terminaba en `/` (el cierre de una URL, p. ej.), y el guard
27
+ * de "embebido en URL" (que mira el carácter previo) confundiría eso con un
28
+ * path genuinamente embebido. Matcheando todo en una sola pasada contra el
29
+ * string original, cada offset que llega a `isEmbeddedInUrl` es siempre real,
30
+ * nunca un artefacto de un borrado previo. */
31
+ const PATH_OR_DROPPED_PAREN = new RegExp('([ \\t]*\\((?:see[ \\t]+)?`?' + PATH_SRC + '`?\\))' + '|' + '(`?' + PATH_SRC + '`?)', 'g');
32
+ /** Un path precedido por `/` es el final de una URL o de un path más largo (un
33
+ * enlace a GitHub, por ejemplo). Esas referencias SÍ resuelven para quien lee la
34
+ * skill en claude.ai, así que no se tocan. */
35
+ function isEmbeddedInUrl(haystack, matchStart, matched) {
36
+ const pathStart = matchStart + matched.indexOf('skills/');
37
+ return pathStart > 0 && haystack[pathStart - 1] === '/';
38
+ }
39
+ const PATH_MATCHER = new RegExp('^skills\\/(' + SKILL_NAME + ')\\/references\\/(' + REF_FILE + ')\\.md$'
40
+ + '|^skills\\/(' + SKILL_NAME + ')\\/SKILL\\.md$');
41
+ function pathlessForm(p) {
42
+ const m = PATH_MATCHER.exec(p);
43
+ if (!m)
44
+ throw new Error(`unreachable: "${p}" matched PATH_SRC but not PATH_MATCHER — the two must stay in sync`);
45
+ const [, refSkill, refFile, skillOnlyName] = m;
46
+ if (skillOnlyName !== undefined)
47
+ return `the \`${skillOnlyName}\` skill`;
48
+ return `the \`${refSkill}\` skill's ${refFile.replace(/-/g, ' ')} reference`;
49
+ }
50
+ function stripIntraRegistryPaths(body) {
51
+ return body.replace(PATH_OR_DROPPED_PAREN, (match, parenForm, bareForm, offset) => {
52
+ if (isEmbeddedInUrl(body, offset, match))
53
+ return match;
54
+ if (parenForm !== undefined)
55
+ return '';
56
+ const path = bareForm.replace(/^`|`$/g, '');
57
+ return pathlessForm(path);
58
+ });
59
+ }
12
60
  function claudeAiTransform(skillMd, skillName) {
13
61
  // \r?\n-tolerant, same rationale as readArtifactDescription in discovery.ts:
14
62
  // SKILL.md files may be CRLF-terminated and that's still valid frontmatter.
@@ -53,5 +101,7 @@ function claudeAiTransform(skillMd, skillName) {
53
101
  ? `${value.slice(0, -1)} ${deference.replace(/'/g, "''")}'`
54
102
  : `${value} ${deference}`;
55
103
  fmLines[descIdx] = `description: ${newValue}`;
56
- return `---\n${fmLines.join('\n')}\n---\n${body}`;
104
+ // Solo el body: el frontmatter ya se editó arriba y sus campos no son prosa
105
+ // navegable (R2.4).
106
+ return `---\n${fmLines.join('\n')}\n---\n${stripIntraRegistryPaths(body)}`;
57
107
  }
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ // src/core/init/failure.ts
3
+ //
4
+ // Failure evidence for `awm init`.
5
+ //
6
+ // `awm init` is transactional: any failure rolls every write back. That part
7
+ // was always right — what was missing is the EVIDENCE. `runInitSteps` already
8
+ // records, per step, `{ id, action: 'failed', error }` (orchestrator.ts's
9
+ // `wrapStep`), but commands/init.ts used to collapse the whole outcome into a
10
+ // bare `new Error('one or more init steps failed')`, so `--json` — whose only
11
+ // job is to emit that outcome — printed nothing at all on the exact path an
12
+ // operator needs it: a headless cloud bootstrap that just aborted.
13
+ //
14
+ // `InitStepsFailedError` carries the outcome across the rollback boundary, and
15
+ // `buildInitFailureOutput` shapes it into the JSON envelope the CLI emits.
16
+ // The envelope is deliberately self-describing (`result`, `failedSteps`,
17
+ // `transaction`) so a bootstrap script can branch on it without re-deriving
18
+ // anything from prose on stderr.
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.InitStepsFailedError = void 0;
21
+ exports.failedSteps = failedSteps;
22
+ exports.transactionNote = transactionNote;
23
+ exports.buildInitFailureOutput = buildInitFailureOutput;
24
+ /** Only the steps that actually failed — the subset an operator needs first. */
25
+ function failedSteps(outcome) {
26
+ return outcome.steps.filter((s) => s.action === 'failed');
27
+ }
28
+ /**
29
+ * Thrown when the step pipeline ran to completion but ≥1 step failed. Carries
30
+ * the full `InitOutcome` so the rollback path can still emit it, and builds a
31
+ * message that names every failed step instead of the old generic sentence.
32
+ */
33
+ class InitStepsFailedError extends Error {
34
+ outcome;
35
+ constructor(outcome) {
36
+ const detail = failedSteps(outcome)
37
+ .map((s) => `${s.id}: ${s.error ?? 'no error recorded'}`)
38
+ .join('; ');
39
+ super(`one or more init steps failed — ${detail || 'no failed step recorded'}`);
40
+ this.name = 'InitStepsFailedError';
41
+ this.outcome = outcome;
42
+ }
43
+ }
44
+ exports.InitStepsFailedError = InitStepsFailedError;
45
+ const NO_TRANSACTION_NOTE = 'init failed before a backup session was opened — nothing was written, so there was nothing to roll back.';
46
+ /** The note explaining what a given rollback outcome means for the machine. */
47
+ function transactionNote(rolledBack) {
48
+ return rolledBack
49
+ ? 'the transaction was NOT committed: every path in restoredFiles was restored to its pre-init state. '
50
+ + '`after` reflects the state observed at the end of the step pipeline, BEFORE this rollback ran.'
51
+ : 'the transaction was NOT committed and the rollback did not complete — see rollbackError and '
52
+ + 'restore manually with `awm backup restore <transactionId>`.';
53
+ }
54
+ function buildInitFailureOutput(o) {
55
+ const steps = o.outcome?.steps ?? [];
56
+ return {
57
+ result: 'failed',
58
+ error: o.error.message,
59
+ steps,
60
+ failedSteps: steps.filter((s) => s.action === 'failed'),
61
+ applied: o.outcome?.applied ?? 0,
62
+ pending: o.outcome?.pending ?? 0,
63
+ failed: o.outcome?.failed ?? 0,
64
+ before: o.outcome?.before ?? null,
65
+ after: o.outcome?.after ?? null,
66
+ transaction: o.transaction ?? {
67
+ committed: false,
68
+ rolledBack: false,
69
+ transactionId: null,
70
+ restoredFiles: [],
71
+ note: NO_TRANSACTION_NOTE,
72
+ },
73
+ };
74
+ }
@@ -39,7 +39,7 @@ const codex_agents_1 = require("../context/strategies/codex-agents");
39
39
  // ---------------------------------------------------------------------------
40
40
  const realInjectionOrchestrator = new orchestrator_1.InjectionOrchestrator();
41
41
  exports.defaultActions = {
42
- syncCache: async () => { await (0, registries_1.syncRegistries)(); },
42
+ syncCache: async () => (0, registries_1.syncRegistries)(),
43
43
  installHook: (o) => (0, install_1.installHook)({
44
44
  agent: o.agent,
45
45
  registryRoot: o.registryRoot,
@@ -135,14 +135,30 @@ async function stepCache(d) {
135
135
  const needsSync = !registryCache.present || registryCache.gitState === 'behind';
136
136
  if (!needsSync)
137
137
  return ok('machine.cache', 'machine', 'skipped');
138
+ let results;
138
139
  try {
139
- await d.actions.syncCache();
140
- return ok('machine.cache', 'machine', 'applied');
140
+ results = (await d.actions.syncCache()) ?? [];
141
141
  }
142
142
  catch (e) {
143
143
  const msg = e instanceof Error ? e.message : String(e);
144
144
  return failed('machine.cache', 'machine', msg);
145
145
  }
146
+ // `syncRegistries()` reports per-registry failures as RESULTS, not throws
147
+ // (registries.ts), so the `try` above catches none of them. Ignoring them
148
+ // is what let an unavailable base registry degrade silently into some
149
+ // later step's failure — with its own cause already gone.
150
+ const errors = (0, registries_1.registrySyncErrors)(results);
151
+ if (errors.length === 0)
152
+ return ok('machine.cache', 'machine', 'applied');
153
+ // A registry that errored and has no content on disk is unusable, and every
154
+ // later step that reads it will fail for a reason that no longer names this
155
+ // cause — so machine.cache owns it here. One that errored but still has
156
+ // content is stale, not broken: record it and carry on.
157
+ const unusable = (0, registries_1.unusableSyncedRegistries)(results);
158
+ if (unusable.length > 0) {
159
+ return failed('machine.cache', 'machine', `registry unavailable — ${(0, registries_1.describeRegistrySyncErrors)(unusable)}`);
160
+ }
161
+ return ok('machine.cache', 'machine', 'applied', `stale registries — ${(0, registries_1.describeRegistrySyncErrors)(errors)}`);
146
162
  }
147
163
  /** Step 2 – Install the session-start hook for the target agent. */
148
164
  function stepHook(d) {
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEXICAL_AFFINITY_MIN = void 0;
4
+ exports.normalizeTokens = normalizeTokens;
5
+ exports.affinity = affinity;
6
+ exports.normalizeRef = normalizeRef;
7
+ exports.clusterEntries = clusterEntries;
8
+ /** Palabras sin valor discriminante de identidad de defecto: se descartan antes
9
+ * de medir afinidad para que no inflen el score de dos hallazgos distintos. */
10
+ const STOPWORDS = new Set([
11
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'not', 'but',
12
+ 'its', 'has', 'have', 'was', 'are', 'were', 'when', 'then', 'than', 'only',
13
+ 'all', 'any', 'via', 'per', 'out', 'off', 'over', 'under',
14
+ ]);
15
+ function normalizeTokens(...texts) {
16
+ const out = new Set();
17
+ for (const text of texts) {
18
+ for (const token of text.toLowerCase().split(/[^a-z0-9]+/)) {
19
+ if (token.length < 2)
20
+ continue;
21
+ if (STOPWORDS.has(token))
22
+ continue;
23
+ out.add(token);
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ /** Coeficiente de solapamiento — |A∩B| / min(|A|,|B|). Elegido sobre Jaccard
29
+ * porque el caso normal acá es un slug corto contra una descripción larga, y
30
+ * Jaccard castiga esa diferencia de longitud incluso cuando el set corto está
31
+ * completamente contenido en el largo. */
32
+ function affinity(a, b) {
33
+ if (a.size === 0 || b.size === 0)
34
+ return 0;
35
+ let shared = 0;
36
+ for (const token of a)
37
+ if (b.has(token))
38
+ shared++;
39
+ return shared / Math.min(a.size, b.size);
40
+ }
41
+ /** El locus de archivo de un `ref`, o null cuando el ref no apunta a un archivo.
42
+ * `PR #16` devuelve null a propósito: un PR entero no es un locus de defecto, y
43
+ * agrupar por él fundiría todo lo hallado en una misma review. */
44
+ function normalizeRef(ref) {
45
+ if (!ref)
46
+ return null;
47
+ const locus = ref.split(':')[0].trim();
48
+ if (!locus)
49
+ return null;
50
+ if (!locus.includes('/') && !/\.[a-z0-9]+$/i.test(locus))
51
+ return null;
52
+ return locus;
53
+ }
54
+ /** Umbral sin `ref` compartido: alto, porque la afinidad léxica es la única
55
+ * evidencia disponible y un falso positivo acá funde hallazgos de archivos
56
+ * distintos.
57
+ *
58
+ * Con `ref` compartido no hay umbral de ratio: alcanza **un token en común**
59
+ * (`score > 0`). Es deliberado y no es lo mismo que "un umbral muy bajo":
60
+ * compartir archivo ya es evidencia fuerte y barata de mismo locus, así que lo
61
+ * único que falta descartar es el par sin ninguna palabra en común — dos
62
+ * defectos genuinamente distintos que caen en el mismo archivo. Un ratio bajo
63
+ * (probamos 0.2) hacía que el caso real del issue —tres lentes, siete a nueve
64
+ * tokens cada una, un solo token compartido por par— cayera exactamente sobre
65
+ * el borde: agrupaba por casualidad aritmética, y cualquier palabra más en una
66
+ * descripción lo habría vuelto a romper. */
67
+ exports.LEXICAL_AFFINITY_MIN = 0.6;
68
+ /** Devuelve, por índice de entrada, el índice raíz de su cluster. */
69
+ function unify(entries) {
70
+ const parent = entries.map((_, i) => i);
71
+ const find = (i) => {
72
+ let node = i;
73
+ while (parent[node] !== node) {
74
+ parent[node] = parent[parent[node]];
75
+ node = parent[node];
76
+ }
77
+ return node;
78
+ };
79
+ const union = (a, b) => {
80
+ const rootA = find(a);
81
+ const rootB = find(b);
82
+ // La raíz más baja gana: hace el resultado independiente del orden de
83
+ // comparación, y por lo tanto determinístico.
84
+ if (rootA !== rootB)
85
+ parent[Math.max(rootA, rootB)] = Math.min(rootA, rootB);
86
+ };
87
+ const tokens = entries.map((e) => normalizeTokens(e.signature, e.desc));
88
+ const refs = entries.map((e) => normalizeRef(e.ref));
89
+ for (let i = 0; i < entries.length; i++) {
90
+ for (let j = i + 1; j < entries.length; j++) {
91
+ if (entries[i].signature === entries[j].signature) {
92
+ union(i, j); // R1.1 — piso preservado, sin más condiciones
93
+ continue;
94
+ }
95
+ if (entries[i].polarity !== entries[j].polarity)
96
+ continue; // R1.4
97
+ const score = affinity(tokens[i], tokens[j]);
98
+ const sameFile = refs[i] !== null && refs[i] === refs[j];
99
+ // Mismo archivo: cualquier palabra en común alcanza (R1.2).
100
+ // Archivos distintos: la afinidad tiene que sostener sola (R1.3).
101
+ const unionable = sameFile ? score > 0 : score >= exports.LEXICAL_AFFINITY_MIN;
102
+ if (unionable)
103
+ union(i, j);
104
+ }
105
+ }
106
+ return entries.map((_, i) => find(i));
107
+ }
108
+ function clusterEntries(entries, min) {
109
+ const roots = unify(entries);
110
+ const byRoot = new Map();
111
+ for (let i = 0; i < entries.length; i++) {
112
+ const group = byRoot.get(roots[i]) ?? [];
113
+ group.push(entries[i]);
114
+ byRoot.set(roots[i], group);
115
+ }
116
+ const clusters = [];
117
+ for (const group of byRoot.values()) {
118
+ const freq = new Map();
119
+ for (const e of group)
120
+ freq.set(e.signature, (freq.get(e.signature) ?? 0) + 1);
121
+ const signatures = [...freq.keys()].sort();
122
+ // signatures viene ascendente y la comparación es estricta, así que un
123
+ // empate de frecuencia deja parada la primera lexicográfica (R1.8).
124
+ const representative = signatures.reduce((best, s) => (freq.get(s) > freq.get(best) ? s : best), signatures[0]);
125
+ clusters.push({
126
+ signature: representative,
127
+ count: group.length,
128
+ kind: signatures.length > 1 ? 'convergent' : 'exact',
129
+ signatures,
130
+ entries: group,
131
+ });
132
+ }
133
+ return clusters
134
+ .filter((c) => c.count >= min)
135
+ .sort((a, b) => b.count - a.count
136
+ || (a.kind === b.kind ? 0 : a.kind === 'convergent' ? -1 : 1)
137
+ || a.signature.localeCompare(b.signature));
138
+ }
@@ -12,6 +12,7 @@ exports.archiveLedger = archiveLedger;
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const child_process_1 = require("child_process");
15
+ const cluster_1 = require("./cluster");
15
16
  const LEDGER_DIR = path_1.default.join('.awm', 'ledger');
16
17
  function detectBranch(cwd) {
17
18
  try {
@@ -33,6 +34,21 @@ function addEntry(cwd, entry) {
33
34
  fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
34
35
  fs_1.default.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
35
36
  }
37
+ /** Required LedgerEntry fields that `cluster.ts` reads unconditionally
38
+ * (`signature`, `desc`, `ref` when present). A JSONL line can be syntactically
39
+ * valid JSON while still being shape-invalid (e.g. missing `desc`) — that's
40
+ * not a parse error, so it needs its own check, extending the same "skip
41
+ * malformed line" policy this function already applies to JSON syntax errors. */
42
+ function isWellFormedEntry(x) {
43
+ if (!x || typeof x !== 'object')
44
+ return false;
45
+ const e = x;
46
+ return typeof e.signature === 'string'
47
+ && typeof e.desc === 'string'
48
+ && typeof e.branch === 'string'
49
+ && typeof e.polarity === 'string'
50
+ && (e.ref === undefined || typeof e.ref === 'string');
51
+ }
36
52
  function listEntries(cwd, branch) {
37
53
  const p = ledgerPath(cwd, branch);
38
54
  if (!fs_1.default.existsSync(p))
@@ -43,23 +59,16 @@ function listEntries(cwd, branch) {
43
59
  if (!trimmed)
44
60
  continue;
45
61
  try {
46
- out.push(JSON.parse(trimmed));
62
+ const parsed = JSON.parse(trimmed);
63
+ if (isWellFormedEntry(parsed))
64
+ out.push(parsed);
47
65
  }
48
66
  catch { /* skip malformed line */ }
49
67
  }
50
68
  return out;
51
69
  }
52
70
  function recurring(cwd, branch, min) {
53
- const bySig = new Map();
54
- for (const e of listEntries(cwd, branch)) {
55
- const arr = bySig.get(e.signature) ?? [];
56
- arr.push(e);
57
- bySig.set(e.signature, arr);
58
- }
59
- return [...bySig.entries()]
60
- .map(([signature, entries]) => ({ signature, count: entries.length, entries }))
61
- .filter(c => c.count >= min)
62
- .sort((a, b) => b.count - a.count);
71
+ return (0, cluster_1.clusterEntries)(listEntries(cwd, branch), min);
63
72
  }
64
73
  function archiveLedger(cwd, branch, label) {
65
74
  const src = ledgerPath(cwd, branch);
@@ -13,6 +13,10 @@ exports.contentRoots = contentRoots;
13
13
  exports.capabilityRoot = capabilityRoot;
14
14
  exports.validateRegistryLayout = validateRegistryLayout;
15
15
  exports.syncRegistries = syncRegistries;
16
+ exports.registrySyncErrors = registrySyncErrors;
17
+ exports.describeRegistrySyncErrors = describeRegistrySyncErrors;
18
+ exports.unusableSyncedRegistries = unusableSyncedRegistries;
19
+ exports.assertSyncedRegistriesUsable = assertSyncedRegistriesUsable;
16
20
  exports.readRegistryManifest = readRegistryManifest;
17
21
  exports.registryNameForPath = registryNameForPath;
18
22
  exports.verifyMinCliVersions = verifyMinCliVersions;
@@ -144,6 +148,46 @@ async function syncRegistries() {
144
148
  }
145
149
  return results;
146
150
  }
151
+ /** The errored entries of a `syncRegistries()` run, in `listRegistries()` order. */
152
+ function registrySyncErrors(results) {
153
+ return results
154
+ .filter((r) => r.action === 'error')
155
+ .map((r) => ({ name: r.name, error: r.error }));
156
+ }
157
+ /** `name: reason; name: reason` — the shape both init and stepCache report errors in. */
158
+ function describeRegistrySyncErrors(errors) {
159
+ return errors.map((e) => `${e.name}: ${e.error}`).join('; ');
160
+ }
161
+ /**
162
+ * Registries that both errored during sync AND have no content on disk
163
+ * afterwards — i.e. genuinely unusable, as opposed to merely stale.
164
+ *
165
+ * `syncRegistries()` deliberately reports per-registry failures as results
166
+ * rather than throwing, so a flaky secondary registry never aborts a whole
167
+ * run. Callers that go on to READ registry content (init) still need to know
168
+ * whether what they are about to read exists: otherwise a missing registry
169
+ * resurfaces much later as an unrelated step's failure, with its real cause
170
+ * already discarded. Note that "usable" is deliberately about content on disk,
171
+ * not about being a healthy git clone — a seeded content root with no `.git`
172
+ * fails to sync every time and is still perfectly readable.
173
+ */
174
+ function unusableSyncedRegistries(results) {
175
+ const errors = registrySyncErrors(results);
176
+ if (errors.length === 0)
177
+ return [];
178
+ const roots = new Map(listRegistries().map((r) => [r.name, r.contentRoot]));
179
+ return errors.filter((e) => {
180
+ const root = roots.get(e.name);
181
+ return root === undefined || !fs_1.default.existsSync(root);
182
+ });
183
+ }
184
+ /** `unusableSyncedRegistries` as a guard: throws naming every unusable registry. */
185
+ function assertSyncedRegistriesUsable(results) {
186
+ const unusable = unusableSyncedRegistries(results);
187
+ if (unusable.length === 0)
188
+ return;
189
+ throw new Error(`registry sync failed and left no content on disk — ${describeRegistrySyncErrors(unusable)}`);
190
+ }
147
191
  exports.REGISTRY_MANIFEST_NAME = 'awm-registry.json';
148
192
  function readRegistryManifest(root) {
149
193
  const file = path_1.default.join(root, exports.REGISTRY_MANIFEST_NAME);
@@ -91,6 +91,7 @@ describe('runInit', () => {
91
91
  const parsed = JSON.parse(written);
92
92
  expect(Array.isArray(parsed.steps)).toBe(true);
93
93
  expect(parsed.after.overall).toBe('degraded');
94
+ expect(parsed.result).toBe('degraded'); // success envelope is self-describing too
94
95
  expect(code).toBe(1);
95
96
  });
96
97
  const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
@@ -291,4 +292,85 @@ describe('runInit', () => {
291
292
  // rollback must restore its exact pre-run content, not just leave it be.
292
293
  expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
293
294
  });
295
+ // -----------------------------------------------------------------------
296
+ // Failure evidence — `--json` must honour its contract on the ERROR path
297
+ // -----------------------------------------------------------------------
298
+ describe('failure evidence', () => {
299
+ let errSpy;
300
+ beforeEach(() => {
301
+ errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
302
+ });
303
+ afterEach(() => errSpy.mockRestore());
304
+ const stdout = () => writeSpy.mock.calls.map((c) => c[0]).join('');
305
+ const stderr = () => errSpy.mock.calls.map((c) => c[0]).join('');
306
+ /** InitActions whose `machine.hook` step blows up inside the step pipeline. */
307
+ function actionsWithFailingHook() {
308
+ const actions = fakeActions([]);
309
+ actions.installHook = () => { throw new Error('hook boom'); };
310
+ return actions;
311
+ }
312
+ it('--json emits parseable JSON carrying the failed step id and error', async () => {
313
+ const { runInit } = require('../../src/commands/init');
314
+ const code = await runInit({
315
+ cwd: tmpHome,
316
+ yes: true,
317
+ json: true,
318
+ actions: actionsWithFailingHook(),
319
+ });
320
+ expect(code).not.toBe(0);
321
+ expect(code).toBe(2);
322
+ const parsed = JSON.parse(stdout());
323
+ expect(parsed.result).toBe('failed');
324
+ const failedStep = parsed.steps.find((s) => s.action === 'failed');
325
+ expect(failedStep.id).toBe('machine.hook');
326
+ expect(failedStep.error).toBe('hook boom');
327
+ expect(parsed.failedSteps).toEqual([failedStep]);
328
+ expect(parsed.failed).toBe(1);
329
+ // The top-level message names the step too — no generic "internal error".
330
+ expect(parsed.error).toContain('machine.hook');
331
+ expect(parsed.error).toContain('hook boom');
332
+ // `before` is the pre-run snapshot the issue asks for.
333
+ expect(parsed.before.results.length).toBeGreaterThan(0);
334
+ });
335
+ it('--json reports the transaction as not committed and rolled back', async () => {
336
+ const { runInit } = require('../../src/commands/init');
337
+ await runInit({ cwd: tmpHome, yes: true, json: true, actions: actionsWithFailingHook() });
338
+ const { transaction } = JSON.parse(stdout());
339
+ expect(transaction.committed).toBe(false);
340
+ expect(transaction.rolledBack).toBe(true);
341
+ expect(typeof transaction.transactionId).toBe('string');
342
+ expect(Array.isArray(transaction.restoredFiles)).toBe(true);
343
+ expect(transaction.note).toBeTruthy();
344
+ // The backup manifest on disk agrees: nothing was committed.
345
+ const { listBackups } = require('../../src/core/install-transaction');
346
+ const backups = listBackups();
347
+ expect(backups.length).toBeGreaterThan(0);
348
+ expect(backups.every((b) => b.committed)).toBe(false);
349
+ // …and the run really did not persist anything.
350
+ expect(fs_1.default.existsSync(prefsFile())).toBe(false);
351
+ });
352
+ it('--json still emits a failure envelope when init fails before the step pipeline', async () => {
353
+ const actions = fakeActions([]);
354
+ actions.syncCache = async () => { throw new Error('registry unreachable'); };
355
+ const { runInit } = require('../../src/commands/init');
356
+ const code = await runInit({ cwd: tmpHome, yes: true, json: true, actions });
357
+ expect(code).toBe(2);
358
+ const parsed = JSON.parse(stdout());
359
+ expect(parsed.result).toBe('failed');
360
+ expect(parsed.error).toContain('registry unreachable');
361
+ expect(parsed.steps).toEqual([]);
362
+ expect(parsed.before).toBeNull();
363
+ expect(parsed.transaction.committed).toBe(false);
364
+ });
365
+ it('human mode renders the failed outcome instead of swallowing it', async () => {
366
+ const { runInit } = require('../../src/commands/init');
367
+ const code = await runInit({ cwd: tmpHome, yes: true, actions: actionsWithFailingHook() });
368
+ expect(code).toBe(2);
369
+ expect(stdout()).toContain('AWM · init');
370
+ expect(stdout()).toContain('machine.hook');
371
+ expect(stdout()).toContain('hook boom');
372
+ expect(stderr()).toContain('machine.hook');
373
+ expect(stderr()).toContain('hook boom');
374
+ });
375
+ });
294
376
  });
@@ -35,7 +35,7 @@ function makeRoot() {
35
35
  fs_1.default.mkdirSync(path_1.default.join(mermaid, 'references'));
36
36
  fs_1.default.writeFileSync(path_1.default.join(mermaid, 'references/flow.md'), 'flow reference bytes');
37
37
  const ported = mk('ported', ['name: ported', 'portable: true', 'description: "Ported."']);
38
- fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n');
38
+ fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim, citing `skills/readiness-gate/SKILL.md` on purpose.\n');
39
39
  return root;
40
40
  }
41
41
  describe('runExport (engine end-to-end)', () => {
@@ -60,7 +60,7 @@ describe('runExport (engine end-to-end)', () => {
60
60
  expect(mermaidMd).toContain('defer to the registry');
61
61
  expect(fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/mermaid/references/flow.md'), 'utf-8')).toBe('flow reference bytes');
62
62
  const portedMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/ported/SKILL.md'), 'utf-8');
63
- expect(portedMd).toBe('---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n'); // cero transforms
63
+ expect(portedMd).toBe('---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim, citing `skills/readiness-gate/SKILL.md` on purpose.\n'); // cero transforms
64
64
  });
65
65
  it('rejects an unknown target listing the valid ones', () => {
66
66
  expect(() => (0, export_1.runExport)({ name: 'dev', target: 'hermes', out, roots: [root], zip: okZip }))
@@ -101,4 +101,9 @@ describe('runExport (engine end-to-end)', () => {
101
101
  fs_1.default.rmSync(cwdTmp, { recursive: true, force: true });
102
102
  }
103
103
  });
104
+ it('does not rewrite paths inside a verbatim override', () => {
105
+ (0, export_1.runExport)({ name: 'dev', out, roots: [root], zip: okZip });
106
+ const portedMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/ported/SKILL.md'), 'utf-8');
107
+ expect(portedMd).toContain('citing `skills/readiness-gate/SKILL.md` on purpose');
108
+ });
104
109
  });
@@ -71,4 +71,81 @@ describe('claudeAiTransform', () => {
71
71
  const input = FM(['name: x', 'portable: true', 'description: "Does things." # a comment']);
72
72
  expect(() => (0, transform_1.claudeAiTransform)(input, 'x')).toThrow(/trailing content|comment/i);
73
73
  });
74
+ it('cleans intra-registry paths in the body', () => {
75
+ const md = [
76
+ '---',
77
+ 'name: product-discovery',
78
+ 'version: "1.0.0"',
79
+ 'portable: true',
80
+ 'description: "Explores problem space."',
81
+ '---',
82
+ 'Hand off to `product-brief` (see `skills/product-brief/SKILL.md`) at the end.',
83
+ '',
84
+ ].join('\n');
85
+ const out = (0, transform_1.claudeAiTransform)(md, 'product-discovery');
86
+ expect(out).toContain('Hand off to `product-brief` at the end.');
87
+ expect(out).not.toContain('skills/product-brief/SKILL.md');
88
+ });
89
+ it('leaves the frontmatter block free of body rewriting', () => {
90
+ const md = [
91
+ '---',
92
+ 'name: weird',
93
+ 'description: "Mentions skills/readiness-gate/SKILL.md inside the description."',
94
+ '---',
95
+ 'Body with no paths.',
96
+ '',
97
+ ].join('\n');
98
+ const out = (0, transform_1.claudeAiTransform)(md, 'weird');
99
+ expect(out).toContain('Mentions skills/readiness-gate/SKILL.md inside the description.');
100
+ });
101
+ });
102
+ describe('stripIntraRegistryPaths', () => {
103
+ it('drops a parenthetical whose only content is a see-path', () => {
104
+ expect((0, transform_1.stripIntraRegistryPaths)('crystallize into a `product-brief` (see `skills/product-brief/SKILL.md`) — the handoff.')).toBe('crystallize into a `product-brief` — the handoff.');
105
+ });
106
+ it('drops a bare-path parenthetical without leaving a space before the comma', () => {
107
+ expect((0, transform_1.stripIntraRegistryPaths)('Same discipline as `brainstorming` (see `skills/brainstorming/SKILL.md`), applied at the business level.')).toBe('Same discipline as `brainstorming`, applied at the business level.');
108
+ });
109
+ it('drops a parenthetical holding only a references path', () => {
110
+ expect((0, transform_1.stripIntraRegistryPaths)("conforming to the brief contract's frontmatter (`skills/readiness-gate/references/brief-contract.md`), using:")).toBe("conforming to the brief contract's frontmatter, using:");
111
+ });
112
+ it('rewrites a path in place when the parenthetical carries more text', () => {
113
+ expect((0, transform_1.stripIntraRegistryPaths)('the literal YAML block below (see `skills/readiness-gate/references/brief-contract.md` for the full normative rules).')).toBe("the literal YAML block below (see the `readiness-gate` skill's brief contract reference for the full normative rules).");
114
+ });
115
+ it('rewrites a bare unquoted path in prose', () => {
116
+ expect((0, transform_1.stripIntraRegistryPaths)('shape are normative — see skills/readiness-gate/references/brief-contract.md.')).toBe("shape are normative — see the `readiness-gate` skill's brief contract reference.");
117
+ });
118
+ it('renders a SKILL.md path as a nameless skill reference', () => {
119
+ expect((0, transform_1.stripIntraRegistryPaths)('invoke `skills/readiness-gate/SKILL.md` to certify it.'))
120
+ .toBe('invoke the `readiness-gate` skill to certify it.');
121
+ });
122
+ it('leaves a GitHub URL containing the same path untouched', () => {
123
+ const url = 'see https://github.com/Kodria/awm-baseline-registry/blob/main/skills/readiness-gate/SKILL.md for the source.';
124
+ expect((0, transform_1.stripIntraRegistryPaths)(url)).toBe(url);
125
+ });
126
+ it('leaves a markdown link whose target is a URL untouched', () => {
127
+ const link = '[the gate](https://github.com/Kodria/awm-baseline-registry/blob/main/skills/readiness-gate/references/brief-contract.md)';
128
+ expect((0, transform_1.stripIntraRegistryPaths)(link)).toBe(link);
129
+ });
130
+ it('leaves prose with no intra-registry path byte-identical', () => {
131
+ const body = '# Heading\n\nA body that cites `docs/plans/x.md` and nothing else.\n';
132
+ expect((0, transform_1.stripIntraRegistryPaths)(body)).toBe(body);
133
+ });
134
+ it('handles several paths in one body', () => {
135
+ expect((0, transform_1.stripIntraRegistryPaths)('hand off to `product-brief` (`skills/product-brief/SKILL.md`) then invoke `skills/readiness-gate/SKILL.md`.')).toBe('hand off to `product-brief` then invoke the `readiness-gate` skill.');
136
+ });
137
+ it('rewrites a path immediately following a dropped parenthetical, even with no separator', () => {
138
+ // Regression guard: a naive two-pass implementation (drop parentheticals,
139
+ // THEN rewrite bare paths on the already-mutated string) can splice a
140
+ // URL's trailing "/" directly against this path with zero separator,
141
+ // making the URL-embedding guard misfire and silently skip the rewrite.
142
+ expect((0, transform_1.stripIntraRegistryPaths)('See http://x.com/y/ (see `skills/a/SKILL.md`)skills/b/SKILL.md now.')).toBe('See http://x.com/y/the `b` skill now.');
143
+ });
144
+ it('rewrites a path that is the very first characters of the body', () => {
145
+ // Exercises the pathStart === 0 boundary in isEmbeddedInUrl (pathStart > 0
146
+ // must be false, not true, when the path opens the string) — every other
147
+ // test in this file has text preceding the path, so this was untested.
148
+ expect((0, transform_1.stripIntraRegistryPaths)('skills/readiness-gate/SKILL.md is required.'))
149
+ .toBe('the `readiness-gate` skill is required.');
150
+ });
74
151
  });
@@ -0,0 +1,109 @@
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
+ // stepCache vs. syncRegistries()'s RESULT-shaped failures.
7
+ //
8
+ // `syncRegistries()` never throws on a per-registry failure — it returns
9
+ // `{ action: 'error', error }` for that registry and keeps going. stepCache
10
+ // used to `await` it and report a blanket 'applied', so a registry that never
11
+ // landed on disk degraded silently into some later step's failure with its own
12
+ // cause already gone.
13
+ //
14
+ // Isolated in its own file (not steps.test.ts) because these assertions reach
15
+ // `listRegistries()`, which resolves AWM_HOME at module require time — the env
16
+ // override therefore has to happen before the module is required, which means
17
+ // `jest.resetModules()` + `require`, not a static import.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ describe('stepCache — registry sync error results', () => {
22
+ let tmpHome;
23
+ let originalHome;
24
+ let originalAwmHome;
25
+ beforeEach(() => {
26
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stepcache-'));
27
+ originalHome = process.env.HOME;
28
+ originalAwmHome = process.env.AWM_HOME;
29
+ process.env.HOME = tmpHome;
30
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
31
+ jest.resetModules();
32
+ });
33
+ afterEach(() => {
34
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
35
+ if (originalHome === undefined)
36
+ delete process.env.HOME;
37
+ else
38
+ process.env.HOME = originalHome;
39
+ if (originalAwmHome === undefined)
40
+ delete process.env.AWM_HOME;
41
+ else
42
+ process.env.AWM_HOME = originalAwmHome;
43
+ });
44
+ const awmHome = () => process.env.AWM_HOME;
45
+ /** Declares `names` in registries.json; creates a content root only for `withContent`. */
46
+ function configureRegistries(names, withContent) {
47
+ fs_1.default.mkdirSync(awmHome(), { recursive: true });
48
+ fs_1.default.writeFileSync(path_1.default.join(awmHome(), 'registries.json'), JSON.stringify(names.map((name) => ({ name, remote: `https://example.com/${name}.git` })), null, 2));
49
+ for (const name of withContent) {
50
+ const skills = path_1.default.join(awmHome(), 'registries', name, 'skills');
51
+ fs_1.default.mkdirSync(skills, { recursive: true });
52
+ fs_1.default.writeFileSync(path_1.default.join(skills, 'placeholder.md'), '# placeholder\n');
53
+ }
54
+ }
55
+ /** InitDeps just complete enough for stepCache: a machine with no registry cache yet. */
56
+ function deps(results) {
57
+ const ctx = {
58
+ machine: {
59
+ registryCache: { present: false },
60
+ hook: { present: true, degraded: false },
61
+ devCore: { present: true, brokenLinks: [] },
62
+ ambient: { wanted: [], installed: [] },
63
+ contextInjection: [],
64
+ globalSkills: { valid: [], repairable: [], dead: [] },
65
+ },
66
+ project: null,
67
+ };
68
+ const actions = { syncCache: async () => results };
69
+ return {
70
+ cwd: tmpHome, ctx, bundles: [], agent: 'claude-code', enabledAgents: ['claude-code'],
71
+ installMethod: 'symlink', registryRoot: '', contentDir: '', sensorPacksRoot: '',
72
+ confirmExtensions: async (p) => p, actions,
73
+ };
74
+ }
75
+ async function run(results) {
76
+ const { stepCache } = require('../../../src/core/init/steps');
77
+ return stepCache(deps(results));
78
+ }
79
+ it('fails, naming the registry, when a sync error left no content on disk', async () => {
80
+ configureRegistries(['baseline'], []);
81
+ const r = await run([{ name: 'baseline', action: 'error', error: 'could not clone' }]);
82
+ expect(r.action).toBe('failed');
83
+ expect(r.error).toContain('baseline');
84
+ expect(r.error).toContain('could not clone');
85
+ });
86
+ it('stays applied but records the error when content is already on disk', async () => {
87
+ configureRegistries(['baseline'], ['baseline']);
88
+ const r = await run([{ name: 'baseline', action: 'error', error: 'pull timed out' }]);
89
+ expect(r.action).toBe('applied');
90
+ expect(r.detail).toContain('baseline');
91
+ expect(r.detail).toContain('pull timed out');
92
+ });
93
+ it('fails on a secondary registry that never landed, even when the base one synced', async () => {
94
+ configureRegistries(['baseline', 'documentation'], ['baseline']);
95
+ const r = await run([
96
+ { name: 'baseline', action: 'pulled', version: 'v1.0.0' },
97
+ { name: 'documentation', action: 'error', error: 'host unreachable' },
98
+ ]);
99
+ expect(r.action).toBe('failed');
100
+ expect(r.error).toContain('documentation');
101
+ expect(r.error).not.toContain('baseline');
102
+ });
103
+ it('reports applied with no detail when every registry synced', async () => {
104
+ configureRegistries(['baseline'], ['baseline']);
105
+ const r = await run([{ name: 'baseline', action: 'pulled', version: 'v1.0.0' }]);
106
+ expect(r.action).toBe('applied');
107
+ expect(r.detail).toBeUndefined();
108
+ });
109
+ });
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cluster_1 = require("../../../src/core/ledger/cluster");
4
+ describe('normalizeTokens', () => {
5
+ test('lowercases and splits slugs on non-alphanumeric boundaries', () => {
6
+ expect([...(0, cluster_1.normalizeTokens)('Validator-Skips_Agents.References')].sort())
7
+ .toEqual(['agents', 'references', 'skips', 'validator']);
8
+ });
9
+ test('drops tokens shorter than two characters', () => {
10
+ expect([...(0, cluster_1.normalizeTokens)('a b ok x')].sort()).toEqual(['ok']);
11
+ });
12
+ test('drops stopwords so they cannot inflate affinity', () => {
13
+ expect([...(0, cluster_1.normalizeTokens)('the gate walks only the skills')].sort())
14
+ .toEqual(['gate', 'skills', 'walks']);
15
+ });
16
+ test('merges tokens across every text it is given', () => {
17
+ expect([...(0, cluster_1.normalizeTokens)('gate-walks', 'skills dir')].sort())
18
+ .toEqual(['dir', 'gate', 'skills', 'walks']);
19
+ });
20
+ });
21
+ describe('affinity', () => {
22
+ test('is the overlap coefficient, so a contained short set scores 1', () => {
23
+ const short = (0, cluster_1.normalizeTokens)('validator gate');
24
+ const long = (0, cluster_1.normalizeTokens)('validator gate walks the skills directory only');
25
+ expect((0, cluster_1.affinity)(short, long)).toBe(1);
26
+ });
27
+ test('is 0 for disjoint sets', () => {
28
+ expect((0, cluster_1.affinity)((0, cluster_1.normalizeTokens)('alpha slug'), (0, cluster_1.normalizeTokens)('beta timeout'))).toBe(0);
29
+ });
30
+ test('is 0 when either side is empty', () => {
31
+ expect((0, cluster_1.affinity)(new Set(), (0, cluster_1.normalizeTokens)('alpha'))).toBe(0);
32
+ });
33
+ });
34
+ describe('normalizeRef', () => {
35
+ test('strips the line number and keeps the file locus', () => {
36
+ expect((0, cluster_1.normalizeRef)('scripts/validate-portability.mjs:41')).toBe('scripts/validate-portability.mjs');
37
+ });
38
+ test('accepts a bare filename with an extension', () => {
39
+ expect((0, cluster_1.normalizeRef)('split.ts:12')).toBe('split.ts');
40
+ });
41
+ test('rejects a non-file ref: a whole PR is not a defect locus', () => {
42
+ expect((0, cluster_1.normalizeRef)('PR #16')).toBeNull();
43
+ });
44
+ test('rejects a URL, whose pre-colon portion carries no locus', () => {
45
+ expect((0, cluster_1.normalizeRef)('https://github.com/Kodria/agentic-workflow/pull/15')).toBeNull();
46
+ });
47
+ test('returns null for a missing ref', () => {
48
+ expect((0, cluster_1.normalizeRef)(undefined)).toBeNull();
49
+ });
50
+ });
51
+ function entry(over = {}) {
52
+ return {
53
+ ts: '2026-07-25T00:00:00.000Z',
54
+ branch: 'feat-x',
55
+ phase: 'post-qa',
56
+ source_skill: 'post-implementation-qa',
57
+ polarity: 'finding',
58
+ class: 'logica',
59
+ signature: 'some-finding',
60
+ severity: 'important',
61
+ desc: 'something is wrong',
62
+ ref: 'src/some.ts:1',
63
+ ...over,
64
+ };
65
+ }
66
+ describe('clusterEntries — exact signature floor', () => {
67
+ test('groups identical signatures and honours min', () => {
68
+ const clusters = (0, cluster_1.clusterEntries)([
69
+ entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
70
+ entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
71
+ entry({ signature: 'solo', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
72
+ ], 2);
73
+ expect(clusters).toHaveLength(1);
74
+ expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
75
+ });
76
+ test('unions identical signatures even across unrelated files and descriptions', () => {
77
+ const clusters = (0, cluster_1.clusterEntries)([
78
+ entry({ signature: 'same-slug', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
79
+ entry({ signature: 'same-slug', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
80
+ ], 2);
81
+ expect(clusters).toHaveLength(1);
82
+ expect(clusters[0].count).toBe(2);
83
+ });
84
+ test('unions identical signatures regardless of polarity, as before', () => {
85
+ const clusters = (0, cluster_1.clusterEntries)([
86
+ entry({ signature: 'same-slug', polarity: 'finding' }),
87
+ entry({ signature: 'same-slug', polarity: 'win' }),
88
+ ], 2);
89
+ expect(clusters).toHaveLength(1);
90
+ });
91
+ });
92
+ describe('clusterEntries — convergence on a shared file', () => {
93
+ // El caso real del 2026-07-25: tres lentes aisladas, tres slugs distintos,
94
+ // un solo defecto (el gate de portabilidad recorría solo skills/).
95
+ const threeLenses = () => [
96
+ entry({
97
+ signature: 'validator-skips-agents-references',
98
+ desc: 'the portability validator never walks agents/ references',
99
+ ref: 'scripts/validate-portability.mjs:41',
100
+ source_skill: 'fidelity-lens',
101
+ }),
102
+ entry({
103
+ signature: 'validator-scope-skills-only',
104
+ desc: 'validator scope covers skills only, missing sibling trees',
105
+ ref: 'scripts/validate-portability.mjs:41',
106
+ source_skill: 'logic-lens',
107
+ }),
108
+ entry({
109
+ signature: 'gate-walks-skills-only',
110
+ desc: 'the gate walks skills and nothing else',
111
+ ref: 'scripts/validate-portability.mjs:58',
112
+ source_skill: 'robustness-lens',
113
+ }),
114
+ ];
115
+ test('clusters three independent lenses on one defect', () => {
116
+ const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
117
+ expect(clusters).toHaveLength(1);
118
+ expect(clusters[0].count).toBe(3);
119
+ });
120
+ test('labels the cluster convergent and lists every distinct signature', () => {
121
+ const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
122
+ expect(clusters[0].kind).toBe('convergent');
123
+ expect(clusters[0].signatures).toEqual([
124
+ 'gate-walks-skills-only',
125
+ 'validator-scope-skills-only',
126
+ 'validator-skips-agents-references',
127
+ ]);
128
+ });
129
+ test('does NOT merge two unrelated defects that happen to share a file', () => {
130
+ const clusters = (0, cluster_1.clusterEntries)([
131
+ entry({
132
+ signature: 'gate-walks-skills-only',
133
+ desc: 'the gate walks skills and nothing else',
134
+ ref: 'scripts/validate-portability.mjs:58',
135
+ }),
136
+ entry({
137
+ signature: 'exit-code-swallowed',
138
+ desc: 'process exits zero after a failed assertion',
139
+ ref: 'scripts/validate-portability.mjs:58',
140
+ }),
141
+ ], 2);
142
+ expect(clusters).toEqual([]);
143
+ });
144
+ test('does NOT merge a win with a finding on the strength of a shared file', () => {
145
+ const clusters = (0, cluster_1.clusterEntries)([
146
+ entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills only', ref: 'a.mjs:1', polarity: 'finding' }),
147
+ entry({ signature: 'gate-walks-skills-fix', desc: 'the gate walks skills only, now fixed', ref: 'a.mjs:1', polarity: 'win' }),
148
+ ], 2);
149
+ expect(clusters).toEqual([]);
150
+ });
151
+ test('a non-file ref contributes no clustering signal', () => {
152
+ const clusters = (0, cluster_1.clusterEntries)([
153
+ entry({ signature: 'alpha-defect', desc: 'alpha slug mismatch', ref: 'PR #16' }),
154
+ entry({ signature: 'beta-defect', desc: 'beta timeout on retry', ref: 'PR #16' }),
155
+ ], 2);
156
+ expect(clusters).toEqual([]);
157
+ });
158
+ });
159
+ describe('clusterEntries — lexical convergence without a shared file', () => {
160
+ test('clusters near-identical wording across different files', () => {
161
+ const clusters = (0, cluster_1.clusterEntries)([
162
+ entry({ signature: 'vacuous-test-asserts-nothing', desc: 'test asserts nothing meaningful', ref: 'tests/a.test.ts:3' }),
163
+ entry({ signature: 'vacuous-test-asserts-nothing-either', desc: 'test asserts nothing meaningful', ref: 'tests/b.test.ts:7' }),
164
+ ], 2);
165
+ expect(clusters).toHaveLength(1);
166
+ expect(clusters[0].kind).toBe('convergent');
167
+ });
168
+ test('leaves weakly-related findings on different files apart', () => {
169
+ const clusters = (0, cluster_1.clusterEntries)([
170
+ entry({ signature: 'validator-scope-skills-only', desc: 'validator scope covers skills', ref: 'src/a.ts:1' }),
171
+ entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills', ref: 'src/b.ts:1' }),
172
+ ], 2);
173
+ expect(clusters).toEqual([]);
174
+ });
175
+ test('merges A and C transitively through B, though A and C alone would not cluster', () => {
176
+ const chain = [
177
+ entry({ signature: 'aaa-marker', desc: 'alpha beta gamma delta', ref: 'src/a.ts:1' }),
178
+ entry({ signature: 'bbb-marker', desc: 'beta gamma delta epsilon', ref: 'src/b.ts:1' }),
179
+ entry({ signature: 'ccc-marker', desc: 'gamma delta epsilon zeta', ref: 'src/c.ts:1' }),
180
+ ];
181
+ const clusters = (0, cluster_1.clusterEntries)(chain, 2);
182
+ expect(clusters).toHaveLength(1);
183
+ expect(clusters[0]).toMatchObject({ count: 3, kind: 'convergent' });
184
+ expect(clusters[0].signatures).toEqual(['aaa-marker', 'bbb-marker', 'ccc-marker']);
185
+ // Control: without the bridging entry, A and C do not satisfy the
186
+ // threshold on their own (affinity 0.5 < LEXICAL_AFFINITY_MIN 0.6) —
187
+ // proving the merge above genuinely relies on transitive closure
188
+ // through B, not a coincidence of the threshold being lenient.
189
+ const withoutBridge = (0, cluster_1.clusterEntries)([chain[0], chain[2]], 2);
190
+ expect(withoutBridge).toEqual([]);
191
+ });
192
+ });
193
+ describe('clusterEntries — representative and ordering', () => {
194
+ test('representative signature is the most frequent one', () => {
195
+ const clusters = (0, cluster_1.clusterEntries)([
196
+ entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
197
+ entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
198
+ entry({ signature: 'alpha-rare', desc: 'gate walks skills only, second lens', ref: 'a.mjs:1' }),
199
+ ], 2);
200
+ expect(clusters).toHaveLength(1);
201
+ expect(clusters[0].signature).toBe('zeta-frequent');
202
+ expect(clusters[0].count).toBe(3);
203
+ });
204
+ test('ties on frequency resolve to the lexicographically first signature', () => {
205
+ const clusters = (0, cluster_1.clusterEntries)([
206
+ entry({ signature: 'zeta-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
207
+ entry({ signature: 'alpha-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
208
+ ], 2);
209
+ expect(clusters[0].signature).toBe('alpha-lens');
210
+ });
211
+ test('sorts by count desc, then convergent before exact, then signature asc', () => {
212
+ const clusters = (0, cluster_1.clusterEntries)([
213
+ // convergent cluster of 2 on one file
214
+ entry({ signature: 'mid-one', desc: 'gate walks skills only', ref: 'mid.mjs:1' }),
215
+ entry({ signature: 'mid-two', desc: 'gate walks skills only, other lens', ref: 'mid.mjs:1' }),
216
+ // exact cluster of 2, unrelated
217
+ entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
218
+ entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
219
+ // exact cluster of 3, unrelated — highest count wins regardless of kind
220
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
221
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
222
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
223
+ ], 2);
224
+ expect(clusters.map((c) => [c.signature, c.count, c.kind])).toEqual([
225
+ ['top-dup', 3, 'exact'],
226
+ ['mid-one', 2, 'convergent'],
227
+ ['exact-dup', 2, 'exact'],
228
+ ]);
229
+ });
230
+ test('an empty ledger yields no clusters', () => {
231
+ expect((0, cluster_1.clusterEntries)([], 2)).toEqual([]);
232
+ });
233
+ test('min <= 0 still returns every group, including size-1 groups', () => {
234
+ const solo = entry({ signature: 'lonely', desc: 'nobody else mentions this', ref: 'z.ts:1' });
235
+ expect((0, cluster_1.clusterEntries)([solo], 0)).toEqual([
236
+ { signature: 'lonely', count: 1, kind: 'exact', signatures: ['lonely'], entries: [solo] },
237
+ ]);
238
+ expect((0, cluster_1.clusterEntries)([solo], -5)).toEqual((0, cluster_1.clusterEntries)([solo], 0));
239
+ });
240
+ });
@@ -54,6 +54,17 @@ describe('ledger store — add/list', () => {
54
54
  expect(got).toHaveLength(2);
55
55
  expect(got.map(e => e.signature)).toEqual(['public-fn-returns-infinity', 's2']);
56
56
  });
57
+ test('listEntries skips a shape-invalid (but syntactically valid) entry without throwing', () => {
58
+ const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
59
+ fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
60
+ const missingDesc = JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'missing-desc', severity: 'minor', ref: 'a.ts:1' });
61
+ const nullDesc = JSON.stringify({ ...entry(), desc: null });
62
+ const numericSignature = JSON.stringify({ ...entry(), signature: 42 });
63
+ fs_1.default.writeFileSync(p, [missingDesc, nullDesc, numericSignature, JSON.stringify(entry({ signature: 's2' }))].join('\n') + '\n');
64
+ const got = (0, store_1.listEntries)(cwd, 'feat-x');
65
+ expect(got).toHaveLength(1);
66
+ expect(got[0].signature).toBe('s2');
67
+ });
57
68
  });
58
69
  describe('ledger store — detectBranch', () => {
59
70
  test('falls back to _no-branch outside a git repo', () => {
@@ -84,10 +95,10 @@ describe('ledger store — recurring', () => {
84
95
  test('groups by signature and reports clusters with count >= min', () => {
85
96
  (0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
86
97
  (0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
87
- (0, store_1.addEntry)(cwd, entry({ signature: 'solo' }));
98
+ (0, store_1.addEntry)(cwd, entry({ signature: 'solo', ref: 'src/other.ts:3', desc: 'pagination cursor skips a page' }));
88
99
  const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
89
100
  expect(clusters).toHaveLength(1);
90
- expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2 });
101
+ expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
91
102
  expect(clusters[0].entries).toHaveLength(2);
92
103
  });
93
104
  test('respects --min: count 2 is excluded when min is 3', () => {
@@ -96,14 +107,38 @@ describe('ledger store — recurring', () => {
96
107
  expect((0, store_1.recurring)(cwd, 'feat-x', 3)).toEqual([]);
97
108
  });
98
109
  test('sorts clusters by count descending', () => {
99
- (0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
100
- (0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
101
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
102
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
103
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
110
+ (0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
111
+ (0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
112
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
113
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
114
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
104
115
  const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
105
116
  expect(clusters.map(c => c.signature)).toEqual(['b', 'a']);
106
117
  });
118
+ test('reports independent lenses on one file as a single convergent cluster', () => {
119
+ (0, store_1.addEntry)(cwd, entry({
120
+ signature: 'validator-scope-skills-only',
121
+ desc: 'validator scope covers skills only',
122
+ ref: 'scripts/validate-portability.mjs:41',
123
+ }));
124
+ (0, store_1.addEntry)(cwd, entry({
125
+ signature: 'gate-walks-skills-only',
126
+ desc: 'the gate walks skills and nothing else',
127
+ ref: 'scripts/validate-portability.mjs:58',
128
+ }));
129
+ const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
130
+ expect(clusters).toHaveLength(1);
131
+ expect(clusters[0]).toMatchObject({ count: 2, kind: 'convergent' });
132
+ expect(clusters[0].signatures).toEqual(['gate-walks-skills-only', 'validator-scope-skills-only']);
133
+ });
134
+ test('recurring does not crash on a shape-invalid entry mixed into an otherwise valid ledger', () => {
135
+ (0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
136
+ (0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
137
+ const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
138
+ fs_1.default.appendFileSync(p, JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'no-desc', severity: 'minor', ref: 'b.ts:1' }) + '\n');
139
+ expect(() => (0, store_1.recurring)(cwd, 'feat-x', 2)).not.toThrow();
140
+ expect((0, store_1.recurring)(cwd, 'feat-x', 2)).toEqual([expect.objectContaining({ signature: 'dup', count: 2 })]);
141
+ });
107
142
  });
108
143
  describe('ledger store — archive', () => {
109
144
  let cwd;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"