agentic-workflow-manager 3.13.5 → 3.13.7

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.
Files changed (84) hide show
  1. package/dist/scripts/support-matrix.js +138 -0
  2. package/dist/src/commands/add.js +5 -0
  3. package/dist/src/commands/doctor.js +1 -0
  4. package/dist/src/commands/hooks/claude.js +15 -2
  5. package/dist/src/commands/hooks/shared.js +6 -1
  6. package/dist/src/commands/init.js +1 -0
  7. package/dist/src/commands/pin.js +24 -4
  8. package/dist/src/commands/preflight/checks.js +3 -2
  9. package/dist/src/commands/registry/add.js +1 -1
  10. package/dist/src/commands/sensors/index.js +7 -0
  11. package/dist/src/commands/sensors/init.js +48 -7
  12. package/dist/src/commands/sensors/install.js +20 -11
  13. package/dist/src/commands/sensors/run.js +31 -29
  14. package/dist/src/commands/sync.js +29 -0
  15. package/dist/src/commands/watch/index.js +11 -1
  16. package/dist/src/core/artifact-name.js +80 -0
  17. package/dist/src/core/bundle-install.js +55 -12
  18. package/dist/src/core/bundles.js +72 -13
  19. package/dist/src/core/context/regenerate.js +21 -4
  20. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  21. package/dist/src/core/diagnostics/checks.js +26 -2
  22. package/dist/src/core/diagnostics/context.js +91 -25
  23. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  24. package/dist/src/core/discovery.js +2 -2
  25. package/dist/src/core/init/mutation-targets.js +34 -1
  26. package/dist/src/core/init/steps.js +70 -19
  27. package/dist/src/core/install-planner.js +62 -17
  28. package/dist/src/core/install-transaction.js +13 -1
  29. package/dist/src/core/journal/adapter.js +10 -1
  30. package/dist/src/core/paths.js +61 -8
  31. package/dist/src/core/profile.js +12 -0
  32. package/dist/src/core/provider-artifacts.js +38 -13
  33. package/dist/src/core/provider-version.js +12 -6
  34. package/dist/src/core/registries.js +40 -19
  35. package/dist/src/core/renderers/registry.js +37 -0
  36. package/dist/src/core/skill-integrity.js +71 -10
  37. package/dist/src/core/update-check.js +14 -2
  38. package/dist/src/core/versioning.js +17 -2
  39. package/dist/src/index.js +31 -23
  40. package/dist/src/providers/index.js +7 -2
  41. package/dist/src/utils/config.js +7 -8
  42. package/dist/tests/commands/doctor.test.js +6 -0
  43. package/dist/tests/commands/pin.test.js +29 -2
  44. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  45. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  46. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  47. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  48. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  49. package/dist/tests/commands/sensors/run.test.js +24 -51
  50. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  51. package/dist/tests/commands/sensors/status.test.js +27 -12
  52. package/dist/tests/commands/watch/runner.test.js +10 -0
  53. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  54. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  55. package/dist/tests/core/context/regenerate.test.js +39 -8
  56. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  57. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  58. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  59. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  60. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  61. package/dist/tests/core/init/mutation-targets-canonical.test.js +81 -0
  62. package/dist/tests/core/init/mutation-targets.test.js +4 -4
  63. package/dist/tests/core/init/orchestrator.test.js +1 -1
  64. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  65. package/dist/tests/core/init/steps.test.js +11 -5
  66. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  67. package/dist/tests/core/paths.test.js +7 -34
  68. package/dist/tests/core/project-skill-links.test.js +159 -0
  69. package/dist/tests/core/provider-artifacts.test.js +66 -14
  70. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  71. package/dist/tests/core/reconciliation.test.js +15 -15
  72. package/dist/tests/core/registry-manifest.test.js +1 -1
  73. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  74. package/dist/tests/core/skill-integrity.test.js +10 -10
  75. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  76. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  77. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  78. package/dist/tests/providers/index.test.js +1 -1
  79. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  80. package/dist/tests/structural/support-matrix-is-current.test.js +100 -0
  81. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  82. package/dist/tests/support/tmp.js +31 -0
  83. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  84. package/package.json +6 -2
@@ -3,12 +3,14 @@ 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.classifyGlobalSkills = classifyGlobalSkills;
7
- exports.repairGlobalSkills = repairGlobalSkills;
6
+ exports.classifySkillLinks = classifySkillLinks;
7
+ exports.repairSkillLinks = repairSkillLinks;
8
8
  exports.reconcileAllSkillLinks = reconcileAllSkillLinks;
9
+ exports.reconcileProjectSkillLinks = reconcileProjectSkillLinks;
9
10
  const fs_1 = __importDefault(require("fs"));
10
11
  const path_1 = __importDefault(require("path"));
11
12
  const providers_1 = require("../providers");
