@sabaiway/agent-workflow-kit 1.3.0 → 1.5.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 +70 -1
- package/README.md +15 -5
- package/SKILL.md +81 -9
- package/bin/install.mjs +59 -8
- package/bin/install.test.mjs +66 -0
- package/capability.json +21 -0
- package/migrations/1.1.0-communication-language.md +5 -5
- package/migrations/README.md +2 -1
- package/package.json +8 -5
- package/references/contracts.md +2 -2
- package/references/scripts/archive-changelog.mjs +1 -4
- package/references/templates/AGENTS.md +2 -2
- package/tools/delegation.mjs +109 -0
- package/tools/delegation.test.mjs +115 -0
- package/tools/detect-backends.mjs +310 -0
- package/tools/detect-backends.test.mjs +342 -0
- package/tools/inject-methodology.mjs +111 -0
- package/tools/inject-methodology.test.mjs +124 -0
- package/tools/manifest/fixtures/bad-available/SKILL.md +7 -0
- package/tools/manifest/fixtures/bad-available/capability.json +10 -0
- package/tools/manifest/fixtures/detect-array/SKILL.md +7 -0
- package/tools/manifest/fixtures/detect-array/capability.json +10 -0
- package/tools/manifest/fixtures/malformed-json/capability.json +1 -0
- package/tools/manifest/fixtures/metadata-version/SKILL.md +10 -0
- package/tools/manifest/fixtures/metadata-version/capability.json +9 -0
- package/tools/manifest/fixtures/missing-key/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-key/capability.json +8 -0
- package/tools/manifest/fixtures/missing-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/missing-source/capability.json +11 -0
- package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +10 -0
- package/tools/manifest/fixtures/nested-version-decoy/capability.json +9 -0
- package/tools/manifest/fixtures/null-root/capability.json +1 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +2 -0
- package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +11 -0
- package/tools/manifest/fixtures/stub/capability.json +10 -0
- package/tools/manifest/fixtures/traversal-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/traversal-source/capability.json +11 -0
- package/tools/manifest/fixtures/unknown-schema/capability.json +9 -0
- package/tools/manifest/fixtures/valid/SKILL.md +10 -0
- package/tools/manifest/fixtures/valid/bin/run.sh +3 -0
- package/tools/manifest/fixtures/valid/capability.json +18 -0
- package/tools/manifest/fixtures/version-mismatch/SKILL.md +7 -0
- package/tools/manifest/fixtures/version-mismatch/capability.json +9 -0
- package/tools/manifest/fixtures/win-absolute-source/SKILL.md +7 -0
- package/tools/manifest/fixtures/win-absolute-source/capability.json +11 -0
- package/tools/manifest/schema.md +67 -0
- package/tools/manifest/validate.mjs +264 -0
- package/tools/manifest/validate.test.mjs +73 -0
- package/tools/methodology-slot.md +1 -0
- package/tools/release-scan.mjs +103 -0
- package/tools/release-scan.test.mjs +41 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdtempSync, writeFileSync, symlinkSync, chmodSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
expandTilde,
|
|
9
|
+
resolveDir,
|
|
10
|
+
findOnPath,
|
|
11
|
+
probeCredential,
|
|
12
|
+
detectBackend,
|
|
13
|
+
detectBackends,
|
|
14
|
+
formatReport,
|
|
15
|
+
KNOWN_BACKENDS,
|
|
16
|
+
} from './detect-backends.mjs';
|
|
17
|
+
|
|
18
|
+
const REPO = fileURLToPath(new URL('../../', import.meta.url)); // …/agent-workflow-kit/tools/ → repo root
|
|
19
|
+
const KIT = join(REPO, 'agent-workflow-kit');
|
|
20
|
+
const FIX = join(KIT, 'tools', 'manifest', 'fixtures');
|
|
21
|
+
const HOME = '/home/u';
|
|
22
|
+
|
|
23
|
+
// An ENOENT-typed error, matching the shape Node's fs throws (used to drive the wrapped probes).
|
|
24
|
+
const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
25
|
+
const eacces = () => Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
|
26
|
+
|
|
27
|
+
// ── pure helpers ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('expandTilde', () => {
|
|
30
|
+
it('"~" → home', () => assert.equal(expandTilde('~', HOME), HOME));
|
|
31
|
+
it('"~/x" → home/x', () => assert.equal(expandTilde('~/x/y', HOME), join(HOME, 'x/y')));
|
|
32
|
+
it('absolute path untouched', () => assert.equal(expandTilde('/abs/path', HOME), '/abs/path'));
|
|
33
|
+
it('relative path untouched', () => assert.equal(expandTilde('rel/path', HOME), 'rel/path'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('resolveDir', () => {
|
|
37
|
+
it('non-empty env wins, as-is', () => {
|
|
38
|
+
assert.equal(resolveDir({ env: 'D', default: '~/d' }, { D: '/from/env' }, HOME), '/from/env');
|
|
39
|
+
});
|
|
40
|
+
it('empty-string env → default', () => {
|
|
41
|
+
assert.equal(resolveDir({ env: 'D', default: '/d' }, { D: '' }, HOME), '/d');
|
|
42
|
+
});
|
|
43
|
+
it('null env name → default', () => {
|
|
44
|
+
assert.equal(resolveDir({ env: null, default: '/d' }, {}, HOME), '/d');
|
|
45
|
+
});
|
|
46
|
+
it('default is tilde-expanded', () => {
|
|
47
|
+
assert.equal(resolveDir({ env: 'D', default: '~/skills/x' }, {}, HOME), join(HOME, 'skills/x'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('findOnPath', () => {
|
|
52
|
+
const linux = { platform: 'linux', getenv: { PATH: '/a:/b' }, realpath: (p) => p };
|
|
53
|
+
|
|
54
|
+
it('present via posix exec-bit', () => {
|
|
55
|
+
const r = findOnPath('codex', { ...linux, access: (p) => { if (p !== '/a/codex') throw enoent(); } });
|
|
56
|
+
assert.equal(r.state, 'present');
|
|
57
|
+
assert.equal(r.path, '/a/codex');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('missing when absent everywhere', () => {
|
|
61
|
+
const r = findOnPath('codex', { ...linux, access: () => { throw enoent(); } });
|
|
62
|
+
assert.equal(r.state, 'missing');
|
|
63
|
+
assert.equal(r.path, null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('found in the 2nd PATH dir', () => {
|
|
67
|
+
const r = findOnPath('agy', { ...linux, access: (p) => { if (p !== '/b/agy') throw enoent(); } });
|
|
68
|
+
assert.equal(r.state, 'present');
|
|
69
|
+
assert.equal(r.path, '/b/agy');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('Windows: matches via PATHEXT', () => {
|
|
73
|
+
const r = findOnPath('codex', {
|
|
74
|
+
platform: 'win32',
|
|
75
|
+
getenv: { PATH: 'C:\\bin', PATHEXT: '.COM;.EXE;.CMD' },
|
|
76
|
+
realpath: (p) => p,
|
|
77
|
+
access: (p) => { if (!p.endsWith('codex.EXE')) throw enoent(); },
|
|
78
|
+
});
|
|
79
|
+
assert.equal(r.state, 'present');
|
|
80
|
+
assert.ok(r.path.endsWith('codex.EXE'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves a symlinked binary to its realpath (real fs)', () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), 'awf-path-'));
|
|
85
|
+
const target = join(dir, 'real-tool');
|
|
86
|
+
const link = join(dir, 'linked-tool');
|
|
87
|
+
writeFileSync(target, '#!/bin/sh\n');
|
|
88
|
+
chmodSync(target, 0o755);
|
|
89
|
+
symlinkSync(target, link);
|
|
90
|
+
const r = findOnPath('linked-tool', { platform: 'linux', getenv: { PATH: dir } });
|
|
91
|
+
assert.equal(r.state, 'present');
|
|
92
|
+
assert.equal(r.path, target); // realpath followed the symlink
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accessSync throwing EACCES → unknown (cannot confirm)', () => {
|
|
96
|
+
const r = findOnPath('codex', { ...linux, access: () => { throw eacces(); } });
|
|
97
|
+
assert.equal(r.state, 'unknown');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('probeCredential', () => {
|
|
102
|
+
const entry = (over = {}) => ({ credential: { env: 'CRED', default: '~/.codex', file: 'auth.json', ...over } });
|
|
103
|
+
|
|
104
|
+
it('present when the marker file is a real file (env override honoured)', () => {
|
|
105
|
+
const r = probeCredential(entry(), { getenv: { CRED: '/cdir' }, home: HOME, exists: () => true, stat: () => ({ isFile: () => true }) });
|
|
106
|
+
assert.equal(r.state, 'present');
|
|
107
|
+
assert.equal(r.path, '/cdir/auth.json');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('absent → missing', () => {
|
|
111
|
+
const r = probeCredential(entry(), { getenv: {}, home: HOME, exists: () => false });
|
|
112
|
+
assert.equal(r.state, 'missing');
|
|
113
|
+
assert.equal(r.path, join(HOME, '.codex/auth.json')); // env unset → tilde default
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('statSync throwing a non-ENOENT error → unknown', () => {
|
|
117
|
+
const r = probeCredential(entry(), { getenv: {}, home: HOME, exists: () => true, stat: () => { throw eacces(); } });
|
|
118
|
+
assert.equal(r.state, 'unknown');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('credential.env:null uses the (tilde-expanded) default', () => {
|
|
122
|
+
const r = probeCredential(entry({ env: null, default: '~/.gemini/antigravity-cli', file: 'tok' }), {
|
|
123
|
+
getenv: {}, home: HOME, exists: () => true, stat: () => ({ isFile: () => true }),
|
|
124
|
+
});
|
|
125
|
+
assert.equal(r.state, 'present');
|
|
126
|
+
assert.equal(r.path, join(HOME, '.gemini/antigravity-cli/tok'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('never counts a directory as a present credential', () => {
|
|
130
|
+
const r = probeCredential(entry(), { getenv: { CRED: '/cdir' }, home: HOME, exists: () => true, stat: () => ({ isFile: () => false }) });
|
|
131
|
+
assert.equal(r.state, 'missing');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── detectBackend: manifestState precedence ──────────────────────────────────
|
|
136
|
+
|
|
137
|
+
// Build an entry whose installed-marker is `capability.json` so a fixture without a SKILL.md still
|
|
138
|
+
// registers as "installed", letting the validator branch decide the state.
|
|
139
|
+
const entryAt = (dir, over = {}) => ({
|
|
140
|
+
name: 'codex-cli-bridge',
|
|
141
|
+
installed: { env: 'X_DIR', default: dir, file: 'capability.json' },
|
|
142
|
+
bin: 'codex',
|
|
143
|
+
credential: { env: null, default: '/no/such/dir', file: 'auth.json' },
|
|
144
|
+
setupUrl: 'https://example.test/setup',
|
|
145
|
+
setupPathLocal: 'setup/README.md',
|
|
146
|
+
...over,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// All readiness inputs present, so manifestState is the only variable under test.
|
|
150
|
+
const allPresentDeps = {
|
|
151
|
+
getenv: {},
|
|
152
|
+
probeCli: () => ({ bin: 'codex', state: 'present', path: '/usr/bin/codex' }),
|
|
153
|
+
probeCredentials: () => ({ state: 'present', path: '/c/auth.json' }),
|
|
154
|
+
probeWrapper: (cmd) => ({ name: cmd, state: 'present' }),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe('detectBackend — manifestState precedence', () => {
|
|
158
|
+
it('not-installed when the marker file is absent', () => {
|
|
159
|
+
const empty = mkdtempSync(join(tmpdir(), 'awf-empty-'));
|
|
160
|
+
const d = detectBackend(entryAt(empty), allPresentDeps);
|
|
161
|
+
assert.equal(d.manifestState, 'not-installed');
|
|
162
|
+
assert.equal(d.skillDir, null);
|
|
163
|
+
assert.equal(d.readiness, 'needs-skill');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('unsupported-schema (fixtures/unknown-schema)', () => {
|
|
167
|
+
const d = detectBackend(entryAt(join(FIX, 'unknown-schema')), allPresentDeps);
|
|
168
|
+
assert.equal(d.manifestState, 'unsupported-schema');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('invalid-manifest (fixtures/malformed-json)', () => {
|
|
172
|
+
const d = detectBackend(entryAt(join(FIX, 'malformed-json')), allPresentDeps);
|
|
173
|
+
assert.equal(d.manifestState, 'invalid-manifest');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('stub (fixtures/stub — available:false)', () => {
|
|
177
|
+
const d = detectBackend(entryAt(join(FIX, 'stub')), allPresentDeps);
|
|
178
|
+
assert.equal(d.manifestState, 'stub');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('foreign (valid, but a memory-substrate, not this backend)', () => {
|
|
182
|
+
const d = detectBackend(entryAt(join(REPO, 'agent-workflow-memory')), allPresentDeps);
|
|
183
|
+
assert.equal(d.manifestState, 'foreign');
|
|
184
|
+
assert.match(d.manifestReason, /memory-substrate/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('ok (the real codex-cli-bridge dir, marker = SKILL.md)', () => {
|
|
188
|
+
const d = detectBackend(
|
|
189
|
+
entryAt(join(REPO, 'codex-cli-bridge'), { installed: { env: 'X_DIR', default: join(REPO, 'codex-cli-bridge'), file: 'SKILL.md' } }),
|
|
190
|
+
allPresentDeps,
|
|
191
|
+
);
|
|
192
|
+
assert.equal(d.manifestState, 'ok');
|
|
193
|
+
assert.deepEqual(d.wrappers.map((w) => w.name).sort(), ['codex-exec', 'codex-review']);
|
|
194
|
+
assert.equal(d.setupHint.local, 'setup/README.md'); // installed AND setup/README.md exists
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── detectBackend: readiness matrix (over an `ok` manifest) ───────────────────
|
|
199
|
+
|
|
200
|
+
const okEntry = entryAt(join(REPO, 'codex-cli-bridge'), {
|
|
201
|
+
installed: { env: 'X_DIR', default: join(REPO, 'codex-cli-bridge'), file: 'SKILL.md' },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('detectBackend — readiness over an ok manifest', () => {
|
|
205
|
+
it('ready when cli + credentials + every wrapper present', () => {
|
|
206
|
+
assert.equal(detectBackend(okEntry, allPresentDeps).readiness, 'ready');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('needs-cli when the CLI is missing', () => {
|
|
210
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCli: () => ({ bin: 'codex', state: 'missing', path: null }) });
|
|
211
|
+
assert.equal(d.readiness, 'needs-cli');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('needs-cli when the CLI is unknown (unknown never counts as present)', () => {
|
|
215
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCli: () => ({ bin: 'codex', state: 'unknown', path: null }) });
|
|
216
|
+
assert.equal(d.readiness, 'needs-cli');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('needs-credentials when the credential marker is missing', () => {
|
|
220
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCredentials: () => ({ state: 'missing', path: '/c/auth.json' }) });
|
|
221
|
+
assert.equal(d.readiness, 'needs-credentials');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('degraded when a wrapper is missing from PATH', () => {
|
|
225
|
+
const d = detectBackend(okEntry, {
|
|
226
|
+
...allPresentDeps,
|
|
227
|
+
probeWrapper: (cmd) => ({ name: cmd, state: cmd === 'codex-review' ? 'missing' : 'present' }),
|
|
228
|
+
});
|
|
229
|
+
assert.equal(d.readiness, 'degraded');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('needs-skill dominates even when cli + credentials are present', () => {
|
|
233
|
+
const empty = mkdtempSync(join(tmpdir(), 'awf-empty2-'));
|
|
234
|
+
const d = detectBackend(entryAt(empty), allPresentDeps);
|
|
235
|
+
assert.equal(d.readiness, 'needs-skill');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── registry drift guard ─────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const readManifest = (dir) => JSON.parse(readFileSync(join(dir, 'capability.json'), 'utf8'));
|
|
242
|
+
|
|
243
|
+
// Every top-level in-repo dir with a kind:execution-backend manifest, discovered fresh from disk.
|
|
244
|
+
const inRepoBackends = () =>
|
|
245
|
+
readdirSync(REPO, { withFileTypes: true })
|
|
246
|
+
.filter((e) => e.isDirectory() && existsSync(join(REPO, e.name, 'capability.json')))
|
|
247
|
+
.filter((e) => readManifest(join(REPO, e.name)).kind === 'execution-backend')
|
|
248
|
+
.map((e) => e.name);
|
|
249
|
+
|
|
250
|
+
describe('KNOWN_BACKENDS — drift guard against the in-repo manifests', () => {
|
|
251
|
+
it('set equality: registry names == in-repo execution-backend dirs', () => {
|
|
252
|
+
const onDisk = inRepoBackends().sort();
|
|
253
|
+
const registry = KNOWN_BACKENDS.map((b) => b.name).sort();
|
|
254
|
+
assert.deepEqual(registry, onDisk);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('the methodology-engine is NOT counted as a backend', () => {
|
|
258
|
+
assert.ok(!KNOWN_BACKENDS.some((b) => b.name === 'agent-workflow-engine'));
|
|
259
|
+
assert.equal(readManifest(join(REPO, 'agent-workflow-engine')).kind, 'methodology-engine');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('registry names are unique', () => {
|
|
263
|
+
const names = KNOWN_BACKENDS.map((b) => b.name);
|
|
264
|
+
assert.equal(new Set(names).size, names.length);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('each entry.installed matches the real manifest detect.installed', () => {
|
|
268
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
269
|
+
const real = readManifest(join(REPO, entry.name)).detect.installed;
|
|
270
|
+
assert.equal(entry.installed.env, real.env, `${entry.name} env`);
|
|
271
|
+
assert.equal(entry.installed.default, real.default, `${entry.name} default`);
|
|
272
|
+
assert.equal(entry.installed.file, real.file, `${entry.name} file`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('each entry.setupPathLocal exists in the repo', () => {
|
|
277
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
278
|
+
assert.ok(existsSync(join(REPO, entry.name, entry.setupPathLocal)), `${entry.name}/${entry.setupPathLocal}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── formatReport ─────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
const readyStatus = {
|
|
286
|
+
name: 'codex-cli-bridge',
|
|
287
|
+
manifestState: 'ok',
|
|
288
|
+
manifestReason: 'ok',
|
|
289
|
+
skillDir: '/skills/codex-cli-bridge',
|
|
290
|
+
cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
|
|
291
|
+
credentials: { state: 'present', path: '/home/u/.codex/auth.json' },
|
|
292
|
+
wrappers: [{ name: 'codex-exec', state: 'present' }, { name: 'codex-review', state: 'present' }],
|
|
293
|
+
readiness: 'ready',
|
|
294
|
+
setupHint: { local: 'setup/README.md', url: 'https://example.test/codex' },
|
|
295
|
+
};
|
|
296
|
+
const needsSkillStatus = {
|
|
297
|
+
name: 'antigravity-cli-bridge',
|
|
298
|
+
manifestState: 'not-installed',
|
|
299
|
+
manifestReason: 'not installed',
|
|
300
|
+
skillDir: null,
|
|
301
|
+
cli: { bin: 'agy', state: 'present', path: '/home/u/.local/bin/agy' },
|
|
302
|
+
credentials: { state: 'present', path: '/home/u/.gemini/antigravity-cli/antigravity-oauth-token' },
|
|
303
|
+
wrappers: [],
|
|
304
|
+
readiness: 'needs-skill',
|
|
305
|
+
setupHint: { url: 'https://example.test/agy' },
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
describe('formatReport', () => {
|
|
309
|
+
const out = formatReport([readyStatus, needsSkillStatus]);
|
|
310
|
+
|
|
311
|
+
it('never says "authenticated" or "authed" (credentials = file presence, not a live login)', () => {
|
|
312
|
+
const low = out.toLowerCase();
|
|
313
|
+
assert.ok(!low.includes('authenticated'));
|
|
314
|
+
assert.ok(!low.includes('authed'));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('uses the word "credentials"', () => assert.ok(out.toLowerCase().includes('credentials')));
|
|
318
|
+
|
|
319
|
+
it('shows the ready backend as ready', () => {
|
|
320
|
+
assert.match(out, /codex-cli-bridge.*ready/s);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('a needs-skill backend (CLI + creds present) says install the bridge + points at the setup URL', () => {
|
|
324
|
+
assert.match(out, /needs-skill/);
|
|
325
|
+
assert.match(out, /install the bridge/);
|
|
326
|
+
assert.match(out, /https:\/\/example\.test\/agy/);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('detectBackends — live shape on this machine', () => {
|
|
331
|
+
it('returns one status per registry entry with all data-model keys', () => {
|
|
332
|
+
const statuses = detectBackends();
|
|
333
|
+
assert.equal(statuses.length, KNOWN_BACKENDS.length);
|
|
334
|
+
for (const s of statuses) {
|
|
335
|
+
for (const key of ['name', 'manifestState', 'manifestReason', 'cli', 'credentials', 'wrappers', 'readiness', 'setupHint']) {
|
|
336
|
+
assert.ok(key in s, `${s.name} missing ${key}`);
|
|
337
|
+
}
|
|
338
|
+
assert.ok(['present', 'missing', 'unknown'].includes(s.cli.state));
|
|
339
|
+
assert.ok(['present', 'missing', 'unknown'].includes(s.credentials.state));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Methodology slot injection — the composition root's only mutation of memory's AGENTS.md.
|
|
3
|
+
//
|
|
4
|
+
// memory ships an EMPTY delimited slot in templates/AGENTS.md; the kit (which knows the whole
|
|
5
|
+
// family) fills it. The engine only *provides* the methodology text — Plan 2 repoints the
|
|
6
|
+
// source to it. Phase 1 source = the kit's bundled tools/methodology-slot.md (a BOUNDED summary
|
|
7
|
+
// + pointer, NOT the full references/planning.md), so AGENTS.md stays under its line cap.
|
|
8
|
+
//
|
|
9
|
+
// Marker contract (shared with memory's upgrade extract-and-reinsert), strictly enforced:
|
|
10
|
+
// - exactly one ordered start→end pair → replace only the bytes between them.
|
|
11
|
+
// - markers absent (legacy AGENTS.md) → gracefully NO-OP (slot migration is Plan 2).
|
|
12
|
+
// - any malformed state (single, reversed, nested, duplicate) → NO-OP WITH AN ERROR; never edit.
|
|
13
|
+
// Prefix/suffix bytes are preserved exactly. Re-running with the same fragment is idempotent.
|
|
14
|
+
//
|
|
15
|
+
// Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
|
|
16
|
+
|
|
17
|
+
export const START_MARKER = '<!-- workflow:methodology:start -->';
|
|
18
|
+
export const END_MARKER = '<!-- workflow:methodology:end -->';
|
|
19
|
+
export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
|
|
20
|
+
|
|
21
|
+
const countOccurrences = (haystack, needle) => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
let from = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const idx = haystack.indexOf(needle, from);
|
|
26
|
+
if (idx === -1) return count;
|
|
27
|
+
count += 1;
|
|
28
|
+
from = idx + needle.length;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Classify the marker state of an AGENTS.md text. Pure; no fs.
|
|
33
|
+
// { state: 'ok', startIdx, endIdx } exactly one ordered pair
|
|
34
|
+
// { state: 'absent' } no markers at all → caller no-ops
|
|
35
|
+
// { state: 'malformed', reason } anything else → caller no-ops WITH error
|
|
36
|
+
export const findSlot = (text) => {
|
|
37
|
+
const starts = countOccurrences(text, START_MARKER);
|
|
38
|
+
const ends = countOccurrences(text, END_MARKER);
|
|
39
|
+
if (starts === 0 && ends === 0) return { state: 'absent' };
|
|
40
|
+
if (starts !== 1 || ends !== 1) {
|
|
41
|
+
return { state: 'malformed', reason: `expected exactly one start/end marker pair, found ${starts} start / ${ends} end` };
|
|
42
|
+
}
|
|
43
|
+
const startIdx = text.indexOf(START_MARKER);
|
|
44
|
+
const endIdx = text.indexOf(END_MARKER);
|
|
45
|
+
if (endIdx < startIdx) return { state: 'malformed', reason: 'end marker precedes start marker' };
|
|
46
|
+
return { state: 'ok', startIdx, endIdx };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Inject `fragment` between the markers, replacing only the bytes between them.
|
|
50
|
+
// Returns { status: 'injected' | 'noop-absent' | 'error', text, error? }. On absent/error the
|
|
51
|
+
// returned text is the INPUT, byte-for-byte (never edit on a malformed slot). Pass
|
|
52
|
+
// `{ maxLines }` to enforce the AGENTS.md line cap as a postcondition (refuse, don't bust it).
|
|
53
|
+
export const injectMethodology = (text, fragment, { maxLines } = {}) => {
|
|
54
|
+
// A fragment that itself contains a marker would create a duplicate/nested slot — refuse.
|
|
55
|
+
if (fragment.includes(START_MARKER) || fragment.includes(END_MARKER)) {
|
|
56
|
+
return { status: 'error', text, error: 'fragment contains a methodology marker — refusing to inject (would create a duplicate/nested slot)' };
|
|
57
|
+
}
|
|
58
|
+
const slot = findSlot(text);
|
|
59
|
+
if (slot.state === 'absent') return { status: 'noop-absent', text };
|
|
60
|
+
if (slot.state === 'malformed') return { status: 'error', text, error: slot.reason };
|
|
61
|
+
const before = text.slice(0, slot.startIdx + START_MARKER.length);
|
|
62
|
+
const after = text.slice(slot.endIdx);
|
|
63
|
+
const out = `${before}\n${fragment.trim()}\n${after}`;
|
|
64
|
+
if (maxLines != null) {
|
|
65
|
+
const lines = out.split('\n').length - (out.endsWith('\n') ? 1 : 0);
|
|
66
|
+
if (lines > maxLines) {
|
|
67
|
+
return { status: 'error', text, error: `injection would push AGENTS.md to ${lines} lines (cap ${maxLines}) — trim the fragment or the file` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { status: 'injected', text: out };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Inverse used by memory's upgrade: extract the current slot content (preserve-on-upgrade).
|
|
74
|
+
// Returns the bytes strictly between the markers, or null on absent/malformed.
|
|
75
|
+
export const extractSlot = (text) => {
|
|
76
|
+
const slot = findSlot(text);
|
|
77
|
+
if (slot.state !== 'ok') return null;
|
|
78
|
+
return text.slice(slot.startIdx + START_MARKER.length, slot.endIdx);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const main = async (argv) => {
|
|
82
|
+
const { readFile, writeFile, rename } = await import('node:fs/promises');
|
|
83
|
+
const { dirname, basename, join, resolve } = await import('node:path');
|
|
84
|
+
const { fileURLToPath } = await import('node:url');
|
|
85
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const agentsPath = argv[0];
|
|
87
|
+
if (!agentsPath) {
|
|
88
|
+
console.error('usage: inject-methodology.mjs <path/to/AGENTS.md> [fragment.md]');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
const fragmentPath = argv[1] ? resolve(argv[1]) : resolve(here, 'methodology-slot.md');
|
|
92
|
+
const text = await readFile(resolve(agentsPath), 'utf8');
|
|
93
|
+
const fragment = await readFile(fragmentPath, 'utf8');
|
|
94
|
+
const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
|
|
95
|
+
if (result.status === 'error') {
|
|
96
|
+
console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
if (result.status === 'noop-absent') {
|
|
100
|
+
console.log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const tmp = join(dirname(resolve(agentsPath)), `.${basename(agentsPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
104
|
+
await writeFile(tmp, result.text, 'utf8');
|
|
105
|
+
await rename(tmp, resolve(agentsPath));
|
|
106
|
+
console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const { pathToFileURL } = await import('node:url');
|
|
110
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
111
|
+
if (isDirectRun) await main(process.argv.slice(2));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
injectMethodology,
|
|
8
|
+
findSlot,
|
|
9
|
+
extractSlot,
|
|
10
|
+
START_MARKER,
|
|
11
|
+
END_MARKER,
|
|
12
|
+
} from './inject-methodology.mjs';
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
|
|
16
|
+
|
|
17
|
+
const wrap = (inner) =>
|
|
18
|
+
`# 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`;
|
|
19
|
+
|
|
20
|
+
describe('findSlot — marker classification', () => {
|
|
21
|
+
it('one ordered pair → ok', () => {
|
|
22
|
+
assert.equal(findSlot(wrap('\n')).state, 'ok');
|
|
23
|
+
});
|
|
24
|
+
it('no markers → absent', () => {
|
|
25
|
+
assert.equal(findSlot('# AGENTS.md\nno markers here\n').state, 'absent');
|
|
26
|
+
});
|
|
27
|
+
it('duplicate pair → malformed', () => {
|
|
28
|
+
const text = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
|
|
29
|
+
assert.equal(findSlot(text).state, 'malformed');
|
|
30
|
+
});
|
|
31
|
+
it('single start only → malformed', () => {
|
|
32
|
+
assert.equal(findSlot(`x\n${START_MARKER}\ny\n`).state, 'malformed');
|
|
33
|
+
});
|
|
34
|
+
it('single end only → malformed', () => {
|
|
35
|
+
assert.equal(findSlot(`x\n${END_MARKER}\ny\n`).state, 'malformed');
|
|
36
|
+
});
|
|
37
|
+
it('reversed (end before start) → malformed', () => {
|
|
38
|
+
assert.equal(findSlot(`${END_MARKER}\nmiddle\n${START_MARKER}\n`).state, 'malformed');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('injectMethodology — byte preservation', () => {
|
|
43
|
+
it('injects into an empty slot, preserving prefix/suffix exactly', () => {
|
|
44
|
+
const input = wrap('\n');
|
|
45
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
46
|
+
assert.equal(out.status, 'injected');
|
|
47
|
+
assert.ok(out.text.startsWith('# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n'));
|
|
48
|
+
assert.ok(out.text.endsWith('\n\n## Hard Constraints\n\nsuffix bytes\n'));
|
|
49
|
+
assert.ok(out.text.includes(FRAGMENT.trim()));
|
|
50
|
+
// markers themselves are preserved
|
|
51
|
+
assert.equal((out.text.match(new RegExp(START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length, 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('is idempotent — re-injecting the same fragment is stable', () => {
|
|
55
|
+
const once = injectMethodology(wrap('\n'), FRAGMENT).text;
|
|
56
|
+
const twice = injectMethodology(once, FRAGMENT).text;
|
|
57
|
+
assert.equal(twice, once);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('overwrites a previously-filled slot (bootstrap composition), preserving outside bytes', () => {
|
|
61
|
+
const filled = wrap('\nstale content\n');
|
|
62
|
+
const out = injectMethodology(filled, FRAGMENT);
|
|
63
|
+
assert.equal(out.status, 'injected');
|
|
64
|
+
assert.ok(!out.text.includes('stale content'));
|
|
65
|
+
assert.ok(out.text.endsWith('\n\n## Hard Constraints\n\nsuffix bytes\n'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a fragment that itself contains a marker (would nest/duplicate the slot)', () => {
|
|
69
|
+
const out = injectMethodology(wrap('\n'), `bad ${START_MARKER} fragment`);
|
|
70
|
+
assert.equal(out.status, 'error');
|
|
71
|
+
assert.equal(out.text, wrap('\n'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses to bust the line cap (maxLines) instead of silently overflowing it', () => {
|
|
75
|
+
const huge = Array.from({ length: 40 }, (_, i) => `methodology line ${i}`).join('\n');
|
|
76
|
+
const out = injectMethodology(wrap('\n'), huge, { maxLines: 20 });
|
|
77
|
+
assert.equal(out.status, 'error');
|
|
78
|
+
assert.match(out.error, /cap 20/);
|
|
79
|
+
assert.equal(out.text, wrap('\n')); // unchanged
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('absent markers → no-op, returns input byte-for-byte', () => {
|
|
83
|
+
const input = '# AGENTS.md\nlegacy file, no slot\n';
|
|
84
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
85
|
+
assert.equal(out.status, 'noop-absent');
|
|
86
|
+
assert.equal(out.text, input);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const [label, input] of [
|
|
90
|
+
['duplicate pair', `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`],
|
|
91
|
+
['single start', `head\n${START_MARKER}\ntail\n`],
|
|
92
|
+
['single end', `head\n${END_MARKER}\ntail\n`],
|
|
93
|
+
['reversed', `${END_MARKER}\nx\n${START_MARKER}\n`],
|
|
94
|
+
]) {
|
|
95
|
+
it(`malformed (${label}) → error, returns input byte-for-byte`, () => {
|
|
96
|
+
const out = injectMethodology(input, FRAGMENT);
|
|
97
|
+
assert.equal(out.status, 'error');
|
|
98
|
+
assert.equal(out.text, input); // never edits a malformed slot
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('extractSlot — preserve-on-upgrade inverse', () => {
|
|
104
|
+
it('returns the bytes strictly between the markers', () => {
|
|
105
|
+
assert.equal(extractSlot(wrap('\nkeep me\n')), '\nkeep me\n');
|
|
106
|
+
});
|
|
107
|
+
it('null on absent/malformed', () => {
|
|
108
|
+
assert.equal(extractSlot('no markers'), null);
|
|
109
|
+
assert.equal(extractSlot(`${START_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), null);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('post-injection cap — AGENTS.md stays under its line budget', () => {
|
|
114
|
+
it('injecting the bounded fragment into the real memory template keeps AGENTS.md ≤ 100 lines', () => {
|
|
115
|
+
const template = readFileSync(
|
|
116
|
+
join(HERE, '..', '..', 'agent-workflow-memory', 'references', 'templates', 'AGENTS.md'),
|
|
117
|
+
'utf8',
|
|
118
|
+
);
|
|
119
|
+
const out = injectMethodology(template, FRAGMENT);
|
|
120
|
+
assert.equal(out.status, 'injected');
|
|
121
|
+
const lines = out.text.split('\n').length - (out.text.endsWith('\n') ? 1 : 0);
|
|
122
|
+
assert.ok(lines <= 100, `AGENTS.md would be ${lines} lines after injection (cap 100)`);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "family": "agent-workflow", "schema": 1, "name": "broken",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metadata-version
|
|
3
|
+
version: '9.9.9'
|
|
4
|
+
description: A decoy top-level `version:` that must NOT be read as the authoritative version.
|
|
5
|
+
metadata:
|
|
6
|
+
version: '1.0.0'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# metadata-version fixture — authoritative version is `metadata.version` (1.0.0), not the
|
|
10
|
+
# stray top-level `version: 9.9.9`. capability.json declares 1.0.0, so this is VALID.
|