@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.
- package/CHANGELOG.md +36 -0
- package/README.md +7 -5
- package/SKILL.md +26 -12
- package/bin/install.mjs +117 -14
- package/bin/install.test.mjs +128 -5
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/engine-source.mjs +115 -0
- package/tools/engine-source.test.mjs +182 -0
- package/tools/fs-safe.mjs +4 -1
- package/tools/fs-safe.test.mjs +6 -0
- package/tools/inject-methodology.mjs +51 -7
- package/tools/inject-methodology.test.mjs +157 -12
- package/references/planning.md +0 -105
- package/tools/methodology-slot.md +0 -1
|
@@ -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
|
-
|
|
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()) {
|
package/tools/fs-safe.test.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
159
|
-
const
|
|
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
|
|
186
|
+
const explicitFragmentArg = rest[1];
|
|
171
187
|
const text = await readFile(resolve(agentsPath), 'utf8');
|
|
172
|
-
|
|
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
|
-
|
|
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('
|
|
353
|
+
describe('slotNeedsFill — lazy-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
|
|
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 →
|
|
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 →
|
|
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
|
-
|
|
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('
|
|
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(() =>
|
|
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
|
});
|