@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.
@@ -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. `extra` appends flags (e.g. --force).
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 — published tarball bundles the bridges', () => {
215
- it('npm pack ships bridges/<name>/ (the execution-backend skill mirrors)', () => {
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.10.0",
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.10.0",
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
+ });