@sabaiway/agent-workflow-kit 1.10.0 → 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.
@@ -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');
@@ -3,9 +3,12 @@
3
3
  // deployed AGENTS.md.
4
4
  //
5
5
  // Both templates (memory's + the kit fallback) ship an EMPTY delimited slot; the kit (which knows
6
- // the whole family) fills it. The bounded fragment (tools/methodology-slot.md) is a BOUNDED summary
7
- // + pointer, NOT the full references/planning.md, so AGENTS.md stays under its line cap; it is a
8
- // byte-identical MIRROR of the canonical text in agent-workflow-engine (drift-guarded).
6
+ // the whole family) fills it. The bounded fragment is a BOUNDED summary + pointer (NOT the full
7
+ // references/planning.md), so AGENTS.md stays under its line cap. It is read LIVE from the installed
8
+ // agent-workflow-engine (references/methodology-slot.md) via engine-source.mjs — the family's one
9
+ // source of truth; there is no bundled mirror (retired in Plan 3D, AD-016). The live read is lazy +
10
+ // fail-loud: resolve+read the engine ONLY when a fill is actually needed, and STOP loudly (never a
11
+ // silent fallback) when the engine is needed but absent/invalid.
9
12
  //
10
13
  // Two layers over one marker parser:
11
14
  // - injectMethodology — fill an EXISTING slot. Marker contract, strictly enforced:
@@ -152,11 +155,24 @@ export const reconcileSlot = (text, fragment, { maxLines } = {}) => {
152
155
  return { status, text: injected.text };
153
156
  };
154
157
 
