agentic-workflow-manager 6.4.0 → 6.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,13 +52,30 @@ function runAddBundleCore(options, prefs, bundles, deps = {}) {
52
52
  console.error(picocolors_1.default.red('No project root found (need a .git/, package.json, or .awm/profile.json here). Run inside a project, or pass --global.'));
53
53
  return { code: 1, selectedAgents };
54
54
  }
55
+ // `--method` used to be accepted here and silently discarded — this branch
56
+ // hardcoded 'symlink' regardless of what was passed. Since D-001 made bundle
57
+ // names the ONLY thing `add [name]` resolves against, this is the sole live
58
+ // path for any real invocation; the interactive/`--all` path below still has
59
+ // its own (correct) method resolution, but a valid bundle name never reaches
60
+ // it. Found running the issue #55 Windows playbook's WIN-02: `--method copy`
61
+ // on native Windows still produced a Junction, because 'symlink' was the
62
+ // only value that ever actually reached the installer. The no-flag default
63
+ // stays 'symlink' (unchanged) — only the explicit-override case was broken.
64
+ let methodVal = 'symlink';
65
+ if (options.method) {
66
+ if (options.method !== 'symlink' && options.method !== 'copy') {
67
+ console.error(picocolors_1.default.red(`Invalid method "${options.method}". Use: symlink or copy.`));
68
+ return { code: 1, selectedAgents };
69
+ }
70
+ methodVal = options.method;
71
+ }
55
72
  let result;
