@sabaiway/agent-workflow-kit 1.9.1 → 1.11.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.9.1",
6
+ "version": "1.11.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.9.1",
3
+ "version": "1.11.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",
@@ -17,7 +17,27 @@ The user chooses at bootstrap whether the AI artifacts are visible in the repo o
17
17
  diverge:
18
18
 
19
19
  - **visible** — artifacts are committed. Wire the project's `package.json` scripts (`docs:check` / `docs:index` / `docs:index:check` / `docs:archive` / `docs:archive:check` / `docs:archive:issues` / `docs:archive:issues:check` / `prepare: node scripts/install-git-hooks.mjs`) and add a minimal `.gitignore` (`docs/plans/`, `.claude/settings.local.json`). This is the canonical model.
20
- - **hidden** (in-tree) — same files on disk, but the repo "looks normal": append the artifact paths (`AGENTS.md`, `CLAUDE.md`, `docs/ai/`, `docs/plans/`, `scripts/*.mjs` you added, `docs/ai/.workflow-version`) to the global excludes file git **already uses** (`git config --get core.excludesFile`); if none is set, point it at `~/.gitignore_global` (`git config --global core.excludesFile ~/.gitignore_global`) and append there. **Verify `git status` shows the artifacts as ignored** afterwards. **Do not edit `package.json`** — that is a tracked change and would leak; the pre-commit hook (always untracked in `.git/hooks/`) calls the scripts via `node scripts/<x>.mjs` directly.
20
+ - **hidden** (in-tree) — same files on disk, but the repo "looks normal": the AI/agent footprint is git-ignored via **one managed block in the project-local `.git/info/exclude`** (resolved with `git rev-parse --git-path info/exclude` — never the machine-global `core.excludesFile`, which would affect every repo on the host; **AD-014** amends **AD-006**). The kit's `tools/hide-footprint.mjs` is the single writer: it covers `KIT_OWN_PATHS ∪ the present subset of KNOWN_FOOTPRINT` (the full footprint table below — the kit's own artifacts **and** every other AI/agent tool's files). Per path: a **tracked** file → **ASK** (an exclude does nothing for it; only `git rm --cached` un-tracks it — the tool prints that command and never runs it); an untracked path already covered by a **tracked `.gitignore`** → dropped (redundant); a **present** file whose name is generic enough to be ambiguous (`falsePositiveRisk`) → **ASK**; everything else → **hidden**. `asks` are excluded from the block unless explicitly opted in. **Verify** treats a path as hidden only when it is **untracked AND ignored by our project-local block** (or a tracked `.gitignore`) being ignored by the global excludes does **not** count. Re-running re-derives the block wholesale (sorted/deduped) a clean re-run is a **zero-diff** no-op. On an existing global-excludes deployment the tool **detects + reports the residual legacy machine-global block and keeps it by default** (a harmless double-ignore; the local block wins precedence); removal is the explicit `--remove-global` (it prints the removed lines as a restorable backup), which **the agent only runs after asking** — another of the user's hidden repos on the same host may rely on the same root-anchored global lines. **Do not edit `package.json`** — that is a tracked change and would leak; the pre-commit hook (always untracked in `.git/hooks/`) calls the scripts via `node scripts/<x>.mjs` directly. Windows is supported (text edit, no symlinks; CRLF preserved).
21
+
22
+ **Known AI/agent footprint** (the `KNOWN_FOOTPRINT` registry in `tools/known-footprint.mjs`; this table is its human mirror, kept in sync by review — D11):
23
+
24
+ | Pattern | Owner | Kind | Commit-risk name? | Note |
25
+ |---|---|---|---|---|
26
+ | `/.claude/skills/` | Claude Code | dir | no | local-dev skills; absorbs the AD-013 one-off |
27
+ | `/.cursor/rules/` | Cursor | dir | no | project rule files |
28
+ | `/.cursorrules` | Cursor (legacy) | file | **yes** | legacy single-file rules |
29
+ | `/.codeium/` | Codeium/Windsurf | dir | no | home-scoped launchers live under `~/`, out of scope |
30
+ | `/.windsurf/` | Windsurf (Devin) | dir | no | project config dir |
31
+ | `/.windsurfrules` | Windsurf | file | **yes** | legacy single-file rules |
32
+ | `/GEMINI.md` | Gemini/Antigravity | file | **yes** | context file; generic name |
33
+ | `/.antigravity.md` | Antigravity | file | **yes** | context file |
34
+ | `/.github/copilot-*` | GitHub Copilot | file | **yes** | one reviewed glob; covers `copilot-instructions.md` |
35
+ | `/.aider.conf.yml` | Aider | file | no | config |
36
+ | `/.aider.chat.history.md` | Aider | file | no | chat history |
37
+ | `/.aider.input.history` | Aider | file | no | input history |
38
+ | `/.continue/` | Continue | dir | no | project config dir |
39
+
40
+ The kit's OWN footprint (`KIT_OWN_PATHS`) — `AGENTS.md`, `CLAUDE.md`, `docs/ai/` (subsumes the stamp), the added `scripts/*.mjs`, `docs/plans/`, `.claude/settings.local.json`, and `.claude/settings.json` (hidden-only — visible mode commits it) — is always a candidate in hidden mode.
21
41
 
22
42
  Not in this version: a fully-external hidden mode (artifacts relocated outside the repo tree).
23
43
  Deferred to a later release + migration.
@@ -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
+ });
package/tools/fs-safe.mjs CHANGED
@@ -44,7 +44,10 @@ export const assertContainedRealPath = (root, dest, deps = {}) => {
44
44
  const lstat = deps.lstat ?? lstatSync;
45
45
  const ln = (p) => lstatNoFollow(p, lstat);
46
46
  const rel = relative(root, dest);
47
- if (rel.startsWith('..') || isAbsolute(rel)) {
47
+ // A true escape is `..` exactly or a `..`-prefixed PATH SEGMENT (`../x`) — NOT any string starting
48
+ // with the two chars "..": a legitimately-contained child literally named `..foo` has rel `..foo`,
49
+ // which the old `rel.startsWith('..')` wrongly rejected (Issue-004 — same fix as the engine/memory installers).
50
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
48
51
  throw new Error(`[agent-workflow-kit] refusing to write outside the target dir: ${dest}`);
49
52
  }
50
53
  if (ln(root)?.isSymbolicLink()) {
@@ -30,6 +30,12 @@ describe('assertContainedRealPath', () => {
30
30
  assert.throws(() => assertContainedRealPath('/root', '/etc/passwd'), /outside/);
31
31
  });
32
32
 
33
+ it('Issue-004: accepts a contained child literally named "..foo", still rejects a true ".." segment', () => {
34
+ // rel "..foo" is a real child, NOT a "../" escape — the old `rel.startsWith('..')` wrongly rejected it.
35
+ assert.doesNotThrow(() => assertContainedRealPath(dir, join(dir, '..foo')));
36
+ assert.throws(() => assertContainedRealPath(dir, join(dir, '..', 'escape')), /outside/);
37
+ });
38
+
33
39
  it('rejects writing INTO a symlinked root', () => {
34
40
  const real = join(dir, 'real');
35
41
  const root = join(dir, 'root');