agentic-workflow-manager 8.2.0 → 8.3.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.
@@ -19,6 +19,43 @@ const fs_1 = __importDefault(require("fs"));
19
19
  const path_1 = __importDefault(require("path"));
20
20
  const providers_1 = require("../../providers");
21
21
  const shared_1 = require("./shared");
22
+ const provider_1 = require("../../core/context/provider");
23
+ const orchestrators_1 = require("../../core/orchestrators");
24
+ /**
25
+ * Materializa el payload compuesto (using-awm + orquestadores declarados) en `skillDest`.
26
+ *
27
+ * Antes `using-awm.md` era un symlink al SKILL.md crudo del registry: todo lo que
28
+ * `buildContext` compone (declared orchestrators — Tasks 1-4) nunca llegaba a Claude
29
+ * Code, el proveedor PRINCIPAL que este framework existe para servir. Se escribe el
30
+ * archivo en vez de enlazarlo porque el contenido ya no es un archivo del registry sino
31
+ * un derivado suyo — igual que `materialize()` hace para los demas proveedores
32
+ * (core/context/materializer.ts), solo que aca el destino es el propio scriptsDir del
33
+ * hook en vez del awm-context.md generico.
34
+ *
35
+ * Usada por `installClaudeHook` Y por `resyncClaudeHookFiles` — con proposito: si solo
36
+ * una de las dos escribiera el payload materializado, la otra seguiria symlinkeando al
37
+ * SKILL.md crudo y el siguiente `awm update` reabriria el mismo bypass que esto cierra.
38
+ *
39
+ * Escritura atomica via write-then-rename (post-implementation-qa Finding 1, TOCTOU):
40
+ * el patron previo (`unlinkSync` seguido de `writeFileSync`) dejaba una ventana entre
41
+ * ambas llamadas donde un symlink recreado en `skillDest` seria seguido por
42
+ * `writeFileSync` (que no usa `O_EXCL`), escribiendo a traves de el sobre lo que sea
43
+ * que apunte. Escribir a un temporal unico en el MISMO directorio (para que el rename
44
+ * quede en el mismo filesystem y sea atomico) y luego `renameSync` al destino evita eso
45
+ * por completo: el rename reemplaza la entrada de directorio de forma atomica sin
46
+ * dereferenciar un symlink preexistente en el destino, y sin ventana entre borrar y
47
+ * escribir — no hace falta unlink previo, el rename ya sobrescribe en un solo paso.
48
+ */
49
+ function writeMaterializedSkill(skillDest, registryRoot) {
50
+ const ctx = (0, provider_1.buildContext)({
51
+ registryRoot,
52
+ profileExtensions: [],
53
+ declaredOrchestrators: (0, orchestrators_1.collectAndWarn)(),
54
+ });
55
+ const tmpPath = path_1.default.join(path_1.default.dirname(skillDest), `.using-awm.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
56
+ fs_1.default.writeFileSync(tmpPath, ctx.markdown, 'utf-8');
57
+ fs_1.default.renameSync(tmpPath, skillDest);
58
+ }
22
59
  function isAwmEntry(entry, scriptsDir, matcher) {
23
60
  return (entry?.matcher === matcher &&
24
61
  Array.isArray(entry?.hooks) &&
@@ -39,19 +76,10 @@ function installClaudeHook(options) {
39
76
  fs_1.default.mkdirSync(config.scriptsDir, { recursive: true });
40
77
  (0, shared_1.syncExecutable)(path_1.default.join(sourceHooks, 'session-start'), path_1.default.join(config.scriptsDir, 'session-start'), options.installMethod);
41
78
  (0, shared_1.syncExecutable)(path_1.default.join(sourceHooks, 'run-hook.cmd'), path_1.default.join(config.scriptsDir, 'run-hook.cmd'), options.installMethod);
42
- // 3. Link the skill (default: symlink so 'awm update' propagates; fall back to copy if symlink is unavailable, e.g. Windows without Developer Mode)
79
+ // 3. Materializar el payload compuesto (using-awm + orquestadores declarados) ver
80
+ // writeMaterializedSkill arriba para por que esto ya no es un symlink al SKILL.md crudo.
43
81
  const skillDest = path_1.default.join(config.scriptsDir, 'using-awm.md');
44
- try {
45
- fs_1.default.unlinkSync(skillDest);
46
- }
47
- catch { /* not exists */ }
48
- try {
49
- fs_1.default.symlinkSync(sourceSkill, skillDest, 'file'); // ver shared.ts: el tipo no se infiere
50
- }
51
- catch {
52
- // best-effort: copy the single skill file; 'awm update' will not auto-propagate
53
- fs_1.default.copyFileSync(sourceSkill, skillDest);
54
- }
82
+ writeMaterializedSkill(skillDest, options.registryRoot);
55
83
  // 4. Backup settings if it exists
56
84
  const backupPath = (0, shared_1.backupManagedFile)(config.settingsPath);
57
85
  // 5. Read or initialize settings
@@ -178,29 +206,17 @@ function uninstallClaudeHook(agent) {
178
206
  /** Refresh the Claude hook's script/skill files in place (used by resync). Assumes the caller has verified the settings entry is already present. */
179
207
  function resyncClaudeHookFiles(config, registryRoot, method) {
180
208
  const sourceHooks = path_1.default.join(registryRoot, 'hooks');
181
- const sourceSkill = path_1.default.join(registryRoot, 'skills/using-awm/SKILL.md');
182
209
  fs_1.default.mkdirSync(config.scriptsDir, { recursive: true });
183
210
  (0, shared_1.syncExecutable)(path_1.default.join(sourceHooks, 'session-start'), path_1.default.join(config.scriptsDir, 'session-start'), method);
184
211
  (0, shared_1.syncExecutable)(path_1.default.join(sourceHooks, 'run-hook.cmd'), path_1.default.join(config.scriptsDir, 'run-hook.cmd'), method);
212
+ // Re-materializar el payload compuesto igual que `installClaudeHook` (arriba) — NO
213
+ // volver a symlinkear al SKILL.md crudo. Antes este era el segundo escritor del mismo
214
+ // archivo con el symlink-con-fallback-a-copia; si solo `installClaudeHook` se hubiera
215
+ // arreglado y este no, el primer `awm update` posterior a un install correcto habria
216
+ // sobreescrito el payload materializado con un symlink crudo de nuevo — reabriendo el
217
+ // bypass que esto existe para cerrar.
185
218
  const skillDest = path_1.default.join(config.scriptsDir, 'using-awm.md');
186
- try {
187
- fs_1.default.unlinkSync(skillDest);
188
- }
189
- catch { /* not exists */ }
190
- // Mismo fallback a copia que `installClaudeHook` (arriba). Sin el, en
191
- // Windows sin Developer Mode este symlink tira EPERM, `resyncInstalledHooks`
192
- // propaga el throw y `awm update` devuelve 1 — PARA SIEMPRE: el install
193
- // funcionaba (tenia el fallback) y el update no, en una plataforma que la
194
- // matriz de soporte declara verificada en CI. Dos escritores del mismo
195
- // archivo, solo uno endurecido.
196
- try {
197
- fs_1.default.symlinkSync(sourceSkill, skillDest, 'file'); // ver shared.ts: el tipo no se infiere
198
- }
199
- catch {
200
- // best-effort: `awm update` no auto-propagara cambios de esta skill,
201
- // pero el hook queda funcional en vez de dejar el comando inservible.
202
- fs_1.default.copyFileSync(sourceSkill, skillDest);
203
- }
219
+ writeMaterializedSkill(skillDest, registryRoot);
204
220
  }
205
221
  /** True when the registry has everything needed to resync the Claude hook files. */
206
222
  function claudeResyncSourcesExist(registryRoot) {
@@ -58,7 +58,7 @@ function registerHooksCommand(program) {
58
58
  console.log(` Scripts: ${result.scriptsDir}/session-start`);
59
59
  if (agent === 'claude-code') {
60
60
  console.log(` ${result.scriptsDir}/run-hook.cmd`);
61
- console.log(` ${result.scriptsDir}/using-awm.md ~/.awm/registries/baseline/skills/using-awm/SKILL.md`);
61
+ console.log(` ${result.scriptsDir}/using-awm.md (composed context, written)`);
62
62
  }
63
63
  console.log('');
64
64
  console.log(` Settings file: ${result.settingsPath}`);
@@ -108,7 +108,7 @@ function checkFile(file) {
108
108
  return { ok: true, detail: file };
109
109
  }
110
110
  catch {
111
- return { ok: false, detail: `broken link: ${file}` };
111
+ return { ok: false, detail: `cannot read file: ${file}` };
112
112
  }
113
113
  }
114
114
  /**
@@ -12,6 +12,7 @@ const simple_git_1 = __importDefault(require("simple-git"));
12
12
  const registries_1 = require("../../core/registries");
13
13
  const discovery_1 = require("../../core/discovery");
14
14
  const bundles_1 = require("../../core/bundles");
15
+ const orchestrators_1 = require("../../core/orchestrators");
15
16
  function deriveRegistryName(remote) {
16
17
  // Split on '/', '\' and ':' — not just '/' and ':'. A git remote URL
17
18
  // (https://…/repo.git, git@host:org/repo.git) only ever uses the first
@@ -66,6 +67,10 @@ async function addRegistry(remote, nameOverride) {
66
67
  fs_1.default.rmSync(dest, { recursive: true, force: true });
67
68
  return { ok: false, name, error: e instanceof Error ? e.message : String(e) };
68
69
  }
70
+ // Una declaracion de orquestador malformada se REPORTA, no aborta: el
71
+ // registry puede aportar skills utiles aunque su declaracion este rota,
72
+ // y abortar por eso invalidaria contenido sano (R1.2).
73
+ const { diagnostics: orchestratorDiagnostics } = (0, orchestrators_1.readDeclaredOrchestrators)(dest);
69
74
  (0, registries_1.writeRegistriesConfig)([...existing, { name, remote }]);
70
- return { ok: true, name, contentRoot: dest };
75
+ return { ok: true, name, contentRoot: dest, orchestratorDiagnostics };
71
76
  }
@@ -38,6 +38,9 @@ function registerRegistryCommand(program) {
38
38
  process.exit(1);
39
39
  }
40
40
  s.stop(`Registry ${picocolors_1.default.cyan(result.name)} added at ${result.contentRoot}`);
41
+ for (const d of result.orchestratorDiagnostics) {
42
+ console.warn(picocolors_1.default.yellow(` ⚠ ${d}`));
43
+ }
41
44
  try {
42
45
  (0, regenerate_1.regenerateGlobalContext)();
43
46
  }
@@ -85,6 +85,7 @@ async function resolveParsedPackCompatibility(cwd, pack, options = {}) {
85
85
  environment: variant.command.environment,
86
86
  configFiles: evidence.configFiles,
87
87
  scripts: evidence.scripts,
88
+ variantArgs: variant.command.args,
88
89
  })
89
90
  : null;
90
91
  sensors[name] = (0, resolve_1.resolveProjectCompatibility)({ ...executionPack, sensors: { [name]: sensor } }, { ...resolutionEvidence, probe: probe ?? undefined }).sensors[name];
@@ -24,11 +24,23 @@ function commandFor(kind, evidence) {
24
24
  };
25
25
  if (kind === 'version')
26
26
  return { ...command, args: ['--version'] };
27
+ // A pack asset is never named the tool's default config filename (e.g.
28
+ // `eslint.config.awm.mjs`, not `eslint.config.js`), so the real command always
29
+ // pins it via an explicit `--config <file>`. Mirroring that same pair here — rather
30
+ // than probing with a bare invocation — is what lets the probe find the config the
31
+ // real run will actually use, for whatever name or tool version is in play.
27
32
  if (kind === 'eslint-print-config')
28
- return { ...command, args: ['--print-config', evidence.configFiles?.[0] ?? 'package.json'] };
33
+ return { ...command, args: [...configFlag(evidence.variantArgs), '--print-config', evidence.configFiles?.[0] ?? 'package.json'] };
29
34
  if (kind === 'typescript-show-config')
30
- return { ...command, args: ['--showConfig'] };
31
- return { ...command, args: ['--validate'] };
35
+ return { ...command, args: [...configFlag(evidence.variantArgs), '--showConfig'] };
36
+ return { ...command, args: [...configFlag(evidence.variantArgs), '--validate'] };
37
+ }
38
+ /** The `--config <file>` pair from a variant's real command args, if it declares one. */
39
+ function configFlag(variantArgs) {
40
+ if (!variantArgs)
41
+ return [];
42
+ const i = variantArgs.indexOf('--config');
43
+ return i !== -1 && variantArgs[i + 1] !== undefined ? ['--config', variantArgs[i + 1]] : [];
32
44
  }
33
45
  /** Executes only the closed probe enum. Raw output is intentionally discarded. */
34
46
  async function runCompatibilityProbe(probe, evidence, executor = exec_1.runStructuredCommand) {
@@ -145,10 +145,14 @@ async function computeSensorStatus(cwd = process.cwd()) {
145
145
  const raw = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
146
146
  const parsed = (0, manifest_1.parseSensorManifest)(raw, manifestPath);
147
147
  if (parsed.kind === 'v2') {
148
+ // Monorepo support (mirrors run.ts/init.ts): detection and structured-command
149
+ // asset resolution both need to see the real package, not the (possibly
150
+ // unrelated) directory the manifest lives in.
151
+ const projectCwd = parsed.pack.packageRoot ? path_1.default.resolve(cwd, parsed.pack.packageRoot) : cwd;
148
152
  const checks = {};
149
153
  let compatibility;
150
154
  try {
151
- compatibility = resolveStaticV2Compatibility(cwd, parsed.pack);
155
+ compatibility = resolveStaticV2Compatibility(projectCwd, parsed.pack);
152
156
  }
153
157
  catch (error) {
154
158
  const detail = error instanceof Error ? error.message : 'live compatibility unavailable';
@@ -163,7 +167,7 @@ async function computeSensorStatus(cwd = process.cwd()) {
163
167
  continue;
164
168
  }
165
169
  checks[name] = staticCompatibilityCheck(sensor, compatibility[name])
166
- ?? checkStructuredCommand(sensor.command, cwd, sensor.assets);
170
+ ?? checkStructuredCommand(sensor.command, projectCwd, sensor.assets);
167
171
  }
168
172
  return { overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED', pack: parsed.pack.pack, checks };
169
173
  }
@@ -8,6 +8,7 @@ const config_instructions_1 = require("./strategies/config-instructions");
8
8
  const codex_agents_1 = require("./strategies/codex-agents");
9
9
  const provider_1 = require("./provider");
10
10
  const materializer_1 = require("./materializer");
11
+ const orchestrators_1 = require("../orchestrators");
11
12
  class InjectionOrchestrator {
12
13
  overrides;
13
14
  constructor(overrides = {}) {
@@ -41,7 +42,11 @@ class InjectionOrchestrator {
41
42
  }
42
43
  /** Full input: builds context from registry and materializes to disk. Used by installContext only. */
43
44
  inputFor(op) {
44
- const ctx = (0, provider_1.buildContext)({ registryRoot: op.registryRoot, profileExtensions: op.profileExtensions });
45
+ const ctx = (0, provider_1.buildContext)({
46
+ registryRoot: op.registryRoot,
47
+ profileExtensions: op.profileExtensions,
48
+ declaredOrchestrators: (0, orchestrators_1.collectAndWarn)(),
49
+ });
45
50
  const absPath = this.contextPathFor(op);
46
51
  const ref = (0, materializer_1.materialize)(ctx, absPath, op.scope);
47
52
  return {
@@ -66,7 +71,15 @@ class InjectionOrchestrator {
66
71
  const absPath = this.contextPathFor(op);
67
72
  let contentHash = '';
68
73
  try {
69
- const ctx = (0, provider_1.buildContext)({ registryRoot: op.registryRoot, profileExtensions: op.profileExtensions });
74
+ // Debe recolectar declarados igual que inputFor: si no, el hash "esperado" aqui
75
+ // diverge del hash realmente materializado por installContext en cuanto algun
76
+ // registry instalado declare un orquestador, y contextStatus reportaria 'stale'
77
+ // de forma permanente incluso justo despues de un install correcto.
78
+ const ctx = (0, provider_1.buildContext)({
79
+ registryRoot: op.registryRoot,
80
+ profileExtensions: op.profileExtensions,
81
+ declaredOrchestrators: (0, orchestrators_1.collectAndWarn)(),
82
+ });
70
83
  contentHash = ctx.contentHash;
71
84
  }
72
85
  catch (err) {
@@ -16,6 +16,33 @@ function parseVersion(skill) {
16
16
  const m = skill.match(/^version:\s*["']?([^"'\n]+)["']?\s*$/m);
17
17
  return m ? m[1].trim() : '0.0.0';
18
18
  }
19
+ /**
20
+ * Neutraliza contenido no confiable proveniente de registries declarados
21
+ * (name/appliesWhen/terminatesTo) antes de interpolarlo en markdown.
22
+ * Sin esto, un registry malicioso/comprometido podria inyectar saltos de
23
+ * linea, marcadores markdown (##, `, *, _) o pseudo-tags XML/HTML (<, >)
24
+ * para forjar una seccion nueva o un bloque instruccional dentro del
25
+ * payload de contexto que consume el proveedor de IA — un vector de
26
+ * prompt-injection. `readDeclaredOrchestrators` solo valida que los
27
+ * campos sean strings no vacios; el saneo pertenece a esta frontera de
28
+ * render, no a la validacion de lectura.
29
+ */
30
+ function sanitizeForMarkdown(s) {
31
+ return s.replace(/\r?\n/g, ' ').replace(/[`*_#<>]/g, '');
32
+ }
33
+ function renderDeclared(list) {
34
+ if (list.length === 0)
35
+ return '';
36
+ const rows = list
37
+ .map(o => {
38
+ const name = sanitizeForMarkdown(o.name);
39
+ const appliesWhen = sanitizeForMarkdown(o.appliesWhen);
40
+ const terminatesTo = sanitizeForMarkdown(o.terminatesTo);
41
+ return `- **${name}** — applies when: ${appliesWhen}. Terminates to: \`${terminatesTo}\`.`;
42
+ })
43
+ .join('\n');
44
+ return `## Declared orchestrators\n\nConsider these before the built-in pair:\n\n${rows}\n\n`;
45
+ }
19
46
  function buildContext(input) {
20
47
  const skillPath = path_1.default.join(input.registryRoot, 'skills/using-awm/SKILL.md');
21
48
  if (!fs_1.default.existsSync(skillPath)) {
@@ -24,6 +51,7 @@ function buildContext(input) {
24
51
  const skill = fs_1.default.readFileSync(skillPath, 'utf-8');
25
52
  const exts = input.profileExtensions.length ? input.profileExtensions.join(', ') : 'none';
26
53
  const header = `<!-- AWM context (generated) -->\n# AWM\n\nActive extensions: ${exts}\n\n`;
27
- const markdown = header + skill;
54
+ const declared = renderDeclared(input.declaredOrchestrators ?? []);
55
+ const markdown = header + declared + skill;
28
56
  return { markdown, sourceVersion: parseVersion(skill), contentHash: sha256(markdown) };
29
57
  }
@@ -13,11 +13,28 @@ exports.regenerateGlobalContext = regenerateGlobalContext;
13
13
  const fs_1 = __importDefault(require("fs"));
14
14
  const providers_1 = require("../../providers");
15
15
  const registries_1 = require("../registries");
16
+ const orchestrators_1 = require("../orchestrators");
16
17
  const orchestrator_1 = require("./orchestrator");
17
18
  function regenerateGlobalContext(targets = [...providers_1.AGENT_TARGETS], orch = new orchestrator_1.InjectionOrchestrator()) {
18
19
  const skillsRoot = (0, registries_1.capabilityRoot)('skills');
19
20
  if (!skillsRoot)
20
21
  return [];
22
+ // Print each declared-orchestrator diagnostic ONCE up front, rather than letting
23
+ // every agent's contextStatus/installContext call independently re-collect and
24
+ // re-warn via InjectionOrchestrator.inputFor/statusInputFor (both call
25
+ // orchestrators.ts's collectAndWarn() internally, unaware of this outer loop). A
26
+ // single `awm update` touching N agents (e.g. opencode + codex) would otherwise
27
+ // print the same diagnostic up to 2x per agent (once from statusInputFor, once
28
+ // from inputFor when stale) — noisy but not incorrect, since no data is lost or
29
+ // wrong. This only reduces the printing done BY THIS FUNCTION to one line per
30
+ // diagnostic; inputFor/statusInputFor still call collectAndWarn() internally per
31
+ // op (they need the declared list to build/hash context), so a per-agent
32
+ // duplicate can still print alongside this upfront one. Deeper dedup would mean
33
+ // threading a pre-collected list through ContextOp/InjectionOrchestrator, which
34
+ // touches shared collection-scope logic other callers (hooks/claude.ts) rely on —
35
+ // out of proportion for this minor finding.
36
+ for (const d of (0, orchestrators_1.collectDeclaredOrchestrators)().diagnostics)
37
+ console.warn(`warning: ${d}`);
21
38
  const out = [];
22
39
  for (const agent of targets) {
23
40
  const inj = (0, providers_1.providerFor)(agent).injection;
@@ -42,6 +59,18 @@ function regenerateGlobalContext(targets = [...providers_1.AGENT_TARGETS], orch
42
59
  if (inj.globalPath === null)
43
60
  continue;
44
61
  }
62
+ else if (inj.type === 'cc-settings-merge') {
63
+ // Claude Code no pasa por este dispatcher generico: su contexto se
64
+ // materializa via el hook dedicado (hooks/claude.ts's installClaudeHook/
65
+ // resyncClaudeHookFiles), invocado por `awm update` a traves de un path
66
+ // separado (hooks/resync.ts's resyncInstalledHooks). Mismo salteo que
67
+ // stepContextInjection (init/steps.ts) y contextGlobalCheck
68
+ // (diagnostics/provider-checks.ts) — "covered by hook". Sin este
69
+ // continue, orch.contextStatus/installContext corrian igual para
70
+ // claude-code y escribian un ~/.awm/context/awm-context.md huerfano que
71
+ // nadie lee (el archivo real vive en el scriptsDir del hook).
72
+ continue;
73
+ }
45
74
  const op = {
46
75
  agent,
47
76
  scope: 'global',
@@ -0,0 +1,142 @@
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
+ exports.readDeclaredOrchestrators = readDeclaredOrchestrators;
7
+ exports.collectDeclaredOrchestrators = collectDeclaredOrchestrators;
8
+ exports.collectAndWarn = collectAndWarn;
9
+ // cli/src/core/orchestrators.ts
10
+ // Lector de declaraciones de orquestador. A diferencia de readRegistryManifest
11
+ // (registries.ts), este parser NUNCA lanza: una declaracion malformada se
12
+ // rechaza y se reporta, sin invalidar el registry que la contiene ni a los
13
+ // demas (R1.2). El contrato admite exactamente cuatro campos — identidad,
14
+ // cuando aplica, y a quien cede el control — y rechaza cualquier otro, que
15
+ // es como se impide que vocabulario de un proceso concreto (o un secreto)
16
+ // entre al framework (R1.3, R5.3).
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const registries_1 = require("./registries");
20
+ const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
21
+ // These fields are semantically short (a short identity, a short trigger condition, a
22
+ // short target name) — no legitimate declaration needs more than this. A registry is
23
+ // untrusted input whose fields flow straight into the AI-provider context payload, so an
24
+ // unbounded string here would let a crafted registry bloat/DoS that context.
25
+ const MAX_FIELD_LENGTH = 500;
26
+ function readDeclaredOrchestrators(root) {
27
+ const file = path_1.default.join(root, registries_1.REGISTRY_MANIFEST_NAME);
28
+ // Shares the same trust boundary as readRegistryManifest (registries.ts): a manifest
29
+ // that is a symlink (or otherwise not a regular file) is rejected rather than followed.
30
+ // assertRegularRegistryFile throws on that case, so it's wrapped locally — this reader
31
+ // must never throw, only report (R1.2).
32
+ let exists;
33
+ try {
34
+ exists = (0, registries_1.assertRegularRegistryFile)(file);
35
+ }
36
+ catch (e) {
37
+ return { orchestrators: [], diagnostics: [`${file}: ${e instanceof Error ? e.message : String(e)}`] };
38
+ }
39
+ if (!exists)
40
+ return { orchestrators: [], diagnostics: [] };
41
+ let contents;
42
+ try {
43
+ contents = fs_1.default.readFileSync(file, 'utf-8');
44
+ }
45
+ catch (e) {
46
+ return { orchestrators: [], diagnostics: [`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`] };
47
+ }
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(contents);
51
+ }
52
+ catch (e) {
53
+ return { orchestrators: [], diagnostics: [`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`] };
54
+ }
55
+ const decl = raw?.orchestrator;
56
+ if (decl === undefined)
57
+ return { orchestrators: [], diagnostics: [] };
58
+ if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) {
59
+ return { orchestrators: [], diagnostics: [`${file}: "orchestrator" must be an object`] };
60
+ }
61
+ const problems = [];
62
+ const entries = decl;
63
+ for (const key of Object.keys(entries)) {
64
+ if (!ALLOWED_FIELDS.includes(key)) {
65
+ // key comes straight from an untrusted registry's JSON — JSON.stringify keeps the
66
+ // diagnostic single-line and unambiguous even if the key contains newlines or other
67
+ // control characters, which would otherwise let a crafted key forge extra log lines.
68
+ problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
69
+ }
70
+ }
71
+ for (const field of ALLOWED_FIELDS) {
72
+ const value = entries[field];
73
+ if (typeof value !== 'string' || value.trim() === '') {
74
+ problems.push(`"${field}" must be a non-empty string`);
75
+ }
76
+ else if (value.length > MAX_FIELD_LENGTH) {
77
+ problems.push(`"${field}" must be at most ${MAX_FIELD_LENGTH} characters`);
78
+ }
79
+ }
80
+ if (problems.length > 0) {
81
+ return { orchestrators: [], diagnostics: [`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`] };
82
+ }
83
+ return {
84
+ orchestrators: [{
85
+ name: entries.name,
86
+ appliesWhen: entries.appliesWhen,
87
+ terminatesTo: entries.terminatesTo,
88
+ }],
89
+ diagnostics: [],
90
+ };
91
+ }
92
+ /**
93
+ * Recolecta declaraciones de orquestador de TODOS los registries instalados (no solo
94
+ * el que se esta operando) y diagnosticos de las que estan rotas. Nunca lanza:
95
+ * `readDeclaredOrchestrators` ya garantiza eso por-registry (R1.2), asi que un registry
96
+ * con declaracion rota se omite del resultado sin impedir construir el contexto (R5.1).
97
+ *
98
+ * Vive aca (no en core/context/orchestrator.ts, que la definia originalmente) porque
99
+ * este modulo es una hoja: solo depende de `./registries`, que a su vez no depende de
100
+ * nada bajo commands/*. core/context/orchestrator.ts en cambio arrastra
101
+ * strategies/hook-merge.ts, que importa commands/hooks/install.ts — y claude.ts
102
+ * necesita esta funcion para cerrar el bypass del SKILL.md crudo (Task 6). Si
103
+ * `collectAndWarn` siguiera viviendo en orchestrator.ts, que commands/hooks/claude.ts
104
+ * la importara cerraria un ciclo real: claude.ts -> orchestrator.ts ->
105
+ * strategies/hook-merge.ts -> commands/hooks/install.ts -> claude.ts.
106
+ *
107
+ * Dedupe por "name" entre registries: dos registries instalados pueden declarar el mismo
108
+ * nombre (posiblemente con appliesWhen/terminatesTo distintos y contradictorios). En vez
109
+ * de emitir ambas filas al markdown compuesto, gana la primera en el orden de
110
+ * listRegistries() (= orden de registries.json, ver registries.ts) y la duplicada se
111
+ * descarta con un diagnostico — misma degradacion tolerante (reportar, no lanzar) que el
112
+ * resto de este modulo (R1.2, R5.1).
113
+ */
114
+ function collectDeclaredOrchestrators() {
115
+ const declared = [];
116
+ const diagnostics = [];
117
+ const seenNames = new Set();
118
+ for (const reg of (0, registries_1.listRegistries)()) {
119
+ const r = readDeclaredOrchestrators(reg.contentRoot);
120
+ for (const orch of r.orchestrators) {
121
+ if (seenNames.has(orch.name)) {
122
+ const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
123
+ diagnostics.push(`${file}: orchestrator "${orch.name}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
124
+ continue;
125
+ }
126
+ seenNames.add(orch.name);
127
+ declared.push(orch);
128
+ }
129
+ diagnostics.push(...r.diagnostics);
130
+ }
131
+ return { declared, diagnostics };
132
+ }
133
+ /** Recolecta declarados y emite sus diagnosticos como warnings. Punto unico usado por
134
+ * `InjectionOrchestrator.inputFor`/`statusInputFor` y por `commands/hooks/claude.ts`
135
+ * para que todos permanezcan sincronizados por construccion (ver R5.1 y el bug de
136
+ * staleness que motivo esta extraccion). */
137
+ function collectAndWarn() {
138
+ const { declared, diagnostics } = collectDeclaredOrchestrators();
139
+ for (const d of diagnostics)
140
+ console.warn(`warning: ${d}`);
141
+ return declared;
142
+ }
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
- describe('hooks/install — skill symlink fallback to copy', () => {
9
+ describe('hooks/install — symlink fallback to copy', () => {
10
10
  let tmpHome;
11
11
  let origHome;
12
12
  let origAwmHome;
@@ -40,10 +40,18 @@ describe('hooks/install — skill symlink fallback to copy', () => {
40
40
  fs_1.default.writeFileSync(path_1.default.join(hooksDir, 'run-hook.cmd'), '#!/bin/sh\n');
41
41
  fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), '# using-awm\n');
42
42
  }
43
- it('copies the skill when symlink throws (EPERM), preserving content', () => {
43
+ // Historical note: using-awm.md used to be installed via fs.symlinkSync with an
44
+ // EPERM fallback to a plain copy — this test used to exercise that fallback.
45
+ // Task 6 (writeMaterializedSkill, hooks/claude.ts) replaced the symlink entirely
46
+ // with a materialized write (buildContext() composed markdown via fs.writeFileSync,
47
+ // after an unlinkSync of any prior file): the skill file never routes through
48
+ // fs.symlinkSync at all anymore, for any installMethod. The EPERM mock is kept
49
+ // here specifically to prove that irrelevance — the assertions hold even with
50
+ // symlinkSync forced to throw, and the explicit "never called" check documents
51
+ // why: there's no fallback logic left to exercise for this file.
52
+ it('materializes the skill file — never attempts a symlink, so EPERM on symlinkSync never affects it', () => {
44
53
  const registryRoot = path_1.default.join(tmpHome, 'registry');
45
54
  seedRegistry(registryRoot);
46
- // Force symlinkSync to fail like a platform without symlink permission.
47
55
  symlinkSpy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => {
48
56
  const err = new Error('EPERM: operation not permitted, symlink');
49
57
  err.code = 'EPERM';
@@ -53,8 +61,9 @@ describe('hooks/install — skill symlink fallback to copy', () => {
53
61
  const result = installHook({ agent: 'claude-code', registryRoot, installMethod: 'copy' });
54
62
  const skillDest = path_1.default.join(result.scriptsDir, 'using-awm.md');
55
63
  expect(fs_1.default.existsSync(skillDest)).toBe(true);
56
- expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false); // it was copied, not linked
64
+ expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false); // materialized, not linked
57
65
  expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('using-awm');
66
+ expect(symlinkSpy).not.toHaveBeenCalled(); // proves the EPERM mock above was moot
58
67
  });
59
68
  // Regression: syncExecutable (shared.ts) — used for the hook SCRIPT files
60
69
  // (session-start, run-hook.cmd), not just the bootstrap skill above — called