56
73
  try {
57
74
  result = d.addBundle({
58
75
  bundleName: matchedBundle.name,
59
76
  bundles,
60
77
  agents: selectedAgents,
61
- method: 'symlink',
78
+ method: methodVal,
62
79
  projectRoot: projectRoot ?? cwd,
63
80
  scopeOverride,
64
81
  });
@@ -128,8 +128,17 @@ async function runInit(opts = {}) {
128
128
  // Fires at most once per `awm init` run (native Windows only) — this is
129
129
  // the single emission point; `renderReport`/`renderInitOutcome` below do
130
130
  // NOT also embed it (that used to triple-fire the same text: once here,
131
- // once in the "Initial state" render, once in "Final state").
132
- (0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
131
+ // once in the "Initial state" render, once in "Final state"). In `--json`
132
+ // mode it goes to stderr instead of stdout: stdout must stay pure JSON for
133
+ // `awm init --yes --json > init.json` (documented in core-acceptance.md
134
+ // CORE-03) — a stray banner ahead of the `{` broke that contract on every
135
+ // native-Windows `--json` run.
136
+ if (opts.json) {
137
+ (0, paths_1.noteWindowsCaveat)((m) => process.stderr.write(picocolors_1.default.dim(`ℹ ${m}`) + '\n'));
138
+ }
139
+ else {
140
+ (0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
141
+ }
133
142
  const cwd = opts.cwd ?? process.cwd();
134
143
  const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
135
144
  // R2: gate BEFORE anything is read or written — an unsupported provider
@@ -22,6 +22,8 @@ function evaluateEvidence(state, fingerprintNow, scope) {
22
22
  reasons.push({ category: 'pending-task', detail: `task ${t.id} en ${t.status}` });
23
23
  }
24
24
  }
25
+ const allPlans = [...tasksInScope.flatMap((t) => t.verificationPlan), ...state.cycleVerificationPlan];
26
+ const presentKinds = new Set(allPlans.map((i) => i.kind));
25
27
  if (scope.requireGlobalKinds) {
26
28
  if (state.cycleVerificationPlan.length === 0) {
27
29
  reasons.push({ category: 'empty-cycle-plan', detail: 'CycleVerificationPlan vacio: un ciclo sin plan de cierre jamas certifica (R1.4b)' });
@@ -31,16 +33,28 @@ function evaluateEvidence(state, fingerprintNow, scope) {
31
33
  reasons.push({ category: 'missing-verifier', detail: `CycleVerificationPlan requiere '${required}'` });
32
34
  }
33
35
  }
34
- }
35
- // Verificadores requeridos por la config REAL del repo (watch --init):
36
- // cada kind requerido debe existir en algun plan (R1.4b, R3.6).
37
- const allPlans = [...tasksInScope.flatMap((t) => t.verificationPlan), ...state.cycleVerificationPlan];
38
- const presentKinds = new Set(allPlans.map((i) => i.kind));
39
- for (const mechanical of ['test', 'sensors']) {
40
- if (!state.requiredVerifiers.includes(mechanical)) {
41
- reasons.push({ category: 'missing-verifier', detail: `el repo no tiene '${mechanical}' configurado; no se certifica por ausencia (R3.6)` });
36
+ // R3.6 es una afirmación sobre la CONFIG DEL REPO ("este repo no tiene test/sensors,
37
+ // así que no se certifica por ausencia"), y por eso vive del lado global del scope.
38
+ //
39
+ // Estaba afuera del guard, y eso hacía imposible que un track se congelara NUNCA:
40
+ // `computeTrackGate` pasa `requireGlobalKinds: false` precisamente porque el journal
41
+ // de un track no evalúa los kinds globales, pero heredaba igual esta exigencia — y
42
+ // `requiredVerifiers` de un journal de track es SIEMPRE `[]`, porque
43
+ // `initTrackJournal` llama a `initJournal` y no a `initWatch`, así que la detección
44
+ // mecánica nunca corre ahí. Gate local rojo para siempre => `attemptFreeze` devolvía
45
+ // `continue` en cada tick => la cohorte no podía congelarse => sin C3 no hay merge, y
46
+ // por lo tanto no hay `COMPLETE`. Se observó en la certificación con supervisor vivo:
47
+ // `alpha` con el freeze pedido, su worktree limpio, cero jobs vivos, y estas dos
48
+ // razones como único motivo.
49
+ for (const mechanical of ['test', 'sensors']) {
50
+ if (!state.requiredVerifiers.includes(mechanical)) {
51
+ reasons.push({ category: 'missing-verifier', detail: `el repo no tiene '${mechanical}' configurado; no se certifica por ausencia (R3.6)` });
52
+ }
42
53
  }
43
54
  }
55
+ // Este otro sí es por scope: "lo que el repo exige tiene que estar en ALGÚN plan del
56
+ // scope". En un journal de track es un no-op (`requiredVerifiers` vacío), y si algún día
57
+ // se poblara, exigirlo sobre los planes del track es lo correcto.
44
58
  for (const required of state.requiredVerifiers) {
45
59
  if (!presentKinds.has(required)) {
46
60
  reasons.push({ category: 'missing-verifier', detail: `el repo exige verificador '${required}' y ningun plan lo contiene` });
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.emitTrackRequest = emitTrackRequest;
7
+ exports.emitFinalizeRequest = emitFinalizeRequest;
7
8
  // Emisores de requests de track (R6.1): `add`/`join`/`remove` jamas mutan
8
9
  // Git ni el journal directamente — el unico efecto observable es publicar
9
10
  // una request inmutable que el supervisor del plan consume despues (mismo
@@ -13,6 +14,7 @@ exports.emitTrackRequest = emitTrackRequest;
13
14
  // intents distintos sobre el mismo track jamas colisionan.
14
15
  const crypto_1 = __importDefault(require("crypto"));
15
16
  const requests_1 = require("../../core/journal/requests");
17
+ const git_1 = require("../../core/tracks/git");
16
18
  function emitTrackRequest(repoRoot, branch, generationToken, kind, trackId) {
17
19
  if (trackId.length === 0)
18
20
  throw new Error('trackId obligatorio');
@@ -24,3 +26,41 @@ function emitTrackRequest(repoRoot, branch, generationToken, kind, trackId) {
24
26
  payload,
25
27
  });
26
28
  }
29
+ /**
30
+ * `track-finalize-request`: el autoreporte del controller del PLAN — "corrí la QA global
31
+ * sobre el HEAD ya mergeado de todos los tracks, corregí los hallazgos y comiteé; este es
32
+ * mi HEAD limpio".
33
+ *
34
+ * Es PLAN-scoped y no lleva `trackId`, por eso no pasa por `emitTrackRequest`.
35
+ *
36
+ * Sin este emisor la cohorte no tenía salida: `runRequestGlobalQa` (watch/tracks.ts) espera
37
+ * `s.qaFinalizeRequested`, que solo se puebla aplicando esta request, y NADA en el producto
38
+ * la emitía — el único lugar del repo que la producía era el harness de tests, llamando
39
+ * `emitRequest` directo. Es decir, `COMPLETE` era alcanzable solo desde adentro de los
40
+ * tests: en producción, con todos los tracks en `MERGED_UNVERIFIED`, el supervisor esperaba
41
+ * para siempre una evidencia que ningún controller tenía forma de producir.
42
+ *
43
+ * El HEAD se LEE del repo, no se recibe por flag: el controller reporta el suyo, y un flag
44
+ * solo abriría la puerta a reportar un HEAD ajeno. El árbol sucio se rechaza acá, en el
45
+ * borde, para que el fallo diga qué falta en vez de manifestarse como una espera muda — pero
46
+ * eso NO reemplaza la re-verificación independiente del supervisor (HEAD real + árbol
47
+ * limpio en el momento de consumir la request), que sigue siendo la autoridad fail-closed.
48
+ * El chequeo del borde es un diagnóstico, nunca la prueba.
49
+ */
50
+ function emitFinalizeRequest(repoRoot, branch, generationToken) {
51
+ const dirty = (0, git_1.dirtyPaths)(repoRoot);
52
+ if (dirty.length > 0) {
53
+ throw new Error(`el arbol del plan tiene ${dirty.length} ruta(s) sin comitear (${dirty.slice(0, 3).join(', ')}${dirty.length > 3 ? ', …' : ''}): `
54
+ + 'la QA global se reporta sobre un HEAD limpio — comiteá las correcciones antes de finalizar');
55
+ }
56
+ const qaHeadSha = (0, git_1.headSha)(repoRoot);
57
+ // La key liga el SHA: re-reportar el mismo HEAD colapsa (retry seguro), y un HEAD nuevo
58
+ // — porque la QA encontró algo más y se comiteó otra corrección — es una request nueva.
59
+ const emitted = (0, requests_1.emitRequest)(repoRoot, branch, {
60
+ kind: 'track-finalize-request', generationToken,
61
+ idempotencyKey: crypto_1.default.createHash('sha256')
62
+ .update(`track-finalize-request\0${branch}\0${qaHeadSha}`).digest('hex'),
63
+ payload: { qaHeadSha },
64
+ });
65
+ return { ...emitted, qaHeadSha };
66
+ }
@@ -85,8 +85,33 @@ function registerTrackCommand(program) {
85
85
  const r = (0, emit_1.emitTrackRequest)(repo, branch, opts.generation, 'track-join-request', trackId);
86
86
  process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey }, null, 2) + '\n');
87
87
  });
88
+ track.command('finalize')
89
+ .description('emite track-finalize-request — autoreporte de QA global del controller del plan sobre el HEAD ya mergeado (R7.2)')
90
+ .requiredOption('--generation <token>', 'token de la generacion vigente')
91
+ .action((opts) => {
92
+ const repo = process.cwd();
93
+ const branch = branchOf(repo);
94
+ // Plan-scoped, igual que `list`/`status`: la QA global corre sobre el HEAD del
95
+ // PLAN con todos los tracks ya mergeados. Emitirla desde el worktree de un track
96
+ // escribiría el autoreporte en el journal equivocado, donde nadie lo espera.
97
+ assertPlanCwd(repo, branch);
98
+ let r;
99
+ try {
100
+ r = (0, emit_1.emitFinalizeRequest)(repo, branch, opts.generation);
101
+ }
102
+ catch (e) {
103
+ failGuard(`track finalize: ${e.message}`);
104
+ }
105
+ process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey, qaHeadSha: r.qaHeadSha }, null, 2) + '\n');
106
+ });
107
+ // NO IMPLEMENTADO del lado del supervisor: no hay handler para `track-teardown-request`,
108
+ // así que la request se emite y el supervisor la RECHAZA (`request-rejected-invalid`).
109
+ // La descripción lo dice en vez de prometer un desmantelamiento que no ocurre — hasta
110
+ // este release afirmaba que el supervisor desmantelaba worktree/rama, y no lo hacía.
111
+ // El teardown que sí funciona es el automático de la degradación a serial
112
+ // (`begin-teardown` desde `FALLBACK_PENDING`), que no pasa por este verbo.
88
113
  track.command('remove')
