@sabaiway/agent-workflow-kit 1.10.0 → 1.12.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/CHANGELOG.md +68 -0
- package/README.md +12 -6
- package/SKILL.md +59 -12
- package/bin/install.mjs +117 -14
- package/bin/install.test.mjs +128 -5
- package/capability.json +5 -1
- package/package.json +1 -1
- package/tools/engine-source.mjs +115 -0
- package/tools/engine-source.test.mjs +182 -0
- package/tools/family-registry.mjs +250 -0
- package/tools/family-registry.test.mjs +189 -0
- package/tools/fs-safe.mjs +54 -2
- package/tools/fs-safe.test.mjs +146 -0
- package/tools/inject-methodology.mjs +51 -7
- package/tools/inject-methodology.test.mjs +157 -12
- package/tools/manifest/validate.mjs +3 -1
- package/tools/uninstall.integration.test.mjs +144 -0
- package/tools/uninstall.mjs +420 -0
- package/tools/uninstall.test.mjs +372 -0
- package/references/planning.md +0 -105
- package/tools/methodology-slot.md +0 -1
package/bin/install.test.mjs
CHANGED
|
@@ -7,11 +7,16 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
|
+
import { engineInstallArgv, installEngine, ENGINE_PACKAGE } from './install.mjs';
|
|
11
|
+
|
|
10
12
|
const INSTALLER = join(dirname(fileURLToPath(import.meta.url)), 'install.mjs');
|
|
11
13
|
const KIT_ROOT = dirname(dirname(INSTALLER));
|
|
12
|
-
// --no-launchers so the test never wires Codex/Devin on the host
|
|
14
|
+
// --no-launchers so the test never wires Codex/Devin on the host; --no-engine so a full install never
|
|
15
|
+
// spawns a real `npx … agent-workflow-engine init` (network + a write to the real engine dir). The
|
|
16
|
+
// dedicated engine-step tests cover that path in-process / via a deliberately-broken PATH. `extra`
|
|
17
|
+
// appends flags (e.g. --force, --allow-downgrade).
|
|
13
18
|
const runInstaller = (target, extra = []) =>
|
|
14
|
-
spawnSync(process.execPath, [INSTALLER, '--dir', target, '--no-launchers', ...extra], { encoding: 'utf8' });
|
|
19
|
+
spawnSync(process.execPath, [INSTALLER, '--dir', target, '--no-launchers', '--no-engine', ...extra], { encoding: 'utf8' });
|
|
15
20
|
|
|
16
21
|
// Rewrite / read the installed skill's frontmatter version — used to simulate "a newer kit is already
|
|
17
22
|
// installed" (the stale-cache downgrade scenario). The installer reads exactly this field.
|
|
@@ -44,6 +49,21 @@ describe('kit installer — payload + symlink-traversal hardening', () => {
|
|
|
44
49
|
assert.equal(existsSync(join(target, 'bin/install.mjs')), false);
|
|
45
50
|
});
|
|
46
51
|
|
|
52
|
+
it('removes retired mirror files an older install left behind (single source of truth on upgrade)', async () => {
|
|
53
|
+
const target = join(dir, 'agent-workflow-kit');
|
|
54
|
+
// Seed a pre-3D install carrying the now-retired bundled mirror files.
|
|
55
|
+
await mkdir(join(target, 'references'), { recursive: true });
|
|
56
|
+
await mkdir(join(target, 'tools'), { recursive: true });
|
|
57
|
+
await writeFile(join(target, 'references', 'planning.md'), 'stale mirror\n');
|
|
58
|
+
await writeFile(join(target, 'tools', 'methodology-slot.md'), 'stale mirror\n');
|
|
59
|
+
const res = runInstaller(target);
|
|
60
|
+
assert.equal(res.status, 0, res.stderr);
|
|
61
|
+
assert.equal(existsSync(join(target, 'references', 'planning.md')), false, 'retired references/planning.md removed');
|
|
62
|
+
assert.equal(existsSync(join(target, 'tools', 'methodology-slot.md')), false, 'retired tools/methodology-slot.md removed');
|
|
63
|
+
assert.ok(existsSync(join(target, 'SKILL.md')), 'the real payload is still installed');
|
|
64
|
+
assert.ok(existsSync(join(target, 'references', 'contracts.md')), 'non-retired references/ content survives');
|
|
65
|
+
});
|
|
66
|
+
|
|
47
67
|
it('refuses to write through a symlinked INTERMEDIATE dest component (no leak)', async () => {
|
|
48
68
|
const target = join(dir, 'target');
|
|
49
69
|
const evil = join(dir, 'evil');
|
|
@@ -96,7 +116,7 @@ describe('kit installer — runs through the npx bin symlink', () => {
|
|
|
96
116
|
const shim = join(dir, 'agent-workflow-kit'); // stands in for node_modules/.bin/<name>
|
|
97
117
|
await symlink(INSTALLER, shim);
|
|
98
118
|
const target = join(dir, 'home', 'agent-workflow-kit');
|
|
99
|
-
const res = spawnSync(process.execPath, [shim, '--dir', target, '--no-launchers'], { encoding: 'utf8' });
|
|
119
|
+
const res = spawnSync(process.execPath, [shim, '--dir', target, '--no-launchers', '--no-engine'], { encoding: 'utf8' });
|
|
100
120
|
assert.equal(res.status, 0, res.stderr);
|
|
101
121
|
assert.match(res.stdout, /installed v|updated the kit/);
|
|
102
122
|
assert.ok(existsSync(join(target, 'SKILL.md')), 'install through the symlink must write the payload');
|
|
@@ -211,8 +231,107 @@ describe('kit installer — stale-cache defenses (no network)', () => {
|
|
|
211
231
|
});
|
|
212
232
|
});
|
|
213
233
|
|
|
214
|
-
describe('kit installer —
|
|
215
|
-
it('
|
|
234
|
+
describe('kit installer — mandatory engine install dispatch (Plan 3D, in-process, no network)', () => {
|
|
235
|
+
it('engineInstallArgv: `npx @…/engine@latest init` on POSIX, `npx.cmd` on win32, no shell:true', () => {
|
|
236
|
+
const posix = engineInstallArgv('linux');
|
|
237
|
+
assert.equal(posix.command, 'npx');
|
|
238
|
+
assert.deepEqual(posix.args, [`${ENGINE_PACKAGE}@latest`, 'init']);
|
|
239
|
+
assert.equal(posix.options.shell, undefined, 'must not spawn through a shell');
|
|
240
|
+
assert.equal(engineInstallArgv('darwin').command, 'npx');
|
|
241
|
+
assert.equal(engineInstallArgv('win32').command, 'npx.cmd');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('installEngine: first attempt succeeds → ok, runner called once (no retry)', () => {
|
|
245
|
+
let calls = 0;
|
|
246
|
+
const res = installEngine('linux', () => {
|
|
247
|
+
calls += 1;
|
|
248
|
+
return { status: 0 };
|
|
249
|
+
});
|
|
250
|
+
assert.deepEqual(res, { ok: true });
|
|
251
|
+
assert.equal(calls, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const NO_SLEEP = { sleep: () => {} }; // skip the real backoff so the suite never actually waits
|
|
255
|
+
|
|
256
|
+
it('installEngine: fail once then succeed → backoff + retried exactly once, ends ok (D1)', () => {
|
|
257
|
+
let calls = 0;
|
|
258
|
+
let slept = 0;
|
|
259
|
+
const res = installEngine('linux', () => {
|
|
260
|
+
calls += 1;
|
|
261
|
+
return { status: calls === 1 ? 1 : 0 };
|
|
262
|
+
}, { sleep: () => { slept += 1; } });
|
|
263
|
+
assert.deepEqual(res, { ok: true });
|
|
264
|
+
assert.equal(calls, 2);
|
|
265
|
+
assert.equal(slept, 1, 'backoff runs exactly once, before the single retry');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('installEngine: fails twice → not ok (D1 hard-failure outcome), no third attempt', () => {
|
|
269
|
+
let calls = 0;
|
|
270
|
+
const res = installEngine('linux', () => {
|
|
271
|
+
calls += 1;
|
|
272
|
+
return { status: 1 };
|
|
273
|
+
}, NO_SLEEP);
|
|
274
|
+
assert.deepEqual(res, { ok: false });
|
|
275
|
+
assert.equal(calls, 2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('installEngine: a spawn error (npx not found) counts as a failure', () => {
|
|
279
|
+
const res = installEngine('linux', () => ({ status: null, error: new Error('spawn npx ENOENT') }), NO_SLEEP);
|
|
280
|
+
assert.deepEqual(res, { ok: false });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('installEngine: hands the runner the exact descriptor (command/args/options, no shell)', () => {
|
|
284
|
+
let received;
|
|
285
|
+
installEngine('win32', (d) => {
|
|
286
|
+
received = d;
|
|
287
|
+
return { status: 0 };
|
|
288
|
+
});
|
|
289
|
+
assert.equal(received.command, 'npx.cmd');
|
|
290
|
+
assert.deepEqual(received.args, [`${ENGINE_PACKAGE}@latest`, 'init']);
|
|
291
|
+
assert.equal(received.options.shell, undefined);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('kit installer — mandatory engine install (subprocess)', () => {
|
|
296
|
+
let dir;
|
|
297
|
+
beforeEach(async () => {
|
|
298
|
+
dir = await mkdtemp(join(tmpdir(), 'aw-kit-engine-'));
|
|
299
|
+
});
|
|
300
|
+
afterEach(async () => {
|
|
301
|
+
await rm(dir, { recursive: true, force: true });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('--no-engine skips the engine install and prints the live-read STOP note (exit 0)', () => {
|
|
305
|
+
const target = join(dir, 'agent-workflow-kit'); // runInstaller appends --no-engine by default
|
|
306
|
+
const res = runInstaller(target);
|
|
307
|
+
assert.equal(res.status, 0, res.stderr);
|
|
308
|
+
assert.match(res.stdout, /--no-engine: skipped installing the methodology engine/);
|
|
309
|
+
assert.match(res.stdout, /@latest init/);
|
|
310
|
+
assert.match(res.stdout, /installed v|updated the kit/, 'the kit itself still installs');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('D1: when `npx` cannot run (both attempts fail) → nonzero exit, recommendations, success block NOT printed first', async () => {
|
|
314
|
+
// Force both engine-install attempts to fail deterministically WITHOUT network: run a real install
|
|
315
|
+
// (no --no-engine) with an empty PATH so `npx` resolves to ENOENT. The kit copy + the version gate
|
|
316
|
+
// do not need PATH; only the engine spawn does. This exercises the D1 loud-error + nonzero exit.
|
|
317
|
+
const target = join(dir, 'agent-workflow-kit');
|
|
318
|
+
const emptyBin = join(dir, 'emptybin');
|
|
319
|
+
await mkdir(emptyBin, { recursive: true });
|
|
320
|
+
const res = spawnSync(process.execPath, [INSTALLER, '--dir', target, '--no-launchers'], {
|
|
321
|
+
encoding: 'utf8',
|
|
322
|
+
env: { ...process.env, PATH: emptyBin },
|
|
323
|
+
});
|
|
324
|
+
assert.notEqual(res.status, 0, 'an engine-install failure must exit nonzero');
|
|
325
|
+
assert.match(res.stderr, /FAILED to install the methodology engine/);
|
|
326
|
+
assert.match(res.stderr, /@latest init/, 'recommends the manual engine install');
|
|
327
|
+
assert.match(res.stderr, /--no-engine/, 'recommends the opt-out');
|
|
328
|
+
assert.doesNotMatch(res.stdout, /Next — open your agent/, 'must NOT claim success before the engine failed');
|
|
329
|
+
assert.ok(existsSync(join(target, 'SKILL.md')), 'the kit itself is still on disk (recovery is one step)');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('kit installer — published tarball bundles the bridges + the live-read tool', () => {
|
|
334
|
+
it('npm pack ships bridges/<name>/ and tools/engine-source.mjs', () => {
|
|
216
335
|
// The real `files` whitelist decides what publishes — assert against `npm pack`, not the source
|
|
217
336
|
// tree, so a dropped `bridges/` entry in package.json fails here (not silently at install time).
|
|
218
337
|
const res = spawnSync('npm', ['pack', '--dry-run', '--json'], { cwd: KIT_ROOT, encoding: 'utf8' });
|
|
@@ -220,5 +339,9 @@ describe('kit installer — published tarball bundles the bridges', () => {
|
|
|
220
339
|
const paths = JSON.parse(res.stdout)[0].files.map((f) => f.path);
|
|
221
340
|
assert.ok(paths.includes('bridges/codex-cli-bridge/SKILL.md'), 'codex bridge SKILL.md not packed');
|
|
222
341
|
assert.ok(paths.includes('bridges/antigravity-cli-bridge/bin/agy.sh'), 'antigravity agy.sh not packed');
|
|
342
|
+
assert.ok(paths.includes('tools/engine-source.mjs'), 'the live-read resolver must ship in the tarball');
|
|
343
|
+
// The retired mirror must NOT ship — the whole point of Plan 3D is one source of truth.
|
|
344
|
+
assert.ok(!paths.includes('tools/methodology-slot.md'), 'retired mirror tools/methodology-slot.md must not be packed');
|
|
345
|
+
assert.ok(!paths.includes('references/planning.md'), 'retired mirror references/planning.md must not be packed');
|
|
223
346
|
});
|
|
224
347
|
});
|
package/capability.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"name": "agent-workflow-kit",
|
|
5
5
|
"kind": "composition-root",
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.12.0",
|
|
7
7
|
"provides": [],
|
|
8
8
|
"roles": {},
|
|
9
9
|
"detect": {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"deployed": { "file": "docs/ai/.workflow-version" }
|
|
16
16
|
},
|
|
17
17
|
"install": { "npm": "@sabaiway/agent-workflow-kit" },
|
|
18
|
+
"uninstall": {
|
|
19
|
+
"removeResolved": "detect.installed",
|
|
20
|
+
"note": "the guarded `/agent-workflow-kit uninstall` removes the resolved detect.installed dir — never user-authored docs/ai (those are reported, not deleted)"
|
|
21
|
+
},
|
|
18
22
|
"cost": "none",
|
|
19
23
|
"quota": null,
|
|
20
24
|
"provenance": { "author": "sabaiway", "source": "github:sabaiway/agent-workflow" }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live methodology-engine source resolution — the kit reads the bounded methodology fragment
|
|
3
|
+
// LIVE from the installed agent-workflow-engine (the family's one source of truth), via the same
|
|
4
|
+
// `detect.installed` idiom the kit already uses to find memory (detectMemory in delegation.mjs),
|
|
5
|
+
// NOT an npm `dependencies` edge (the family DAG: the kit DETECTS siblings, it never imports them).
|
|
6
|
+
//
|
|
7
|
+
// resolveEngineDir({ env, home }) → { dir, source } env override vs the ~/.claude default
|
|
8
|
+
// detectEngine(dir, { source }) → { ok, reason, dir } runs the kit's OWN validator
|
|
9
|
+
// readEngineFragment(dir, { source }) → fragment string, or THROWS a loud install-me error
|
|
10
|
+
//
|
|
11
|
+
// Fail-closed: readEngineFragment never falls back to a bundled copy (there is none after the
|
|
12
|
+
// mirror retirement) — when the engine is needed but absent/invalid it throws with the exact
|
|
13
|
+
// remediation, so the reconcile STOPs loudly rather than silently dropping the slot (AGENTS.md:
|
|
14
|
+
// no silent failures). Pure-where-possible (fs + validator injectable for tests), Node >= 18.
|
|
15
|
+
|
|
16
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { validateManifest, VALID } from './manifest/validate.mjs';
|
|
19
|
+
|
|
20
|
+
// The engine's detect.installed contract (agent-workflow-engine/capability.json): the env override,
|
|
21
|
+
// the ~/.claude default home, the declared skill name, and the in-skill path of the live fragment.
|
|
22
|
+
export const ENGINE_ENV = 'AGENT_WORKFLOW_ENGINE_DIR';
|
|
23
|
+
export const EXPECTED_ENGINE_NAME = 'agent-workflow-engine';
|
|
24
|
+
export const ENGINE_FRAGMENT_REL = 'references/methodology-slot.md';
|
|
25
|
+
const ENGINE_DEFAULT_REL = '.claude/skills/agent-workflow-engine';
|
|
26
|
+
|
|
27
|
+
const defaultStatType = (path) => {
|
|
28
|
+
try {
|
|
29
|
+
const s = statSync(path);
|
|
30
|
+
return s.isDirectory() ? 'dir' : s.isFile() ? 'file' : 'other';
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Resolve the installed engine dir from the env override (if set) or the ~/.claude default,
|
|
37
|
+
// mirroring the engine's `detect.installed`. `source` is load-bearing: a missing dir is reported as
|
|
38
|
+
// `env-set-but-missing` ONLY when source === 'env' (the user pointed us somewhere that is not there),
|
|
39
|
+
// which cannot be derived from `dir` alone.
|
|
40
|
+
export const resolveEngineDir = ({ env = {}, home = '' } = {}) => {
|
|
41
|
+
const fromEnv = env[ENGINE_ENV];
|
|
42
|
+
if (typeof fromEnv === 'string' && fromEnv) return { dir: fromEnv, source: 'env' };
|
|
43
|
+
return { dir: join(home, ENGINE_DEFAULT_REL), source: 'default' };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Decide whether the resolved dir is a usable methodology engine. Runs the kit's OWN validator
|
|
47
|
+
// (never a candidate-shipped one), and clones detectMemory's reason ladder so each failure has a
|
|
48
|
+
// distinct, actionable reason. ok only on: a dir that exists + VALID manifest + kind
|
|
49
|
+
// methodology-engine + the right name + available + the live fragment file present.
|
|
50
|
+
export const detectEngine = (engineDir, { source } = {}, deps = {}) => {
|
|
51
|
+
const validate = deps.validate ?? validateManifest;
|
|
52
|
+
const statType = deps.statType ?? defaultStatType;
|
|
53
|
+
|
|
54
|
+
// The dir itself must exist first — this is what lets an env-pointed-but-absent dir read as the
|
|
55
|
+
// distinct `env-set-but-missing` (validateManifest would only say "capability.json not found").
|
|
56
|
+
if (statType(engineDir) !== 'dir') {
|
|
57
|
+
const reason =
|
|
58
|
+
source === 'env'
|
|
59
|
+
? `engine dir from ${ENGINE_ENV} is missing or not a directory (env-set-but-missing): ${engineDir}`
|
|
60
|
+
: `engine not installed at ${engineDir}`;
|
|
61
|
+
return { ok: false, reason, dir: engineDir };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The validator does an UNGUARDED read of the candidate's SKILL.md for its version source, so a
|
|
65
|
+
// corrupt engine (e.g. SKILL.md is a directory → EISDIR) makes validateManifest THROW. Treat any
|
|
66
|
+
// validator throw as `invalid` so the failure still flows through readEngineFragment's stable
|
|
67
|
+
// "methodology engine not found/invalid" message + install command — never a raw fs error.
|
|
68
|
+
const report = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return validate(engineDir);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { result: 'invalid', errors: [`validator threw: ${err?.message ?? err}`] };
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
const fragmentPresent = statType(join(engineDir, ENGINE_FRAGMENT_REL)) === 'file';
|
|
76
|
+
const ok =
|
|
77
|
+
report.result === VALID &&
|
|
78
|
+
report.kind === 'methodology-engine' &&
|
|
79
|
+
report.name === EXPECTED_ENGINE_NAME &&
|
|
80
|
+
report.available !== false &&
|
|
81
|
+
fragmentPresent;
|
|
82
|
+
const reason = ok
|
|
83
|
+
? 'engine manifest valid (kind: methodology-engine) and the live fragment is present'
|
|
84
|
+
: report.result !== VALID
|
|
85
|
+
? `engine manifest ${report.result} at ${engineDir}`
|
|
86
|
+
: report.kind !== 'methodology-engine'
|
|
87
|
+
? `engine manifest kind "${report.kind}" is not methodology-engine`
|
|
88
|
+
: report.name !== EXPECTED_ENGINE_NAME
|
|
89
|
+
? `engine manifest name "${report.name}" is not "${EXPECTED_ENGINE_NAME}"`
|
|
90
|
+
: report.available === false
|
|
91
|
+
? 'engine manifest is a declared stub (available:false)'
|
|
92
|
+
: `engine fragment missing (${ENGINE_FRAGMENT_REL})`;
|
|
93
|
+
return { ok, reason, dir: engineDir };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Read the bounded methodology fragment LIVE from the installed engine. Returns the fragment string
|
|
97
|
+
// on the happy path; THROWS a loud Error naming the resolved dir + the reason + the exact
|
|
98
|
+
// remediation on absent/invalid/unreadable — never a fallback (fail-closed). The "methodology engine
|
|
99
|
+
// not found/invalid" prefix is a stable contract: the agent classifies the reconcile STOP by it
|
|
100
|
+
// (distinct from the cap-skip message), so do not reword it without updating SKILL.md.
|
|
101
|
+
export const readEngineFragment = (engineDir, deps = {}) => {
|
|
102
|
+
const detection = detectEngine(engineDir, { source: deps.source }, deps);
|
|
103
|
+
const installHint = `npx @sabaiway/agent-workflow-engine@latest init (or set ${ENGINE_ENV})`;
|
|
104
|
+
if (!detection.ok) {
|
|
105
|
+
throw new Error(`methodology engine not found/invalid at ${engineDir} (${detection.reason}) — install it: ${installHint}`);
|
|
106
|
+
}
|
|
107
|
+
const read = deps.readFileSync ?? readFileSync;
|
|
108
|
+
try {
|
|
109
|
+
return read(join(engineDir, ENGINE_FRAGMENT_REL), 'utf8');
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`methodology engine not found/invalid at ${engineDir} (fragment unreadable: ${err.message}) — install it: ${installHint}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
resolveEngineDir,
|
|
6
|
+
detectEngine,
|
|
7
|
+
readEngineFragment,
|
|
8
|
+
ENGINE_ENV,
|
|
9
|
+
EXPECTED_ENGINE_NAME,
|
|
10
|
+
ENGINE_FRAGMENT_REL,
|
|
11
|
+
} from './engine-source.mjs';
|
|
12
|
+
|
|
13
|
+
// A valid engine dir = it exists (dir), the fragment file exists, and the validator reports a
|
|
14
|
+
// VALID methodology-engine with the right name. The deps below let every branch be exercised
|
|
15
|
+
// in-process without touching the real filesystem.
|
|
16
|
+
const ENGINE_DIR = '/home/u/.claude/skills/agent-workflow-engine';
|
|
17
|
+
|
|
18
|
+
// statType stub: ENGINE_DIR → 'dir', the fragment → 'file', everything else → null.
|
|
19
|
+
const okStatType = (path) =>
|
|
20
|
+
path === ENGINE_DIR ? 'dir' : path === join(ENGINE_DIR, ENGINE_FRAGMENT_REL) ? 'file' : null;
|
|
21
|
+
|
|
22
|
+
const okReport = {
|
|
23
|
+
result: 'valid',
|
|
24
|
+
kind: 'methodology-engine',
|
|
25
|
+
name: EXPECTED_ENGINE_NAME,
|
|
26
|
+
available: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const deps = (overrides = {}) => ({
|
|
30
|
+
validate: () => okReport,
|
|
31
|
+
statType: okStatType,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('resolveEngineDir — env override vs the ~/.claude default', () => {
|
|
36
|
+
it('uses AGENT_WORKFLOW_ENGINE_DIR when set, tagging source=env', () => {
|
|
37
|
+
const out = resolveEngineDir({ env: { [ENGINE_ENV]: '/custom/engine' }, home: '/home/u' });
|
|
38
|
+
assert.deepEqual(out, { dir: '/custom/engine', source: 'env' });
|
|
39
|
+
});
|
|
40
|
+
it('falls back to ~/.claude/skills/agent-workflow-engine, tagging source=default', () => {
|
|
41
|
+
const out = resolveEngineDir({ env: {}, home: '/home/u' });
|
|
42
|
+
assert.equal(out.source, 'default');
|
|
43
|
+
assert.equal(out.dir, join('/home/u', '.claude/skills/agent-workflow-engine'));
|
|
44
|
+
});
|
|
45
|
+
it('treats an empty env value as unset (source=default)', () => {
|
|
46
|
+
const out = resolveEngineDir({ env: { [ENGINE_ENV]: '' }, home: '/home/u' });
|
|
47
|
+
assert.equal(out.source, 'default');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('detectEngine — happy path + each distinct failure reason', () => {
|
|
52
|
+
it('ok when dir exists, manifest VALID methodology-engine, right name, fragment present', () => {
|
|
53
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps());
|
|
54
|
+
assert.equal(out.ok, true);
|
|
55
|
+
assert.equal(out.dir, ENGINE_DIR);
|
|
56
|
+
assert.match(out.reason, /methodology-engine/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('env-set-but-missing: dir absent AND source=env → distinct reason', () => {
|
|
60
|
+
const out = detectEngine(ENGINE_DIR, { source: 'env' }, deps({ statType: () => null }));
|
|
61
|
+
assert.equal(out.ok, false);
|
|
62
|
+
assert.match(out.reason, /env-set-but-missing/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('not-installed: dir absent AND source=default → not an env-set reason', () => {
|
|
66
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ statType: () => null }));
|
|
67
|
+
assert.equal(out.ok, false);
|
|
68
|
+
assert.match(out.reason, /not installed/i);
|
|
69
|
+
assert.doesNotMatch(out.reason, /env-set-but-missing/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('invalid manifest → reason names the validator result', () => {
|
|
73
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ validate: () => ({ ...okReport, result: 'invalid' }) }));
|
|
74
|
+
assert.equal(out.ok, false);
|
|
75
|
+
assert.match(out.reason, /invalid/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('wrong kind → reason names the kind', () => {
|
|
79
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ validate: () => ({ ...okReport, kind: 'memory-substrate' }) }));
|
|
80
|
+
assert.equal(out.ok, false);
|
|
81
|
+
assert.match(out.reason, /kind/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('wrong name → reason names the name mismatch', () => {
|
|
85
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ validate: () => ({ ...okReport, name: 'something-else' }) }));
|
|
86
|
+
assert.equal(out.ok, false);
|
|
87
|
+
assert.match(out.reason, /name/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('declared stub (available:false) → reason names the stub', () => {
|
|
91
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ validate: () => ({ ...okReport, available: false }) }));
|
|
92
|
+
assert.equal(out.ok, false);
|
|
93
|
+
assert.match(out.reason, /stub|available:false/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('missing fragment → reason names the fragment path', () => {
|
|
97
|
+
const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ statType: (p) => (p === ENGINE_DIR ? 'dir' : null) }));
|
|
98
|
+
assert.equal(out.ok, false);
|
|
99
|
+
assert.match(out.reason, /fragment/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('a validator that THROWS (corrupt engine, e.g. EISDIR) → ok:false, no raw error escapes', () => {
|
|
103
|
+
const out = detectEngine(
|
|
104
|
+
ENGINE_DIR,
|
|
105
|
+
{ source: 'default' },
|
|
106
|
+
deps({
|
|
107
|
+
validate: () => {
|
|
108
|
+
throw new Error('EISDIR: illegal operation on a directory, read');
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
assert.equal(out.ok, false);
|
|
113
|
+
assert.match(out.reason, /invalid/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('readEngineFragment — live read or loud throw', () => {
|
|
118
|
+
it('returns the fragment bytes on the happy path', () => {
|
|
119
|
+
const out = readEngineFragment(ENGINE_DIR, deps({ source: 'default', readFileSync: () => 'FRAGMENT BODY' }));
|
|
120
|
+
assert.equal(out, 'FRAGMENT BODY');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('throws naming the dir + the exact install command when the engine is absent', () => {
|
|
124
|
+
assert.throws(
|
|
125
|
+
() => readEngineFragment(ENGINE_DIR, deps({ source: 'env', statType: () => null })),
|
|
126
|
+
(err) => {
|
|
127
|
+
assert.match(err.message, /methodology engine not found\/invalid/);
|
|
128
|
+
assert.match(err.message, new RegExp(ENGINE_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
129
|
+
assert.match(err.message, /npx @sabaiway\/agent-workflow-engine@latest init/);
|
|
130
|
+
assert.match(err.message, new RegExp(ENGINE_ENV));
|
|
131
|
+
return true;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws when the manifest is invalid (never falls back)', () => {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => readEngineFragment(ENGINE_DIR, deps({ source: 'default', validate: () => ({ ...okReport, result: 'invalid' }) })),
|
|
139
|
+
/methodology engine not found\/invalid/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('a THROWING validator still yields the stable install message (no raw fs error escapes)', () => {
|
|
144
|
+
assert.throws(
|
|
145
|
+
() =>
|
|
146
|
+
readEngineFragment(
|
|
147
|
+
ENGINE_DIR,
|
|
148
|
+
deps({
|
|
149
|
+
source: 'default',
|
|
150
|
+
validate: () => {
|
|
151
|
+
throw new Error('EISDIR: illegal operation on a directory, read');
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
(err) => {
|
|
156
|
+
assert.match(err.message, /methodology engine not found\/invalid/);
|
|
157
|
+
assert.match(err.message, /npx @sabaiway\/agent-workflow-engine@latest init/);
|
|
158
|
+
return true;
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('throws with the install command when the fragment file is unreadable', () => {
|
|
164
|
+
assert.throws(
|
|
165
|
+
() =>
|
|
166
|
+
readEngineFragment(
|
|
167
|
+
ENGINE_DIR,
|
|
168
|
+
deps({
|
|
169
|
+
source: 'default',
|
|
170
|
+
readFileSync: () => {
|
|
171
|
+
throw new Error('EISDIR');
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
(err) => {
|
|
176
|
+
assert.match(err.message, /fragment unreadable: EISDIR/);
|
|
177
|
+
assert.match(err.message, /npx @sabaiway\/agent-workflow-engine@latest init/);
|
|
178
|
+
return true;
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|