158
+ // Pure predicate (no fs): does this AGENTS.md actually need the methodology fragment? True only when
159
+ // the slot can be ensured (present or insertable) AND is empty — i.e. when reconcileSlot would
160
+ // inject. False when the slot is already filled (preserve-verbatim, no fragment read) OR when the
161
+ // slot/anchor is malformed (so reconcileSlot's own precise error path still fires). It reuses the
162
+ // SAME primitives as reconcileSlot (ensureSlot + extractSlot), so the lazy "read the engine only
163
+ // when needed" guard in main() cannot diverge from the actual fill decision.
164
+ export const slotNeedsFill = (text) => {
165
+ const ensured = ensureSlot(text);
166
+ if (ensured.status === 'error') return false;
167
+ const current = extractSlot(ensured.text);
168
+ return current == null || current.trim() === '';
169
+ };
170
+
155
171
  const main = async (argv) => {
156
172
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
157
173
  const { dirname, basename, join, resolve } = await import('node:path');
158
- const { fileURLToPath } = await import('node:url');
159
- const here = dirname(fileURLToPath(import.meta.url));
174
+ const { homedir } = await import('node:os');
175
+ const { resolveEngineDir, readEngineFragment } = await import('./engine-source.mjs');
160
176
 
161
177
  // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade);
162
178
  // `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-slot mode.
@@ -167,9 +183,29 @@ const main = async (argv) => {
167
183
  console.error('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
168
184
  process.exit(2);
169
185
  }
170
- const fragmentPath = rest[1] ? resolve(rest[1]) : resolve(here, 'methodology-slot.md');
186
+ const explicitFragmentArg = rest[1];
171
187
  const text = await readFile(resolve(agentsPath), 'utf8');
172
- const fragment = await readFile(fragmentPath, 'utf8');
188
+
189
+ // Source the bounded fragment LAZILY. An explicit [fragment.md] arg (tests + manual) wins and skips
190
+ // engine resolution entirely. Otherwise read it LIVE from the installed engine — there is no
191
+ // bundled mirror. readEngineFragment THROWS (never falls back) when the engine is needed but
192
+ // absent/invalid; sourceFragmentOrStop turns that into a hard, loud STOP carrying the install
193
+ // command. The caller only invokes this when a fill is actually needed (the laziness).
194
+ const sourceFragment = async () => {
195
+ if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
196
+ const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
197
+ return readEngineFragment(dir, { source }); // sync; throws loudly when the engine is absent/invalid
198
+ };
199
+ const sourceFragmentOrStop = async (label) => {
200
+ try {
201
+ return await sourceFragment();
202
+ } catch (err) {
203
+ // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The
204
+ // "methodology engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
205
+ console.error(`[inject-methodology] ${label} — ${err.message}`);
206
+ process.exit(1);
207
+ }
208
+ };
173
209
 
174
210
  const writeAtomic = async (out) => {
175
211
  const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
@@ -183,6 +219,10 @@ const main = async (argv) => {
183
219
  };
184
220
 
185
221
  if (mode === 'reconcile') {
222
+ // Read the engine only when the slot actually needs filling (lazy). slotNeedsFill reuses the same
223
+ // primitives reconcileSlot does, so it cannot disagree with the fill decision below — a filled
224
+ // slot reconciles to a zero-diff no-op WITHOUT consulting the engine.
225
+ const fragment = slotNeedsFill(text) ? await sourceFragmentOrStop('reconcile STOP') : '';
186
226
  const result = reconcileSlot(text, fragment, { maxLines: AGENTS_MD_CAP });
187
227
  if (result.status === 'error') {
188
228
  console.error(`[inject-methodology] reconcile refused — ${result.error}`);
@@ -201,6 +241,10 @@ const main = async (argv) => {
201
241
  return;
202
242
  }
203
243
 
244
+ // Legacy inject-into-existing-slot mode. injectMethodology no-ops on absent markers and errors on a
245
+ // malformed slot WITHOUT reading the fragment, so resolve+read the engine only when there is a
246
+ // present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
247
+ const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP') : '';
204
248
  const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
205
249
  if (result.status === 'error') {
206
250
  console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
@@ -1,6 +1,6 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -11,6 +11,7 @@ import {
11
11
  extractSlot,
12
12
  ensureSlot,
13
13
  reconcileSlot,
14
+ slotNeedsFill,
14
15
  METHODOLOGY_ANCHOR,
15
16
  EMPTY_SLOT,
16
17
  AGENTS_MD_CAP,
@@ -20,7 +21,47 @@ import {
20
21
 
21
22
  const HERE = dirname(fileURLToPath(import.meta.url));
22
23
  const SCRIPT = join(HERE, 'inject-methodology.mjs');
23
- const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
24
+
25
+ // The bounded methodology fragment is read LIVE from the installed engine now (the kit mirror is
26
+ // retired in Plan 3D), so this suite no longer reads a bundled methodology-slot.md. Tests use an
27
+ // inline fragment and, for the live-read CLI cases, an on-the-fly engine fixture that ships exactly
28
+ // this fragment — keeping the kit suite decoupled from the sibling engine's on-disk presence.
29
+ // Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
30
+ const FRAGMENT =
31
+ '> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
32
+
33
+ // Temp dirs created by the fixtures below — cleaned up once after the whole file.
34
+ const tmpDirs = [];
35
+ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
36
+
37
+ // A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
38
+ // whose metadata.version matches it (the validator's authoritative version source when there is no
39
+ // package.json) + the live fragment at references/methodology-slot.md. detectEngine accepts it.
40
+ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
41
+ const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
42
+ tmpDirs.push(dir);
43
+ const manifest = {
44
+ family: 'agent-workflow',
45
+ schema: 1,
46
+ name: 'agent-workflow-engine',
47
+ kind: 'methodology-engine',
48
+ version,
49
+ available: true,
50
+ provides: ['plan'],
51
+ roles: {},
52
+ };
53
+ writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifest, null, 2));
54
+ writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
55
+ mkdirSync(join(dir, 'references'), { recursive: true });
56
+ writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
57
+ return dir;
58
+ };
59
+
60
+ const ENGINE = makeEngineFixture();
61
+ // A path that is guaranteed NOT to be a valid engine — proves the no-op / explicit-override paths
62
+ // never consult the engine, and drives the fail-loud STOP.
63
+ const NO_ENGINE = join(tmpdir(), `definitely-no-engine-${process.pid}`);
64
+ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR: engineDir });
24
65
 
25
66
  const wrap = (inner) =>
26
67
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
@@ -309,7 +350,46 @@ describe('reconcileSlot — ensure + inject-if-empty + cap, as one atomic policy
309
350
  });
310
351
  });