89
- .description('emite track-teardown-request el supervisor del plan desmantela worktree/rama (R6.1)')
114
+ .description('NO IMPLEMENTADO: emite track-teardown-request, que el supervisor todavía no sabe aplicar y rechaza')
90
115
  .requiredOption('--generation <token>', 'token de la generacion vigente')
91
116
  .argument('<trackId>')
92
117
  .action((trackId, opts) => {
@@ -18,6 +18,11 @@ const atomic_file_1 = require("../../core/atomic-file");
18
18
  const store_1 = require("../../core/journal/store");
19
19
  const paths_1 = require("../../core/journal/paths");
20
20
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
+ /** Fases desde las cuales el track YA tiene trabajo propio que atender, así que su
22
+ * supervisor debe estar corriendo. Es el conjunto monótono "ACTIVE o posterior": el plan
23
+ * puede haber avanzado varias fases entre dos polls de este wrapper, y perderse el arranque
24
+ * por no haber visto la fase exacta deja al track sin nadie que consuma sus requests. */
25
+ const LAUNCH_PHASES = ['ACTIVE', 'JOIN_REQUESTED', 'FROZEN', 'JOIN_INTENT', 'MERGED_UNVERIFIED', 'JOINED'];
21
26
  function localBranch(worktreePath) {
22
27
  return (0, child_process_2.execFileSync)('git', ['branch', '--show-current'], { cwd: worktreePath, encoding: 'utf8', stdio: process_1.EXEC_STDIO }).trim();
23
28
  }
@@ -67,7 +72,18 @@ async function runSupervisorWrapper(opts) {
67
72
  const r = (0, store_1.readJournal)(opts.planRoot, opts.planBranch);
68
73
  if (!r.corrupt && r.state !== null) {
69
74
  const ref = r.state.tracks?.find((t) => t.trackId === opts.trackId);
70
- if (ref?.phase === 'ACTIVE')
75
+ // MONOTONO, nunca la igualdad exacta con `ACTIVE`. Esperar a ver una fase
76
+ // puntual con un poll de 2 s es una carrera: el plan puede cruzar `ACTIVE` entre
77
+ // dos lecturas y este wrapper quedarse esperando para siempre una fase que ya
78
+ // pasó — sin lanzar `awm watch`, así que las requests cross-journal del plan
79
+ // (empezando por `track-freeze-request`) no las consume nadie y la cohorte se
80
+ // traba en el freeze.
81
+ //
82
+ // Pasó de verdad: al reparar el join, un track podía ir `ARMED -> ACTIVE ->
83
+ // JOIN_REQUESTED` dentro de un mismo `reconcileTracks`, y la ventana `ACTIVE`
84
+ // dejó de ser observable. La igualdad exacta solo funcionaba mientras el join
85
+ // estaba roto y los tracks se quedaban parados en `ACTIVE`.
86
+ if (ref !== undefined && LAUNCH_PHASES.includes(ref.phase))
71
87
  break;
72
88
  // BLOCKED/REMOVED: el plan ya decidió que este track no avanza —
73
89
  // el wrapper nunca lanza `awm watch` para un track que el plan
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.updateOutro = updateOutro;
6
7
  exports.runUpdateCore = runUpdateCore;
7
8
  // src/commands/update.ts
8
9
  //
@@ -24,6 +25,7 @@ const paths_1 = require("../core/paths");
24
25
  const config_1 = require("../utils/config");
25
26
  const agent_targets_1 = require("../core/agent-targets");
26
27
  const registries_1 = require("../core/registries");
28
+ const paths_2 = require("../core/paths");
27
29
  const regenerate_1 = require("../core/context/regenerate");
28
30
  const reconciliation_1 = require("../core/reconciliation");
29
31
  const install_transaction_1 = require("../core/install-transaction");
@@ -35,12 +37,34 @@ const defaultDeps = {
35
37
  planReconciliation: reconciliation_1.planReconciliation,
36
38
  applyInstallPlan: install_transaction_1.applyInstallPlan,
37
39
  resyncInstalledHooks: resync_1.resyncInstalledHooks,
38
- offerSelfUpdate: async () => {
40
+ offerSelfUpdate: async (mode) => {
39
41
  // eslint-disable-next-line @typescript-eslint/no-var-requires
40
42
  const { offerSelfUpdate: real } = require('../core/update-check');
41
- await real();
43
+ await real({ mode });
42
44
  },
43
45
  };
46
+ const NO_REGISTRIES = { configured: 0, synced: 0, failed: [] };
47
+ /**
48
+ * El texto de cierre, derivado del resultado. Vive acá y no en la registración de
49
+ * Commander porque el literal de `index.ts` era el defecto: decía
50
+ * "✅ Registries, skills and hooks updated." con `code === 0`, y `code` valía 0 pase lo
51
+ * que pase — así que una máquina SIN NINGÚN registry configurado recibía la confirmación
52
+ * de que se le habían actualizado los registries, los skills y los hooks. Un comando que
53
+ * miente es peor que uno que falla: el que falla te manda a mirar, el que miente te manda
54
+ * a otro lado a buscar el problema.
55
+ */
56
+ function updateOutro(result) {
57
+ const { registries: reg } = result;
58
+ if (reg.configured === 0)
59
+ return 'Nothing updated — no registries configured on this machine.';
60
+ if (result.code !== 0)
61
+ return 'Update failed — see errors above.';
62
+ if (reg.failed.length > 0) {
63
+ return `⚠ Updated with stale content — ${reg.synced}/${reg.configured} registries synced, `
64
+ + `kept on-disk content for: ${reg.failed.join(', ')}.`;
65
+ }
66
+ return `✅ ${reg.synced} ${reg.synced === 1 ? 'registry' : 'registries'}, skills and hooks updated.`;
67
+ }
44
68
  /**
45
69
  * Core, UI-free `awm update` logic: resolves agent targets, runs every stage
46
70
  * in order (registry sync → CLI-version gate → context regen → artifact
@@ -58,7 +82,7 @@ async function runUpdateCore(options = {}, deps = {}) {
58
82
  const resolved = (0, agent_targets_1.resolveAgentTargetsOrError)({ prefs, explicit: options.agent });
59
83
  if (!resolved.ok) {
60
84
  console.error(picocolors_1.default.red(resolved.error));
61
- return { code: 1, selectedAgents: [] };
85
+ return { code: 1, selectedAgents: [], registries: NO_REGISTRIES };
62
86
  }
63
87
  const selectedAgents = resolved.targets;
64
88
  const registryResults = await d.syncRegistries();
@@ -68,12 +92,39 @@ async function runUpdateCore(options = {}, deps = {}) {
68
92
  else
69
93
  console.log(picocolors_1.default.green(` ✓ Registry ${r.name} ${r.action === 'pulled' ? 'updated' : 're-cloned'} @ ${r.version}`));
70
94
  }
95
+ const registries = {
96
+ configured: registryResults.length,
97
+ synced: registryResults.filter((r) => r.action !== 'error').length,
98
+ failed: registryResults.filter((r) => r.action === 'error').map((r) => r.name),
99
+ };
100
+ // Cero registries no es "todo al día": es una máquina que nunca corrió `awm init`
101
+ // (o cuyo registries.json se perdió). Seguir adelante regenera contexto vacío,
102
+ // reconcilia contra cero content roots y no re-sincroniza ningún hook — cada etapa
103
+ // "pasa" porque no tiene nada que hacer, y el comando terminaba anunciando éxito.
104
+ // Se corta acá, nombrando el archivo que falta y el comando que lo crea.
105
+ if (registries.configured === 0) {
106
+ console.error(picocolors_1.default.red(`No registries configured in ${(0, paths_2.awmHome)()} — nothing to update.`));
107
+ console.error(picocolors_1.default.dim(" This machine was never initialized: run 'awm init' first."));
108
+ return { code: 1, selectedAgents, registries };
109
+ }
110
+ // Falla CERRADO solo cuando el registry quedó sin contenido en disco: ahí lo que sigue
111
+ // leería un árbol inexistente y el fallo reaparecería más tarde, disfrazado de otra
112
+ // etapa. Un registry que falló pero conserva su contenido queda stale, no roto — se
113
+ // sigue (doctrina de `unusableSyncedRegistries`: un registry secundario flaky nunca
114
+ // aborta la corrida) y el cierre lo nombra en vez de declarar éxito parejo.
115
+ try {
116
+ (0, registries_1.assertSyncedRegistriesUsable)(registryResults);
117
+ }
118
+ catch (e) {
119
+ console.error(picocolors_1.default.red(e.message));
120
+ return { code: 1, selectedAgents, registries };
121
+ }
71
122
  try {
72
123
  (0, registries_1.assertRegistryGates)(d.verifyMinCliVersions());
73
124
  }
74
125
  catch (e) {
75
126
  console.error(picocolors_1.default.red(e.message));
76
- return { code: 1, selectedAgents };
127
+ return { code: 1, selectedAgents, registries };
77
128
  }
78
129
  const regen = d.regenerateGlobalContext(selectedAgents);
79
130
  const refreshed = regen.filter((r) => r.action === 'refreshed').map((r) => r.agent);
@@ -86,7 +137,7 @@ async function runUpdateCore(options = {}, deps = {}) {
86
137
  }
87
138
  catch (e) {
88
139
  console.error(picocolors_1.default.red(`Artifact reconciliation failed: ${e.message}`));
89
- return { code: 1, selectedAgents };
140
+ return { code: 1, selectedAgents, registries };
90
141
  }
91
142
  if (artifactResult.installed.length > 0) {
92
143
  console.log(picocolors_1.default.green(` ✓ Reconciled artifacts: ${artifactResult.installed.join(', ')}`));
@@ -103,9 +154,12 @@ async function runUpdateCore(options = {}, deps = {}) {
103
154
  }
104
155
  catch (e) {
105
156
  console.error(picocolors_1.default.red(`Hook resync failed: ${e.message}`));
106
- return { code: 1, selectedAgents };
157
+ return { code: 1, selectedAgents, registries };
107
158
  }
108
159
  }
109
- await d.offerSelfUpdate();
110
- return { code: 0, selectedAgents };
160
+ // `--yes` es consentimiento explícito para reemplazar el binario global. Sin él se
161
+ // pasa `undefined` a propósito, para que la decisión la tome `defaultSelfUpdateMode()`
162
+ // según haya o no un humano en stdin — no este llamador.
163
+ await d.offerSelfUpdate(options.yes === true ? 'assume-yes' : undefined);
164
+ return { code: 0, selectedAgents, registries };
111
165
  }
@@ -223,6 +223,33 @@ function applyRequestToState(s, env, digest, repoRoot) {
223
223
  (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
224
224
  return;
225
225
  }
226
+ if (env.kind === 'track-join-request') {
227
+ // R6.1: el ÚNICO efecto es marcar la intención. Quien decide si eso mueve la fase es
228
+ // el reducer puro (`reconcileProtocol`, rama `join-requested`: solo un track en
229
+ // `ACTIVE` pasa a `JOIN_REQUESTED`), y quien congela/mergea es `reconcileTracks` —
230
+ // jamás este consumo transaccional. Mismo criterio declarativo que
231
+ // `track-freeze-request` arriba.
232
+ //
233
+ // Este handler NO EXISTÍA: `awm track join` emitía la request, `applyRequestToState`
234
+ // se caía por el final sin reconocer el kind, y `consumePendingRequests` la contaba
235
+ // como `applied` y borraba el archivo. El comando salía 0 imprimiendo un requestId,
236
+ // el supervisor sumaba una request "aplicada", y el track se quedaba en `ACTIVE`
237
+ // para siempre: la cohorte no podía congelarse (C3), así que jamás llegaba a
238
+ // `MERGED_UNVERIFIED` ni a `COMPLETE`. `join-requested` era la única observación del
239
+ // protocolo con CERO productores en todo `src/`.
240
+ const p = env.payload;
241
+ if (typeof p.trackId !== 'string' || p.trackId.length === 0)
242
+ throw new Error('track-join-request requiere trackId');
243
+ const trackId = p.trackId;
244
+ const ref = s.tracks?.find((t) => t.trackId === trackId);
245
+ // Fail-closed: un join sobre un track que no existe es un error del emisor, no algo
246
+ // que se absorba en silencio (que es exactamente lo que hacía la ausencia de handler).
247
+ if (ref === undefined)
248
+ throw new Error(`track-join-request: track desconocido: ${trackId}`);
249
+ ref.joinRequested = true;
250
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
251
+ return;
252
+ }
226
253
  if (env.kind === 'track-freeze-request') {
227
254
  // R5.2/R6.3 (Task 10): request administrativa CROSS-JOURNAL — el
228
255
  // supervisor del PLAN la emite directamente al `requestsDir` de ESTE
@@ -443,6 +470,18 @@ function applyRequestToState(s, env, digest, repoRoot) {
443
470
  (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: verdictId });
444
471
  return;
445
472
  }
473
+ // FALLA CERRADO ante un kind sin handler. Antes esta función simplemente se caía por el
474
+ // final: `consumePendingRequests` no veía excepción, hacía `applied++` y BORRABA el
475
+ // archivo. Una request emitida por un comando real quedaba contada como aplicada, sin
476
+ // evento, sin cambio de estado y sin error — el modo de falla más caro que existe, porque
477
+ // todo el sistema reporta éxito. Así vivió `track-join-request` (ver su handler arriba):
478
+ // el comando salía 0, el journal sumaba una request "aplicada", y la cohorte no avanzaba.
479
+ //
480
+ // Con este throw, un kind sin handler se convierte en `request-rejected-invalid`: queda en
481
+ // `requestProblems`, deja evento durable, y el archivo se renombra a `.rejected` en vez de
482
+ // desaparecer. Agregar un `RequestKind` sin su handler pasa a ser ruidoso — que es la
483
+ // única forma de que no se repita.
484
+ throw new Error(`request kind sin handler en el supervisor: ${env.kind} — emitida pero nunca aplicada`);
446
485
  }
447
486
  /** Consume TODAS las requests pendientes en orden. ORDEN CRITICO (R1.3,
448
487
  * bloqueador 4): (1) mutar estado, (2) writeJournal, (3) borrar archivos,
@@ -70,6 +70,12 @@ const RUNTIME_EFFECTS = new Set([
70
70
  // `runRunFinalInterlock` más abajo).
71
71
  'request-global-qa', 'request-final-integration', 'run-final-interlock',
72
72
  ]);
73
+ /** Fases en las que un `joinRequested` todavía tiene futuro: el track aún no llegó a
74
+ * `ACTIVE`, así que el pedido espera a que llegue en vez de perderse. `ACTIVE` está incluida
75
+ * porque es justamente donde se aplica; cualquier otra fase lo vuelve moot. */
76
+ const JOIN_PENDING_PHASES = [
77
+ 'DECLARED', 'PREPARE_INTENT', 'WORKTREE_CREATED', 'JOURNAL_CREATED', 'SUPERVISOR_STARTING', 'ARMED', 'ACTIVE',
78
+ ];
73
79
  function toProtocol(state, maxParallel) {
74
80
  const tracks = {};
75
81
  for (const ref of state.tracks ?? []) {
@@ -812,6 +818,51 @@ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
812
818
  if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
813
819
  return { state: s, effectExecuted: null };
814
820
  }
821
+ // El pedido de join del controller (`joinRequested`, marcado declarativamente al
822
+ // consumir `track-join-request`) se convierte ACÁ en la observación que el reducer
823
+ // entiende. La regla de si eso mueve la fase vive en `reconcileProtocol` (solo
824
+ // `ACTIVE` pasa a `JOIN_REQUESTED`), no acá: este bloque solo produce la observación
825
+ // que hasta ahora nadie producía. Se consume una por vuelta y se persiste, para no
826
+ // colapsar dos fronteras en un mismo call (mismo invariante que el resto del loop).
827
+ //
828
+ // El pedido es DURABLE mientras el track todavía no llegó a `ACTIVE`. Un join que
829
+ // llega antes de la activación no es un error del controller: es la carrera normal
830
+ // — con `maxParallel` chico la cohorte activa de a un track por vez, así que el
831
+ // controller termina su trabajo y pide el join mientras su track sigue en `ARMED`.
832
+ // Se observó en la certificación: dos joins emitidos con ambos tracks en `ARMED`.
833
+ // Descartarlos ahí (que es lo que hacía la primera versión de este bloque) devuelve
834
+ // el mismo modo de falla que este cambio vino a cerrar — un pedido que reporta éxito
835
+ // y no pasa nada — y solo sobrevivía porque el controller scripteado los re-emite.
836
+ const pendingJoin = (s.tracks ?? []).find((t) => t.joinRequested === true && t.phase === 'ACTIVE');
837
+ if (pendingJoin !== undefined) {
838
+ const protocolBefore = toProtocol(s, maxParallel);
839
+ const observed = (0, protocol_1.reconcileProtocol)(protocolBefore, { kind: 'join-requested', trackId: pendingJoin.trackId });
840
+ const next = applyProtocolToState(s, observed);
841
+ for (const t of next.tracks ?? [])
842
+ if (t.trackId === pendingJoin.trackId)
843
+ t.joinRequested = undefined;
844
+ s = persist(planRoot, branch, next);
845
+ (0, store_1.appendEvent)(planRoot, branch, {
846
+ kind: 'track-join-observed', trackId: pendingJoin.trackId,
847
+ phase: s.tracks?.find((t) => t.trackId === pendingJoin.trackId)?.phase,
848
+ });
849
+ continue;
850
+ }
851
+ // Pedido que ya no puede aplicarse nunca: el track pasó de `ACTIVE` (join duplicado,
852
+ // o el relevo de un controller que re-emite) o quedó `BLOCKED`. Se limpia la marca
853
+ // — dejarla prendida la haría reevaluar en cada tick sin progreso posible. Lo que NO
854
+ // se limpia es el pedido de un track anterior a `ACTIVE`: ese sigue esperando.
855
+ const mootJoin = (s.tracks ?? []).find((t) => t.joinRequested === true
856
+ && !JOIN_PENDING_PHASES.includes(t.phase));
857
+ if (mootJoin !== undefined) {
858
+ const next = structuredClone(s);
859
+ for (const t of next.tracks ?? [])
860
+ if (t.trackId === mootJoin.trackId)
861
+ t.joinRequested = undefined;
862
+ s = persist(planRoot, branch, next);
863
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-join-moot', trackId: mootJoin.trackId, phase: mootJoin.phase });
864
+ continue;
865
+ }
815
866
  const protocol = toProtocol(s, maxParallel);
816
867
  const effect = (0, protocol_1.nextProtocolEffect)(protocol);
817
868
  if (effect === null)
@@ -4,6 +4,7 @@ exports.assertProviderSupported = assertProviderSupported;
4
4
  const child_process_1 = require("child_process");
5
5
  const providers_1 = require("../providers");
6
6
  const versioning_1 = require("./versioning");
7
+ const paths_1 = require("./paths");
7
8
  function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
8
9
  const provider = (0, providers_1.providerFor)(agent);
9
10
  if (!provider.versionCommand || !provider.minimumVersion) {
@@ -15,17 +16,40 @@ function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
15
16
  encoding: 'utf8',
16
17
  stdio: ['ignore', 'pipe', 'pipe'],
17
18
  timeout: 5000,
19
+ // Windows can't CreateProcess a `.cmd` shim directly (npm installs
20
+ // `codex` as `codex.cmd`, not `codex.exe`) — execFileSync needs a
21
+ // shell to resolve and run it, or it throws ENOENT even though
22
+ // typing `codex --version` in the same shell works fine. Safe here
23
+ // (unlike sensors.json's `cmd`, core/paths.ts's resolveOnPath):
24
+ // `provider.versionCommand.command`/`args` are hardcoded first-party
25
+ // config (providers/index.ts), never attacker-controlled input.
26
+ // Found running the issue #55 Windows playbook: `awm init -a codex`
27
+ // reported "Codex is not installed" on a machine where it plainly
28
+ // was — `codex --version` worked fine typed directly.
29
+ shell: (0, paths_1.isWindowsNative)(),
18
30
  }).toString();
19
31
  }
20
32
  catch (error) {
21
- const code = error.code;
33
+ const err = error;
22
34
  // `provider.label` y `versionCommand`, no "Codex" literal. Esta funcion es
23
35
  // generica sobre AgentTarget desde siempre, pero cada mensaje y el patron de
24
36
  // parseo nombraban al unico provider que hoy declara `versionCommand` — el
25
37
  // segundo en declararlo habria reportado "Codex no esta instalado" al no
26
38
  // encontrar SU binario, y habria fallado a parsear una salida perfectamente
27
39
  // valida contra el formato de otro programa.
28
- if (code === 'ENOENT') {
40
+ //
41
+ // Two distinct "not found" shapes to catch now that Windows goes through
42
+ // a shell (see `shell: isWindowsNative()` above): without a shell, a
43
+ // missing binary is a spawn-level ENOENT; through cmd.exe, the shell
44
+ // itself spawns fine and the missing command instead surfaces as a
45
+ // non-zero exit with "'codex' is not recognized..." on stderr — no
46
+ // ENOENT anywhere. Missing this second shape was a real regression:
47
+ // windows-latest CI (no codex installed at all) started reporting
48
+ // "version probe failed" instead of "not installed", because the exit
49
+ // code alone doesn't say WHY the shell failed.
50
+ const shellCommandNotFound = (0, paths_1.isWindowsNative)()
51
+ && /is not recognized as an internal or external command/i.test(String(err.stderr ?? ''));
52
+ if (err.code === 'ENOENT' || shellCommandNotFound) {
29
53
  throw new Error(`${provider.label} is not installed or not available on PATH ` +
30
54
  `(tried \`${provider.versionCommand.command}\`). Install it, then re-run.`);
31
55
  }
@@ -8,6 +8,7 @@ exports.writeUpdateCache = writeUpdateCache;
8
8
  exports.fetchLatestVersion = fetchLatestVersion;
9
9
  exports.spawnRefreshWorker = spawnRefreshWorker;
10
10
  exports.maybeNotifyUpdate = maybeNotifyUpdate;
11
+ exports.defaultSelfUpdateMode = defaultSelfUpdateMode;
11
12
  exports.offerSelfUpdate = offerSelfUpdate;
12
13
  // cli/src/core/update-check.ts
13
14
  //
@@ -92,20 +93,39 @@ function maybeNotifyUpdate(opts) {
92
93
  if (!cache || now - cache.lastCheck > TTL_MS)
93
94
  spawnWorker();
94
95
  }
95
- /** Capa 2 — en `awm update`: detecta, pregunta, ejecuta npm i -g; degrada a aviso. */
96
+ /**
97
+ * Sin nadie del otro lado, un prompt no es una pregunta: es un cuelgue. `awm update`
98
+ * corre en CI, en cron y dentro de sesiones agénticas, y ahí el confirm de self-update
99
+ * bloqueaba el proceso indefinidamente — el comando quedaba a mitad de camino y ningún
100
+ * humano podía destrabarlo. La ausencia de TTY en stdin es la evidencia POSITIVA de que
101
+ * no hay quien conteste, así que se degrada al aviso.
102
+ *
103
+ * Degrada a `skip`, jamás a `assume-yes`: nadie pidió reemplazar el binario global de la
104
+ * máquina, y hacerlo por iniciativa propia porque "no había a quién preguntarle" es
105
+ * exactamente la clase de acción que el silencio no autoriza.
106
+ */
107
+ function defaultSelfUpdateMode() {
108
+ return process.stdin.isTTY === true ? 'prompt' : 'skip';
109
+ }
110
+ /** Capa 2 — en `awm update`: detecta, pregunta (si hay a quién), ejecuta npm i -g; degrada a aviso. */
96
111
  async function offerSelfUpdate(deps = {}) {
97
112
  if (process.env.AWM_NO_UPDATE_CHECK)
98
113
  return;
99
114
  const current = deps.current ?? (0, cli_version_1.cliVersion)();
115
+ const mode = deps.mode ?? defaultSelfUpdateMode();
100
116
  const latest = deps.latest !== undefined ? deps.latest : await fetchLatestVersion(deps.fetchImpl ?? fetch);
101
117
  writeUpdateCache({ lastCheck: Date.now(), latest: latest ?? null });
102
118
  if (!latest || !isNewer(latest, current))
103
119
  return;
120
+ if (mode === 'skip') {
121
+ console.log(picocolors_1.default.dim(` ⬆ awm v${latest} available — to update: npm i -g ${cli_version_1.CLI_PACKAGE_NAME}`));
122
+ return;
123
+ }
104
124
  const confirmImpl = deps.confirmImpl ?? (async (message) => {
105
125
  const r = await (0, prompts_1.confirm)({ message });
106
126
  return !(0, prompts_1.isCancel)(r) && r === true;
107
127
  });
108
- const yes = await confirmImpl(`Update awm v${current} → v${latest} now?`);
128
+ const yes = mode === 'assume-yes' ? true : await confirmImpl(`Update awm v${current} → v${latest} now?`);
109
129
  if (!yes) {
110
130
  console.log(picocolors_1.default.dim(` To update later: npm i -g ${cli_version_1.CLI_PACKAGE_NAME}`));
111
131
  return;
package/dist/src/index.js CHANGED
@@ -122,7 +122,7 @@ program.command('add [name]')
122
122
  if (name) {
123
123
  const allBundles = (0, bundles_1.discoverAllBundles)();
124
124
  const prefs = (0, config_1.getPreferences)();
125
- const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope }, prefs, allBundles);
125
+ const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope, method: options.method }, prefs, allBundles);
126
126
  if (outcome.code !== 0)
127
127
  process.exit(outcome.code);
128
128
  (0, prompts_1.outro)('Done.');
@@ -403,10 +403,15 @@ program.command('add [name]')
403
403
  program.command('update')
404
404
  .description('Sync all configured registries, reconcile artifacts and re-sync hooks')
405
405
  .option('-a, --agent <agent>', `Target agent(s), comma-separated: ${providers_1.AGENT_TARGETS.join(', ')} (defaults to every enabled agent)`)
406
+ .option('-y, --yes', 'Non-interactive: never prompt, and self-update the CLI without asking')
406
407
  .action(async (options) => {
407
408
  (0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Update Registries ')));
408
409
  const result = await (0, update_1.runUpdateCore)(options);
409
- (0, prompts_1.outro)(result.code === 0 ? '✅ Registries, skills and hooks updated.' : picocolors_1.default.red('Update failed see errors above.'));
410
+ // El cierre lo DERIVA `updateOutro` del resultado. No se recorta a `code === 0` con
411
+ // un literal fijo: eso es lo que hacía que una máquina sin registries recibiera la
412
+ // confirmación de que se le habían actualizado.
413
+ const message = (0, update_1.updateOutro)(result);
414
+ (0, prompts_1.outro)(result.code === 0 ? message : picocolors_1.default.red(message));
410
415
  process.exitCode = result.code;
411
416
  });
412
417
  program.command('sync')
@@ -529,12 +534,17 @@ program.command('remove [name]')
529
534
  targetAgents = agentChoice;
530
535
  }
531
536
  let scopeVal;
532
- if (options.scope) {
533
- if (options.scope !== 'local' && options.scope !== 'global') {
534
- console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
537
+ if (options.scope || options.yes) {
538
+ // Same reasoning as the --agent default just above: `--yes` means ZERO
539
+ // prompts. Without the `options.yes` half of this condition, `awm remove
540
+ // <bundle> --yes` still opened the scope picker and hung any
541
+ // non-interactive caller — the flag promised no-interactive and wasn't.
542
+ const resolved = (0, config_1.resolveScopeOption)(options.scope, prefs.defaultScope);
543
+ if (!resolved.ok) {
544
+ console.error(picocolors_1.default.red(resolved.error));
535
545
  process.exit(1);
536
546
  }
537
- scopeVal = options.scope;
547
+ scopeVal = resolved.scope;
538
548
  }
539
549
  else {
540
550
  const scopeChoice = await (0, prompts_1.select)({