13
+ const paths_1 = require("./paths");
12
14
  function findRegistrySkillPath(registryContentDirs, name) {
13
15
  for (const root of registryContentDirs) {
14
16
  const p = path_1.default.join(root, 'skills', name);
@@ -17,8 +19,11 @@ function findRegistrySkillPath(registryContentDirs, name) {
17
19
  }
18
20
  return null;
19
21
  }
20
- /** Clasifica cada entrada de `skillsDir` (read-only, no muta nada). */
21
- function classifyGlobalSkills(skillsDir, registryContentDirs) {
22
+ /** Clasifica cada entrada de `skillsDir` (read-only, no muta nada).
23
+ * Agnostico al alcance: `skillsDir` es global (`provider.skill.global`) o de
24
+ * proyecto (`<projectRoot>/<provider.skill.local>`) — el nombre decia "Global" y
25
+ * eso basto para que nadie lo apuntara nunca a un dir de proyecto. */
26
+ function classifySkillLinks(skillsDir, registryContentDirs) {
22
27
  const out = { valid: [], repairable: [], dead: [] };
23
28
  let entries;
24
29
  try {
@@ -51,10 +56,11 @@ function classifyGlobalSkills(skillsDir, registryContentDirs) {
51
56
  return out;
52
57
  }
53
58
  /** Re-linkea los repairable al primer root que tenga la skill y poda los dead. Idempotente. Cada
54
- * symlink aislado en try/catch — una falla no aborta el resto. */
55
- function repairGlobalSkills(skillsDir, registryContentDirs) {
59
+ * symlink aislado en try/catch — una falla no aborta el resto. Agnostico al alcance:
60
+ * ver la nota de `classifySkillLinks`. */
61
+ function repairSkillLinks(skillsDir, registryContentDirs) {
56
62
  const result = { relinked: [], pruned: [], failed: [] };
57
- const { repairable, dead } = classifyGlobalSkills(skillsDir, registryContentDirs);
63
+ const { repairable, dead } = classifySkillLinks(skillsDir, registryContentDirs);
58
64
  for (const name of repairable) {
59
65
  const p = path_1.default.join(skillsDir, name);
60
66
  try {
@@ -63,8 +69,35 @@ function repairGlobalSkills(skillsDir, registryContentDirs) {
63
69
  result.failed.push(name);
64
70
  continue;
65
71
  }
66
- fs_1.default.rmSync(p, { force: true });
67
- fs_1.default.symlinkSync(target, p, 'dir');
72
+ // Dos correcciones, ambas criticas en Windows:
73
+ //
74
+ // 1. `'junction'` en win32, no `'dir'`. Un symlink de directorio
75
+ // exige SeCreateSymbolicLinkPrivilege, denegado por defecto en
76
+ // cuentas sin privilegios (incluido el runner windows-latest);
77
+ // una junction la puede crear cualquiera y libuv la reporta
78
+ // igual. `executor.ts` ya documenta esto en extenso — este sitio
79
+ // nunca recibio el fix.
80
+ //
81
+ // 2. Se crea el link NUEVO antes de retirar el viejo. Antes el
82
+ // `rmSync` iba primero, asi que en Windows el link se borraba y
83
+ // la recreacion fallaba: el usuario quedaba PEOR que antes, y
84
+ // con la entrada ya ausente el proximo `awm init` ni siquiera
85
+ // podia verla como reparable. Un fallo ahora deja el estado
86
+ // original intacto.
87
+ const staged = path_1.default.join(skillsDir, `.${name}.${process.pid}.relink`);
88
+ try {
89
+ fs_1.default.rmSync(staged, { recursive: true, force: true });
90
+ fs_1.default.symlinkSync(target, staged, (0, paths_1.isWindowsNative)() ? 'junction' : 'dir');
91
+ fs_1.default.rmSync(p, { recursive: true, force: true });
92
+ fs_1.default.renameSync(staged, p);
93
+ }
94
+ finally {
95
+ // El staging jamas debe sobrevivir a esta iteracion: si quedara,
96
+ // `classifySkillLinks` lo veria como un symlink valido (apunta
97
+ // al registry, que existe) y nunca lo clasificaria como muerto,
98
+ // asi que se quedaria en el directorio para siempre.
99
+ fs_1.default.rmSync(staged, { recursive: true, force: true });
100
+ }
68
101
  result.relinked.push(name);
69
102
  }
70
103
  catch {
@@ -92,7 +125,35 @@ function reconcileAllSkillLinks(registryContentDirs) {
92
125
  const skillsDir = (0, providers_1.providerFor)(agent).skill.global;
93
126
  if (skillsDir === null || !fs_1.default.existsSync(skillsDir))
94
127
  continue;
95
- out.push({ agent, result: repairGlobalSkills(skillsDir, registryContentDirs) });
128
+ out.push({ agent, result: repairSkillLinks(skillsDir, registryContentDirs) });
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * El equivalente de `reconcileAllSkillLinks` para el alcance de PROYECTO: reconcilia
134
+ * los symlinks de skills bajo `<projectRoot>/<provider.skill.local>` de cada agente
135
+ * pedido. Es la mitad que faltaba — todo lo de arriba servia igual para un dir local,
136
+ * pero ningun llamador lo apuntaba ahi, asi que un link colgante de proyecto no se
137
+ * curaba, no se podaba y no se reportaba en ninguna superficie. Para Copilot
138
+ * (`skill.global === null`) esto no era una mitad sino el total: TODAS sus skills son
139
+ * de proyecto.
140
+ *
141
+ * Un dir inexistente se omite (no hay nada que reconciliar y crearlo seria instalar,
142
+ * no reparar). Solo toca symlinks colgantes: los archivos y directorios reales del
143
+ * usuario, y los links vivos, quedan intactos — ver `classifySkillLinks`.
144
+ */
145
+ function reconcileProjectSkillLinks(projectRoot, agents, registryContentDirs) {
146
+ const out = [];
147
+ const seen = new Set();
148
+ for (const agent of agents) {
149
+ // Varios providers comparten dir local (opencode y codex → `.agents/skills`).
150
+ // Reconciliar dos veces el mismo dir reportaria las mismas curaciones por
151
+ // duplicado, y la segunda pasada las veria ya sanas: se hace una sola vez.
152
+ const skillsDir = path_1.default.join(projectRoot, (0, providers_1.providerFor)(agent).skill.local);
153
+ if (seen.has(skillsDir) || !fs_1.default.existsSync(skillsDir))
154
+ continue;
155
+ seen.add(skillsDir);
156
+ out.push({ agent, result: repairSkillLinks(skillsDir, registryContentDirs) });
96
157
  }
97
158
  return out;
98
159
  }
@@ -21,6 +21,18 @@ const picocolors_1 = __importDefault(require("picocolors"));
21
21
  const prompts_1 = require("@clack/prompts");
22
22
  const cli_version_1 = require("./cli-version");
23
23
  const versioning_1 = require("./versioning");
24
+ /** El aviso de actualizacion es cosmetico: una version ilegible (de la cache o
25
+ * del registro de npm) no debe romper el comando que el usuario pidio — solo
26
+ * se omite el aviso. El gate de `minCliVersion`, en cambio, falla CERRADO
27
+ * (ver verifyMinCliVersions). */
28
+ function isNewer(a, b) {
29
+ try {
30
+ return (0, versioning_1.compareSemver)(a, b) > 0;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
24
36
  const paths_1 = require("./paths");
25
37
  const TTL_MS = 24 * 60 * 60 * 1000;
26
38
  const REGISTRY_URL = `https://registry.npmjs.org/${cli_version_1.CLI_PACKAGE_NAME}/latest`;
@@ -70,7 +82,7 @@ function maybeNotifyUpdate(opts) {
70
82
  const now = opts?.now ?? Date.now();
71
83
  const spawnWorker = opts?.spawnWorker ?? spawnRefreshWorker;
72
84
  const cache = readUpdateCache();
73
- if (cache?.latest && (0, versioning_1.compareSemver)(cache.latest, (0, cli_version_1.cliVersion)()) > 0) {
85
+ if (cache?.latest && isNewer(cache.latest, (0, cli_version_1.cliVersion)())) {
74
86
  console.log(picocolors_1.default.dim(`\n⬆ awm v${cache.latest} available → npm i -g ${cli_version_1.CLI_PACKAGE_NAME}`));
75
87
  }
76
88
  if (!cache || now - cache.lastCheck > TTL_MS)
@@ -83,7 +95,7 @@ async function offerSelfUpdate(deps = {}) {
83
95
  const current = deps.current ?? (0, cli_version_1.cliVersion)();
84
96
  const latest = deps.latest !== undefined ? deps.latest : await fetchLatestVersion(deps.fetchImpl ?? fetch);
85
97
  writeUpdateCache({ lastCheck: Date.now(), latest: latest ?? null });
86
- if (!latest || (0, versioning_1.compareSemver)(latest, current) <= 0)
98
+ if (!latest || !isNewer(latest, current))
87
99
  return;
88
100
  const confirmImpl = deps.confirmImpl ?? (async (message) => {
89
101
  const r = await (0, prompts_1.confirm)({ message });
@@ -105,8 +105,23 @@ function machineVersionOpts(registryName) {
105
105
  }
106
106
  }
107
107
  /** Compara "X.Y.Z" vs "X.Y.Z" (sin prefijo v) numéricamente. <0, 0, >0. */
108
+ /** Compara dos semver X.Y.Z.
109
+ *
110
+ * Antes devolvia `NaN` ante una entrada malformada (`'1.2'`, `'x.y.z'`), y como
111
+ * `NaN > 0` y `NaN < 0` son AMBOS false, el gate de version fallaba ABIERTO:
112
+ * `verifyMinCliVersions` dejaba pasar en silencio un CLI por debajo del minimo,
113
+ * y el aviso de actualizacion no disparaba nunca. Para un gate, la direccion
114
+ * correcta de fallo es la contraria. Ahora una entrada que no es semver lanza,
115
+ * nombrando el valor: es el llamador quien decide como tratarlo, con
116
+ * informacion suficiente para hacerlo. */
108
117
  function compareSemver(a, b) {
109
- const pa = a.split('.').map(Number);
110
- const pb = b.split('.').map(Number);
118
+ const parse = (v) => {
119
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim());
120
+ if (!m)
121
+ throw new Error(`not a semver version: ${JSON.stringify(v)}`);
122
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
123
+ };
124
+ const pa = parse(a);
125
+ const pb = parse(b);
111
126
  return (pa[0] - pb[0]) || (pa[1] - pb[1]) || (pa[2] - pb[2]);
112
127
  }
package/dist/src/index.js CHANGED
@@ -18,6 +18,9 @@ const provider_preflight_1 = require("./ui/provider-preflight");
18
18
  const discovery_1 = require("./core/discovery");
19
19
  const bundles_1 = require("./core/bundles");
20
20
  const registries_1 = require("./core/registries");
21
+ const install_planner_1 = require("./core/install-planner");
22
+ const install_transaction_1 = require("./core/install-transaction");
23
+ const profile_1 = require("./core/profile");
21
24
  const path_1 = __importDefault(require("path"));
22
25
  const picocolors_1 = __importDefault(require("picocolors"));
23
26
  const fs_1 = __importDefault(require("fs"));
@@ -166,31 +169,33 @@ program.command('add [name]')
166
169
  (0, prompts_1.outro)(picocolors_1.default.yellow('No artifacts available.'));
167
170
  return;
168
171
  }
169
- const preflightOk = (0, provider_preflight_1.preflightLinkArtifactsForCli)(targetAgents.flatMap((agent) => artifactsToInstall.map((artifact) => ({ agent, artifact }))));
170
- if (!preflightOk)
171
- return;
172
172
  const installSpinner = (0, prompts_1.spinner)();
173
173
  installSpinner.start('Installing artifacts...');
174
174
  try {
175
- const installed = [];
176
- const skipped = [];
177
- for (const currentAgent of targetAgents) {
178
- for (const artifact of artifactsToInstall) {
179
- const config = (0, providers_1.assertLinkRenderer)(artifact.type, currentAgent);
180
- if (config === null) {
181
- skipped.push(`${artifact.name} (${currentAgent})`);
182
- continue;
183
- }
184
- const targetDir = config[scopeVal];
185
- if (targetDir === null) {
186
- skipped.push(`${artifact.name} (${currentAgent})`);
187
- continue;
188
- }
189
- const finalDest = path_1.default.join(targetDir, artifact.name);
190
- (0, executor_1.installArtifact)(artifact.sourcePath, finalDest, methodVal);
191
- installed.push(`${artifact.name} → ${currentAgent} (${scopeVal})`);
192
- }
193
- }
175
+ // Se delega en el MISMO pipeline que `awm add <bundle>`
176
+ // (planInstall + applyInstallPlan): transaccional, con backups,
177
+ // rollback y soporte real de renderers.
178
+ //
179
+ // Antes esto instalaba a mano y pasaba por `assertLinkRenderer`,
180
+ // que TIRA para cualquier renderer que no sea `link` — asi que
181
+ // `awm add --all` moria con "Renderer 'cursor-mdc' … is not
182
+ // implemented yet" para cursor y copilot, y para CODEX era puro
183
+ // dano colateral: sus skills son `link` e instalan bien, pero un
184
+ // solo artefacto `agent` mataba la corrida entera. El pipeline
185
+ // real ya soporta los tres renderers — `awm add <bundle> -a cursor`
186
+ // lo demuestra — asi que el unico problema era este camino paralelo.
187
+ const summary = (0, install_transaction_1.applyInstallPlan)((0, install_planner_1.planInstall)({
188
+ artifacts: artifactsToInstall.map((a) => ({
189
+ name: a.name, type: a.type, installName: a.name, sourcePath: a.sourcePath,
190
+ })),
191
+ selectedAgents: targetAgents,
192
+ enabledAgents: prefs.enabledAgents,
193
+ scope: scopeVal,
194
+ projectRoot: (0, profile_1.findProjectRoot)(process.cwd()) ?? process.cwd(),
195
+ method: methodVal,
196
+ }));
197
+ const installed = summary.installed;
198
+ const skipped = summary.skipped;
194
199
  const updatedPrefs = targetAgents.reduce((current, agent) => (0, config_1.enableAgent)(current, agent), prefs);
195
200
  (0, config_1.savePreferences)({
196
201
  ...updatedPrefs,
@@ -497,7 +502,10 @@ program.command('remove')
497
502
  });
498
503
  handleCancel(scopeChoice);
499
504
  const scopeVal = scopeChoice;
500
- const installed = (0, provider_artifacts_1.scanLegacyArtifacts)(targetAgents, scopeVal);
505
+ // projectRoot explicito: `config.local` es relativo, y resolverlo contra
506
+ // `process.cwd()` hacia que `awm remove` no encontrara nada desde un
507
+ // subdirectorio — y que le pasara rutas RELATIVAS a fs.rmSync cuando si.
508
+ const installed = (0, provider_artifacts_1.scanLegacyArtifacts)(targetAgents, scopeVal, (0, profile_1.findProjectRoot)(process.cwd()) ?? process.cwd());
501
509
  if (installed.length === 0) {
502
510
  (0, prompts_1.outro)(picocolors_1.default.yellow('No installed artifacts found for the selected agents/scope.'));
503
511
  process.exit(0);
@@ -3,7 +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.UnsupportedRendererError = exports.AGENT_TARGETS = void 0;
6
+ exports.UnsupportedRendererError = exports.RENDERER_IDS = exports.AGENT_TARGETS = void 0;
7
7
  exports.isAgentTarget = isAgentTarget;
8
8
  exports.requireAgentTarget = requireAgentTarget;
9
9
  exports.unsupportedScopeError = unsupportedScopeError;
@@ -18,6 +18,11 @@ exports.getInjection = getInjection;
18
18
  const path_1 = __importDefault(require("path"));
19
19
  const paths_1 = require("../core/paths");
20
20
  exports.AGENT_TARGETS = ['antigravity', 'opencode', 'claude-code', 'codex', 'cursor', 'copilot'];
21
+ /** Runtime-enumerable, so the renderer table in `core/renderers/registry.ts` can be
22
+ * checked for completeness against it (tests/structural/renderer-table-is-single-source).
23
+ * A type-only union cannot be iterated, which is part of why four partial copies of the
24
+ * renderer→extension mapping could drift apart unnoticed. */
25
+ exports.RENDERER_IDS = ['link', 'codex-agent-toml', 'cursor-mdc', 'copilot-instructions'];
21
26
  function isAgentTarget(value) {
22
27
  return typeof value === 'string' &&
23
28
  exports.AGENT_TARGETS.includes(value);
@@ -102,7 +107,7 @@ function providers() {
102
107
  codex: {
103
108
  label: 'Codex',
104
109
  minimumVersion: '0.145.0',
105
- versionCommand: { command: 'codex', args: ['--version'] },
110
+ versionCommand: { command: 'codex', args: ['--version'], versionPattern: /^codex-cli (\d+\.\d+\.\d+)$/ },
106
111
  skill: {
107
112
  global: path_1.default.join(home, '.agents/skills'),
108
113
  local: '.agents/skills',
@@ -13,6 +13,7 @@ const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const providers_1 = require("../providers");
15
15
  const paths_1 = require("../core/paths");
16
+ const atomic_file_1 = require("../core/atomic-file");
16
17
  const DEFAULT_PREFS = {
17
18
  // claude-code matches `awm init`'s own documented default (see init.ts / `awm init --help`).
18
19
  // Previously 'antigravity', which silently mis-installed bundles in claude-code
@@ -118,14 +119,12 @@ function loadPreferences(initialAgent = 'claude-code') {
118
119
  }
119
120
  function savePreferences(prefs) {
120
121
  const normalized = normalizePreferences(prefs, false).prefs;
121
- const file = prefsFile();
122
- fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
123
- const temp = `${file}.${process.pid}.tmp`;
124
- fs_1.default.writeFileSync(temp, JSON.stringify(normalized, null, 2) + '\n', {
125
- encoding: 'utf8',
126
- mode: 0o600,
127
- });
128
- fs_1.default.renameSync(temp, file);
122
+ // `writeFileAtomic`, not a second hand-rolled write-temp-then-rename. The local copy
123
+ // predated the primitive and had drifted below it: no fsync, a pid-only temp name,
124
+ // and none of the guards the shared one grew. Same 0o600 intent and same
125
+ // mode-preservation semantics as `artifact-state.ts`, the other 0o600 file written
126
+ // through it — a file of this sensitivity has an established treatment here.
127
+ (0, atomic_file_1.writeFileAtomic)(prefsFile(), JSON.stringify(normalized, null, 2) + '\n', 0o600);
129
128
  }
130
129
  function enableAgent(prefs, agent) {
131
130
  normalizePreferences(prefs);
@@ -148,6 +148,12 @@ describe('runDoctor', () => {
148
148
  expect(code).toBe(1);
149
149
  });
150
150
  it('reports every enabled provider and stable remediation codes in JSON', () => {
151
+ // El check `agents.native` solo se emite si hay artefactos nativos que
152
+ // verificar: un registry sin `agents/` ya no produce una fila roja sin
153
+ // remedio. Se siembra uno para que el check sea genuino en vez de
154
+ // asertar sobre una fila que hoy, con razon, no existiria.
155
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.codex', 'agents'), { recursive: true });
156
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.codex', 'agents', 'demo.toml'), 'name = "demo"\n');
151
157
  writePrefs(prefsWith(['claude-code', 'opencode', 'codex']));
152
158
  const code = (0, doctor_1.runDoctor)({ cwd: tmpWork, json: true });
153
159
  const report = JSON.parse(stdout());
@@ -34,7 +34,7 @@ describe('pin/unpin (editores de preferences)', () => {
34
34
  it('setPin escribe pins.base normalizado (acepta prefijo v)', () => {
35
35
  const { setPin } = require('../../src/commands/pin');
36
36
  setPin('base', 'v1.2.0');
37
- expect(readPrefs().pins).toEqual({ base: '1.2.0' });
37
+ expect(readPrefs().pins).toEqual({ baseline: '1.2.0' });
38
38
  });
39
39
  it('setPin acepta un registry adicional configurado', () => {
40
40
  const { writeRegistriesConfig } = require('../../src/core/registries');
@@ -66,7 +66,34 @@ describe('pin/unpin (editores de preferences)', () => {
66
66
  writeRegistriesConfig([{ name: 'equipo', remote: '/tmp/x' }]);
67
67
  setPin('equipo', '2.0.0');
68
68
  const prefs = readPrefs();
69
- expect(prefs.pins).toEqual({ base: '1.0.0', equipo: '2.0.0' });
69
+ expect(prefs.pins).toEqual({ baseline: '1.0.0', equipo: '2.0.0' });
70
70
  expect(prefs.defaultAgent).toBeDefined();
71
71
  });
72
72
  });
73
+ describe('awm pin base: el alias tiene que llegar a la clave que el resolutor lee', () => {
74
+ // Regresion: el registry base se llama `baseline` en disco, pero la ayuda
75
+ // del comando siempre documento `awm pin base <v>`. `base` se aceptaba y se
76
+ // persistia tal cual — en una clave que NADA lee. El comando reportaba
77
+ // "✓ base pinned to v1.0.0" y no cambiaba nada, incluso siendo la salida
78
+ // documentada de un bloqueo por `minCliVersion`, donde el usuario queda sin
79
+ // poder correr `awm update` ni `awm sync`.
80
+ it('setPin("base") persiste bajo "baseline", que es lo que consulta el resolutor', () => {
81
+ const { setPin } = require('../../src/commands/pin');
82
+ setPin('base', '1.5.0');
83
+ const { getPreferences } = require('../../src/utils/config');
84
+ expect(getPreferences().pins).toEqual({ baseline: '1.5.0' });
85
+ });
86
+ it('removePin("base") borra ese mismo pin (no una clave fantasma)', () => {
87
+ const { setPin, removePin } = require('../../src/commands/pin');
88
+ setPin('base', '1.5.0');
89
+ expect(removePin('base')).toBe(true);
90
+ const { getPreferences } = require('../../src/utils/config');
91
+ expect(getPreferences().pins).toEqual({});
92
+ });
93
+ it('"baseline" explicito sigue funcionando igual', () => {
94
+ const { setPin } = require('../../src/commands/pin');
95
+ setPin('baseline', '2.0.0');
96
+ const { getPreferences } = require('../../src/utils/config');
97
+ expect(getPreferences().pins).toEqual({ baseline: '2.0.0' });
98
+ });
99
+ });
@@ -163,18 +163,34 @@ describe('preflight', () => {
163
163
  // sensor configuration, and an empty sensors object now fails `checkManifest`
164
164
  // (see the `total === 0` branch), which would drag `report.status` off 'ready'
165
165
  // for reasons unrelated to what these tests exercise.
166
- beforeEach(() => { mockExecSync.mockReset(); });
166
+ // `resolveOnPath` resuelve PATH en proceso (ya no invoca un shell — ver la
167
+ // nota de seguridad en core/paths.ts), asi que la disponibilidad de `gh`/
168
+ // `glab` se simula con un PATH aislado, no mockeando `execSync`. Ademas de
169
+ // reflejar el mecanismo real, esto vuelve deterministas los casos de
170
+ // "no esta en PATH", que antes dependian de que la maquina no lo tuviera.
171
+ let pathDir;
172
+ let originalPath;
173
+ beforeEach(() => {
174
+ pathDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-path-'));
175
+ originalPath = process.env.PATH;
176
+ process.env.PATH = pathDir;
177
+ });
178
+ afterEach(() => {
179
+ if (originalPath === undefined)
180
+ delete process.env.PATH;
181
+ else
182
+ process.env.PATH = originalPath;
183
+ fs_1.default.rmSync(pathDir, { recursive: true, force: true });
184
+ });
185
+ function installOnPath(tool) {
186
+ const f = path_1.default.join(pathDir, tool);
187
+ fs_1.default.writeFileSync(f, '#!/bin/sh\nexit 0\n');
188
+ fs_1.default.chmodSync(f, 0o755);
189
+ }
167
190
  it('reports github + gh available, and does not affect status', () => {
168
191
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
169
192
  gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
170
- // `resolveOnPath('gh')` runs `command -v gh` on POSIX but `where gh` on
171
- // win32 (see paths.ts) — match both invocation forms so this test's
172
- // "gh is available" fixture holds on windows-latest CI too.
173
- mockExecSync.mockImplementation(((cmd) => {
174
- if (cmd === 'command -v gh' || cmd === 'where gh')
175
- return Buffer.from('/usr/bin/gh');
176
- throw new Error(`not found: ${cmd}`);
177
- }));
193
+ installOnPath('gh');
178
194
  const report = (0, checks_1.preflight)(dir);
179
195
  expect(check(report, 'host').ok).toBe(true);
180
196
  expect(check(report, 'host').detail).toBe('github detected, gh available');
@@ -185,9 +201,7 @@ describe('preflight', () => {
185
201
  // advisory contract: it must not drag an otherwise-clean repo to `degraded`.
186
202
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
187
203
  gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
188
- mockExecSync.mockImplementation((() => {
189
- throw new Error('not found');
190
- }));
204
+ // PATH aislado y vacio => glab no resuelve.
191
205
  const report = (0, checks_1.preflight)(dir);
192
206
  expect(check(report, 'host').ok).toBe(true);
193
207
  expect(check(report, 'host').detail).toContain('glab not on PATH');
@@ -254,7 +268,7 @@ describe('preflight', () => {
254
268
  // test guards is that it must never again read as gitlab.
255
269
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
256
270
  gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
257
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
271
+ // PATH real: estos casos no dependen de gh/glab.
258
272
  const report = (0, checks_1.preflight)(dir);
259
273
  expect(check(report, 'host').ok).toBe(true);
260
274
  expect(check(report, 'host').detail).toContain('github detected');
@@ -289,28 +303,28 @@ describe('preflight', () => {
289
303
  it('still detects github.com over HTTPS', () => {
290
304
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
291
305
  gitRepo(dir, 'https://github.com/org/repo.git');
292
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
306
+ // PATH real: estos casos no dependen de gh/glab.
293
307
  const report = (0, checks_1.preflight)(dir);
294
308
  expect(check(report, 'host').detail).toContain('github detected');
295
309
  });
296
310
  it('still detects github.com over SSH shorthand', () => {
297
311
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
298
312
  gitRepo(dir, 'git@github.com:org/repo.git');
299
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
313
+ // PATH real: estos casos no dependen de gh/glab.
300
314
  const report = (0, checks_1.preflight)(dir);
301
315
  expect(check(report, 'host').detail).toContain('github detected');
302
316
  });
303
317
  it('still detects gitlab over HTTPS', () => {
304
318
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
305
319
  gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
306
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
320
+ // PATH real: estos casos no dependen de gh/glab.
307
321
  const report = (0, checks_1.preflight)(dir);
308
322
  expect(check(report, 'host').detail).toContain('gitlab detected');
309
323
  });
310
324
  it('still detects gitlab over SSH shorthand', () => {
311
325
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
312
326
  gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
313
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
327
+ // PATH real: estos casos no dependen de gh/glab.
314
328
  const report = (0, checks_1.preflight)(dir);
315
329
  expect(check(report, 'host').detail).toContain('gitlab detected');
316
330
  });
@@ -38,8 +38,8 @@ describe('installBundlesFromRegistry', () => {
38
38
  fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
39
39
  });
40
40
  it('installs all ambient bundles of the given registry root for the agent', () => {
41
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
42
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'team');
41
+ const { registriesDir } = require('../../../src/core/registries');
42
+ const regRoot = path_1.default.join(registriesDir(), 'team');
43
43
  writeBundleRoot(regRoot, 'team-pack', 'team-skill');
44
44
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
45
45
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'registries.json'), JSON.stringify([{ name: 'team', remote: 'r' }]));
@@ -52,8 +52,8 @@ describe('installBundlesFromRegistry', () => {
52
52
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude', 'skills', 'team-skill'))).toBe(true);
53
53
  });
54
54
  it('returns empty when the registry has no bundles', () => {
55
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
56
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'empty');
55
+ const { registriesDir } = require('../../../src/core/registries');
56
+ const regRoot = path_1.default.join(registriesDir(), 'empty');
57
57
  fs_1.default.mkdirSync(path_1.default.join(regRoot, 'skills', 's'), { recursive: true });
58
58
  fs_1.default.writeFileSync(path_1.default.join(regRoot, 'skills', 's', 'SKILL.md'), `---\nname: s\ndescription: d\n---\n`);
59
59
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
@@ -62,8 +62,8 @@ describe('installBundlesFromRegistry', () => {
62
62
  expect(installBundlesFromRegistry(regRoot, 'all', ['claude-code'], tmpWork)).toEqual([]);
63
63
  });
64
64
  it('installs only the named bundles when a list is given', () => {
65
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
66
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'team');
65
+ const { registriesDir } = require('../../../src/core/registries');
66
+ const regRoot = path_1.default.join(registriesDir(), 'team');
67
67
  writeBundleRoot(regRoot, 'wanted', 'skill-w');
68
68
  // second bundle in same catalog
69
69
  fs_1.default.mkdirSync(path_1.default.join(regRoot, 'bundles', 'unwanted'), { recursive: true });
@@ -0,0 +1,96 @@
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
+ // `awm sensors init` auto-detects the stack, then reads that pack's defaults out of
7
+ // the registry. When the registry has no such pack — a pinned or stale registry, or one
8
+ // that simply never shipped it — `readPackDefaults` returned null and the manifest was
9
+ // written EMPTY, with no error and no warning:
10
+ //
11
+ // $ awm sensors init # python project, registry predates the python pack
12
+ // ✔ Detected: python (pyproject.toml)
13
+ // ✔ Wrote .awm/sensors.json ← {"pack":"python","sensors":{}}
14
+ //
15
+ // Nothing in that output says the quality gate now checks nothing. The operator finds
16
+ // out later, from an unrelated command (`awm preflight` exits 1, `awm sensors status`
17
+ // reports DEGRADED), and the manifest is by then committed.
18
+ //
19
+ // `--pack <name>` has always validated against the registry and thrown (assertPackExists).
20
+ // Auto-detection reaching the same dead end must not be quieter than the explicit path:
21
+ // it now falls back to a pack the registry actually has, and says which pack was missing
22
+ // so the operator hears it at the moment the decision is made.
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const os_1 = __importDefault(require("os"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const init_1 = require("../../../src/commands/sensors/init");
27
+ function mkRegistry(packs) {
28
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-packreg-'));
29
+ for (const [name, sensors] of Object.entries(packs)) {
30
+ const dir = path_1.default.join(root, 'sensor-packs', name);
31
+ fs_1.default.mkdirSync(dir, { recursive: true });
32
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'pack.json'), JSON.stringify({ name, sensors }));
33
+ }
34
+ return root;
35
+ }
36
+ function mkPythonProject() {
37
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-packproj-'));
38
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'pyproject.toml'), '[project]\nname = "x"\n');
39
+ return dir;
40
+ }
41
+ const GENERIC = { security: { defaultCmd: 'semgrep .', fast: false } };
42
+ const PYTHON = { typecheck: { defaultCmd: 'mypy .', fast: true, formatter: 'mypy' } };
43
+ describe('awm sensors init — detected pack missing from the registry', () => {
44
+ const made = [];
45
+ const registry = (packs) => { const r = mkRegistry(packs); made.push(r); return r; };
46
+ const project = () => { const p = mkPythonProject(); made.push(p); return p; };
47
+ const manifestOf = (dir) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
48
+ afterAll(() => { for (const d of made)
49
+ fs_1.default.rmSync(d, { recursive: true, force: true }); });
50
+ it('names the missing pack instead of writing an empty manifest silently', () => {
51
+ const cwd = project();
52
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {}, generic: GENERIC }) });
53
+ expect(result.unavailablePack).toBe('python');
54
+ expect(result.detection.pack).toBe('python'); // what the tree actually is — unchanged
55
+ });
56
+ it('falls back to a pack the registry does have, so the gate still measures something', () => {
57
+ const cwd = project();
58
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {}, generic: GENERIC }) });
59
+ expect(result.manifest.pack).toBe('generic');
60
+ expect(Object.keys(result.manifest.sensors)).toEqual(['security']);
61
+ expect(manifestOf(cwd).pack).toBe('generic');
62
+ });
63
+ it('stays on the detected pack when the registry has no fallback either', () => {
64
+ // Nothing better exists. The manifest is honestly empty and still says so —
65
+ // preflight's `manifest` check fails on `total === 0` with a registry remedy.
66
+ const cwd = project();
67
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {} }) });
68
+ expect(result.unavailablePack).toBe('python');
69
+ expect(result.manifest.pack).toBe('python');
70
+ expect(result.manifest.sensors).toEqual({});
71
+ });
72
+ it('is quiet when the registry does have the detected pack', () => {
73
+ const cwd = project();
74
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ python: PYTHON, generic: GENERIC }) });
75
+ expect(result.unavailablePack).toBeUndefined();
76
+ expect(result.manifest.pack).toBe('python');
77
+ expect(Object.keys(result.manifest.sensors)).toEqual(['typecheck']);
78
+ });
79
+ it('does not fall back when there is no registry to check against', () => {
80
+ // No registryRoot means nothing to validate against, the same tolerance
81
+ // `--pack` already has — not evidence that the pack is missing.
82
+ const cwd = project();
83
+ const result = (0, init_1.initSensors)({ cwd });
84
+ expect(result.unavailablePack).toBeUndefined();
85
+ expect(result.manifest.pack).toBe('python');
86
+ });
87
+ it('keeps sensors the user already had when falling back', () => {
88
+ // The fallback must not be a way to lose hand-written configuration.
89
+ const cwd = project();
90
+ fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
91
+ fs_1.default.writeFileSync(path_1.default.join(cwd, '.awm', 'sensors.json'), JSON.stringify({ pack: 'python', sensors: { test: { cmd: 'pytest -q', fast: false } } }));
92
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ generic: GENERIC }) });
93
+ expect(result.manifest.sensors.test?.cmd).toBe('pytest -q');
94
+ expect(Object.keys(result.manifest.sensors).sort()).toEqual(['security', 'test']);
95
+ });
96
+ });