agentic-workflow-manager 8.2.1 → 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.
- package/dist/src/commands/hooks/claude.js +47 -31
- package/dist/src/commands/hooks/index.js +1 -1
- package/dist/src/commands/hooks/shared.js +1 -1
- package/dist/src/commands/registry/add.js +6 -1
- package/dist/src/commands/registry/index.js +3 -0
- package/dist/src/core/context/orchestrator.js +15 -2
- package/dist/src/core/context/provider.js +29 -1
- package/dist/src/core/context/regenerate.js +29 -0
- package/dist/src/core/orchestrators.js +142 -0
- package/dist/tests/commands/hooks/install-symlink-fallback.test.js +13 -4
- package/dist/tests/commands/hooks/install.test.js +121 -3
- package/dist/tests/commands/hooks/resync.test.js +40 -2
- package/dist/tests/commands/registry/add.test.js +104 -0
- package/dist/tests/core/context/orchestrator.test.js +86 -0
- package/dist/tests/core/context/provider.test.js +137 -0
- package/dist/tests/core/context/regenerate.test.js +56 -0
- package/dist/tests/core/orchestrators.test.js +236 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}`);
|
|
@@ -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
|
}
|
|
@@ -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)({
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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); //
|
|
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
|
|
@@ -51,7 +51,10 @@ describe('installHook (happy path + merge)', () => {
|
|
|
51
51
|
const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
|
|
52
52
|
expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'session-start'))).toBe(true);
|
|
53
53
|
expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'run-hook.cmd'))).toBe(true);
|
|
54
|
-
|
|
54
|
+
// Task 6: using-awm.md is now a materialized file (buildContext's output), not a
|
|
55
|
+
// symlink to the raw SKILL.md — so declared orchestrators actually reach Claude Code.
|
|
56
|
+
expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(false);
|
|
57
|
+
expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'using-awm.md'), 'utf-8')).toContain('MUST invoke skills.');
|
|
55
58
|
const settings = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.claude/settings.json'), 'utf-8'));
|
|
56
59
|
expect(settings.hooks.SessionStart).toHaveLength(1);
|
|
57
60
|
expect(settings.hooks.SessionStart[0].matcher).toBe('startup|clear|compact');
|
|
@@ -135,11 +138,126 @@ describe('installHook (happy path + merge)', () => {
|
|
|
135
138
|
// Did not create settings.json
|
|
136
139
|
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude/settings.json'))).toBe(false);
|
|
137
140
|
});
|
|
138
|
-
|
|
141
|
+
// Task 6: using-awm.md is materialized (buildContext's composed output), never a
|
|
142
|
+
// symlink, regardless of installMethod — superseding the pre-Task-6 "UX choice" of
|
|
143
|
+
// always symlinking this one file even under installMethod 'copy'.
|
|
144
|
+
it('materializes using-awm.md (never a symlink) even when installMethod is copy', () => {
|
|
139
145
|
const { installHook } = require('../../../src/commands/hooks/install');
|
|
140
146
|
installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'copy' });
|
|
141
147
|
const skillPath = path_1.default.join(tmpHome, '.awm/hooks/using-awm.md');
|
|
142
|
-
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(
|
|
148
|
+
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
149
|
+
expect(fs_1.default.readFileSync(skillPath, 'utf-8')).toContain('MUST invoke skills.');
|
|
150
|
+
});
|
|
151
|
+
// Non-regression net (Task 5, Step 1): fixes the hook's observable
|
|
152
|
+
// contract before Task 6 replaces the symlink with a materialized
|
|
153
|
+
// file write in claude.ts. Verifies R6.1.
|
|
154
|
+
it('el hook queda apuntando a un archivo legible con el contenido de using-awm', () => {
|
|
155
|
+
const { installHook } = require('../../../src/commands/hooks/install');
|
|
156
|
+
installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'symlink' });
|
|
157
|
+
const skillDest = path_1.default.join(tmpHome, '.awm/hooks/using-awm.md');
|
|
158
|
+
expect(fs_1.default.existsSync(skillDest)).toBe(true);
|
|
159
|
+
const content = fs_1.default.readFileSync(skillDest, 'utf-8');
|
|
160
|
+
expect(content).toContain('MUST invoke skills.');
|
|
161
|
+
});
|
|
162
|
+
// Task 6, Step 1: closes the bypass — everything buildContext composes (declared
|
|
163
|
+
// orchestrators, Tasks 1-4) must actually reach Claude Code's using-awm.md, not just
|
|
164
|
+
// the raw SKILL.md. Verifies R1.1.
|
|
165
|
+
//
|
|
166
|
+
// Investigation note: the plan's literal sample writes `awm-registry.json` straight
|
|
167
|
+
// into `tmpRegistry` and expects `collectAndWarn()` to pick it up via `listRegistries()`
|
|
168
|
+
// — but `listRegistries()` reads registries.json under AWM_HOME, and `tmpRegistry` here
|
|
169
|
+
// is a bare mkdtemp dir, never registered there via `awm registry add`. Writing the
|
|
170
|
+
// manifest into an unregistered dir would leave `declared` empty and this test green
|
|
171
|
+
// for the wrong reason (or red for the wrong reason, pre-fix). Instead this test
|
|
172
|
+
// registers a REAL listed registry via `writeRegistriesConfig` + `registryContentRoot`
|
|
173
|
+
// (the same pattern `tests/core/context/orchestrator.test.ts` already uses for this
|
|
174
|
+
// exact situation) and installs FROM that registry, so `options.registryRoot` and the
|
|
175
|
+
// one entry `listRegistries()` returns are the same directory — exercising the real
|
|
176
|
+
// collection path end to end.
|
|
177
|
+
it('el hook recibe los orquestadores declarados, no el SKILL.md crudo', () => {
|
|
178
|
+
const { installHook } = require('../../../src/commands/hooks/install');
|
|
179
|
+
const { writeRegistriesConfig, registryContentRoot } = require('../../../src/core/registries');
|
|
180
|
+
writeRegistriesConfig([{ name: 'declaring-test', remote: 'unused' }]);
|
|
181
|
+
const registryRoot = registryContentRoot('declaring-test');
|
|
182
|
+
const regHooks = path_1.default.join(registryRoot, 'hooks');
|
|
183
|
+
const regSkill = path_1.default.join(registryRoot, 'skills/using-awm');
|
|
184
|
+
fs_1.default.mkdirSync(regHooks, { recursive: true });
|
|
185
|
+
fs_1.default.mkdirSync(regSkill, { recursive: true });
|
|
186
|
+
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
187
|
+
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
188
|
+
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
189
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
190
|
+
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
191
|
+
const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
|
|
192
|
+
expect(content).toContain('mi-proceso'); // la composicion LLEGA a Claude Code
|
|
193
|
+
expect(content).toContain('MUST invoke skills.'); // y el skill sigue entero
|
|
194
|
+
});
|
|
195
|
+
// Finding 2 (code quality review, Task 6): regression-locks what the reviewer verified
|
|
196
|
+
// by hand — a pre-Task-6 install left using-awm.md as a REAL symlink to the registry's
|
|
197
|
+
// raw SKILL.md. writeMaterializedSkill() must fs.unlinkSync() that symlink (removing
|
|
198
|
+
// only the directory entry) before writing the materialized file, never dereference it
|
|
199
|
+
// and clobber the registry's own SKILL.md.
|
|
200
|
+
it('migrates a pre-Task-6 symlinked using-awm.md to a materialized file without touching the registry SKILL.md', () => {
|
|
201
|
+
const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
|
|
202
|
+
fs_1.default.mkdirSync(scriptsDir, { recursive: true });
|
|
203
|
+
const skillDest = path_1.default.join(scriptsDir, 'using-awm.md');
|
|
204
|
+
const registrySkillPath = path_1.default.join(tmpRegistry, 'skills/using-awm/SKILL.md');
|
|
205
|
+
const originalSkillContent = fs_1.default.readFileSync(registrySkillPath, 'utf-8');
|
|
206
|
+
fs_1.default.symlinkSync(registrySkillPath, skillDest, 'file');
|
|
207
|
+
expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(true);
|
|
208
|
+
const { installHook } = require('../../../src/commands/hooks/install');
|
|
209
|
+
installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'symlink' });
|
|
210
|
+
expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false);
|
|
211
|
+
expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('MUST invoke skills.');
|
|
212
|
+
// The registry's own SKILL.md must be untouched — proves unlinkSync removed only
|
|
213
|
+
// the directory entry and never dereferenced/deleted the symlink's target.
|
|
214
|
+
expect(fs_1.default.existsSync(registrySkillPath)).toBe(true);
|
|
215
|
+
expect(fs_1.default.readFileSync(registrySkillPath, 'utf-8')).toBe(originalSkillContent);
|
|
216
|
+
});
|
|
217
|
+
// Finding 2 (post-implementation-qa, Release 2): R5.1's fail-safe guarantee (a broken
|
|
218
|
+
// declaration in one registry never blocks context construction for the others) was
|
|
219
|
+
// already regression-tested for the generic InjectionOrchestrator/opencode path
|
|
220
|
+
// (tests/core/context/orchestrator.test.ts, 'installContext still succeeds when a
|
|
221
|
+
// DIFFERENT installed registry has a broken declaration') but not through
|
|
222
|
+
// installClaudeHook/resyncClaudeHookFiles — Task 6's own highest-risk change. Mirrors
|
|
223
|
+
// that test's two-registries setup (one valid orchestrator declaration, one broken
|
|
224
|
+
// JSON) via writeRegistriesConfig/registryContentRoot, same as 'el hook recibe los
|
|
225
|
+
// orquestadores declarados...' above, but installs from the VALID registry and asserts
|
|
226
|
+
// the broken sibling never surfaces as a thrown error.
|
|
227
|
+
it('installHook succeeds and materializes the valid registry\'s orchestrator when a sibling registry has a broken declaration', () => {
|
|
228
|
+
const { installHook } = require('../../../src/commands/hooks/install');
|
|
229
|
+
const { writeRegistriesConfig, registryContentRoot } = require('../../../src/core/registries');
|
|
230
|
+
writeRegistriesConfig([
|
|
231
|
+
{ name: 'broken-sibling', remote: 'unused' },
|
|
232
|
+
{ name: 'valid-declaring', remote: 'unused' },
|
|
233
|
+
]);
|
|
234
|
+
// Broken sibling: unparsable awm-registry.json. No hooks/skill content needed —
|
|
235
|
+
// it is never the registryRoot passed to installHook, only a sibling
|
|
236
|
+
// collectAndWarn() walks while gathering declared orchestrators.
|
|
237
|
+
const brokenRoot = registryContentRoot('broken-sibling');
|
|
238
|
+
fs_1.default.mkdirSync(brokenRoot, { recursive: true });
|
|
239
|
+
fs_1.default.writeFileSync(path_1.default.join(brokenRoot, 'awm-registry.json'), '{ not json');
|
|
240
|
+
// Valid registry: the one actually installed from.
|
|
241
|
+
const registryRoot = registryContentRoot('valid-declaring');
|
|
242
|
+
const regHooks = path_1.default.join(registryRoot, 'hooks');
|
|
243
|
+
const regSkill = path_1.default.join(registryRoot, 'skills/using-awm');
|
|
244
|
+
fs_1.default.mkdirSync(regHooks, { recursive: true });
|
|
245
|
+
fs_1.default.mkdirSync(regSkill, { recursive: true });
|
|
246
|
+
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
247
|
+
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
248
|
+
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
249
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
250
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
251
|
+
let result;
|
|
252
|
+
expect(() => {
|
|
253
|
+
result = installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
254
|
+
}).not.toThrow();
|
|
255
|
+
expect(result.status).toBe('installed');
|
|
256
|
+
const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
|
|
257
|
+
expect(content).toContain('proceso-valido'); // valid registry's declared orchestrator reached Claude Code
|
|
258
|
+
expect(content).toContain('MUST invoke skills.'); // and the skill body is intact
|
|
259
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('warning:')); // broken sibling warned, not thrown
|
|
260
|
+
warnSpy.mockRestore();
|
|
143
261
|
});
|
|
144
262
|
it('throws for unsupported agent target', () => {
|
|
145
263
|
const { installHook } = require('../../../src/commands/hooks/install');
|