311
352
 
312
- describe('reconcile CLI atomic ensure+inject-if-empty+cap on the real filesystem', () => {
353
+ describe('slotNeedsFilllazy-read predicate (matches reconcileSlot fill decision)', () => {
354
+ it('present empty slot → true', () => {
355
+ assert.equal(slotNeedsFill(wrap('\n')), true);
356
+ });
357
+ it('markerless legacy with one anchor (insertable empty slot) → true', () => {
358
+ assert.equal(slotNeedsFill(legacyWithAnchor()), true);
359
+ });
360
+ it('present filled/customized slot → false (fragment not needed)', () => {
361
+ assert.equal(slotNeedsFill(wrap('\nuser notes\n')), false);
362
+ });
363
+ it('malformed slot → false (reconcileSlot error path fires, not a fill)', () => {
364
+ assert.equal(slotNeedsFill(`${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), false);
365
+ });
366
+ it('markerless with no anchor → false (cannot insert; reconcile errors without the engine)', () => {
367
+ assert.equal(slotNeedsFill('# AGENTS.md\n\nno anchor here\n'), false);
368
+ });
369
+
370
+ // slotNeedsFill and reconcileSlot share the SAME emptiness primitives (ensureSlot + extractSlot), so
371
+ // the lazy "read the engine only when needed" guard cannot disagree with reconcileSlot's actual fill
372
+ // decision. Pin that equivalence across representative inputs: needsFill === true IFF reconcile fills
373
+ // (reconciled-filled / reconciled-inserted); needsFill === false IFF reconcile does NOT fill
374
+ // (present-filled / error). This forecloses any future divergence that could silently drop a slot.
375
+ it('agrees with reconcileSlot across every slot state (no divergence → no silent drop)', () => {
376
+ const cases = [
377
+ wrap('\n'), // present empty slot → fill
378
+ legacyWithAnchor(), // markerless + anchor → insert + fill
379
+ wrap('\nuser notes\n'), // filled slot → no fill
380
+ `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`, // malformed → no fill (error)
381
+ '# AGENTS.md\n\nno anchor here\n', // no anchor → no fill (error)
382
+ ];
383
+ for (const text of cases) {
384
+ const needs = slotNeedsFill(text);
385
+ const result = reconcileSlot(text, FRAGMENT, { maxLines: AGENTS_MD_CAP });
386
+ const filled = result.status === 'reconciled-filled' || result.status === 'reconciled-inserted';
387
+ assert.equal(needs, filled, `slotNeedsFill (${needs}) must match whether reconcile fills (${result.status})`);
388
+ }
389
+ });
390
+ });
391
+
392
+ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragment LIVE from the engine', () => {
313
393
  const withTempAgents = (contents, run) => {
314
394
  const dir = mkdtempSync(join(tmpdir(), 'reconcile-cli-'));
315
395
  const agents = join(dir, 'AGENTS.md');
@@ -321,35 +401,100 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap on the real filesy
321
401
  }
322
402
  };
323
403
 
324
- it('markerless legacy (with anchor) → slot inserted and filled (exit 0)', () => {
404
+ it('markerless legacy (with anchor) → slot inserted + filled from the live engine fragment (exit 0)', () => {
325
405
  withTempAgents(legacyWithAnchor(), (agents) => {
326
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
406
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
327
407
  const out = readFileSync(agents, 'utf8');
328
408
  assert.equal(findSlot(out).state, 'ok');
329
409
  assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
330
410
  });
331
411
  });
332
412
 
333
- it('present empty slot → slot filled (exit 0)', () => {
413
+ it('present empty slot → filled from the live engine fragment (exit 0)', () => {
334
414
  withTempAgents(wrap('\n'), (agents) => {
335
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
415
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
336
416
  assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
337
417
  });
338
418
  });
339
419
 
340
- it('filled/customized slot → file left byte-for-byte untouched', () => {
420
+ it('filled/customized slot → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
341
421
  const custom = wrap('\nuser notes\n');
342
422
  withTempAgents(custom, (agents) => {
343
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
423
+ // Engine pointed at a path that does not exist — a filled slot must NOT require it.
424
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
344
425
  assert.equal(readFileSync(agents, 'utf8'), custom);
345
426
  });
346
427
  });
347
428
 
348
- it('malformed slot → STOP with non-zero exit, file byte-unchanged', () => {
429
+ it('present empty slot + engine ABSENT hard STOP (nonzero) printing the install command, file unchanged', () => {
430
+ withTempAgents(wrap('\n'), (agents) => {
431
+ const err = (() => {
432
+ try {
433
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
434
+ return null;
435
+ } catch (e) {
436
+ return e;
437
+ }
438
+ })();
439
+ assert.ok(err, 'expected a non-zero exit when a fill is needed but the engine is absent');
440
+ const stderr = String(err.stderr);
441
+ assert.match(stderr, /methodology engine not found\/invalid/);
442
+ assert.match(stderr, /npx @sabaiway\/agent-workflow-engine@latest init/);
443
+ assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write on STOP');
444
+ });
445
+ });
446
+
447
+ it('explicit [fragment.md] override fills from that file and skips engine resolution (engine absent)', () => {
448
+ const override = '> custom override fragment line\n';
449
+ const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
450
+ tmpDirs.push(fdir);
451
+ const fpath = join(fdir, 'frag.md');
452
+ writeFileSync(fpath, override);
453
+ withTempAgents(wrap('\n'), (agents) => {
454
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
455
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), override.trim());
456
+ });
457
+ });
458
+
459
+ it('malformed slot → STOP with non-zero exit, file byte-unchanged (engine never consulted)', () => {
349
460
  const malformed = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
350
461
  withTempAgents(malformed, (agents) => {
351
- assert.throws(() => execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' }));
462
+ assert.throws(() =>
463
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
464
+ );
352
465
  assert.equal(readFileSync(agents, 'utf8'), malformed);
353
466
  });
354
467
  });
468
+
469
+ it('legacy inject mode: markerless AGENTS.md → no-op WITHOUT the engine (exit 0)', () => {
470
+ const markerless = '# AGENTS.md\n\nlegacy, no slot\n';
471
+ withTempAgents(markerless, (agents) => {
472
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
473
+ assert.equal(readFileSync(agents, 'utf8'), markerless);
474
+ });
475
+ });
476
+
477
+ // Legacy `inject` mode FORCE-OVERWRITES any present (ok) slot — filled or empty — unlike `reconcile`,
478
+ // which preserves a filled slot. So for an ok slot it genuinely NEEDS the fragment and reads the
479
+ // engine; the read-on-`state==='ok'` guard is correct (reading only an EMPTY ok slot would inject ''
480
+ // and WIPE a filled slot). These two tests pin that contract.
481
+ it('legacy inject mode: a FILLED slot is OVERWRITTEN from the live engine (engine present, exit 0)', () => {
482
+ const filled = wrap('\nstale user content\n');
483
+ withTempAgents(filled, (agents) => {
484
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(ENGINE) });
485
+ const out = readFileSync(agents, 'utf8');
486
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'slot overwritten with the live fragment');
487
+ assert.ok(!out.includes('stale user content'), 'prior slot content replaced');
488
+ });
489
+ });
490
+
491
+ it('legacy inject mode: a present (ok) slot + engine ABSENT → hard STOP, file unchanged', () => {
492
+ const filled = wrap('\nstale user content\n');
493
+ withTempAgents(filled, (agents) => {
494
+ assert.throws(() =>
495
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
496
+ );
497
+ assert.equal(readFileSync(agents, 'utf8'), filled, 'no partial write on STOP');
498
+ });
499
+ });
355
500
  });