@sabaiway/agent-workflow-kit 1.11.0 → 1.13.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 +56 -1
- package/README.md +12 -4
- package/SKILL.md +103 -37
- package/capability.json +5 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +276 -0
- package/tools/family-registry.test.mjs +247 -0
- package/tools/fs-safe.mjs +50 -1
- package/tools/fs-safe.test.mjs +140 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/manifest/validate.mjs +3 -1
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
- package/tools/uninstall.integration.test.mjs +144 -0
- package/tools/uninstall.mjs +420 -0
- package/tools/uninstall.test.mjs +372 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// family-registry.mjs — the unified, kit-owned registry over EVERY agent-workflow family member.
|
|
3
|
+
//
|
|
4
|
+
// Until now "who the members are" was split across three disjoint kit-owned tables: KNOWN_BACKENDS
|
|
5
|
+
// (the 2 bridges, detect-backends.mjs), KIT_OWN_PATHS/KNOWN_FOOTPRINT (the hidden-mode paths,
|
|
6
|
+
// known-footprint.mjs), and the 5 per-member capability.json files. This module is the single
|
|
7
|
+
// authoritative aggregation: it answers "what is installed, what version, what kind" (the SKILL
|
|
8
|
+
// axis) and "what is deployed in this project" (the deploy axis). It is the substrate the read-only
|
|
9
|
+
// `/agent-workflow-kit status` mode and the guarded `/agent-workflow-kit uninstall` both consume.
|
|
10
|
+
//
|
|
11
|
+
// Source of truth = the in-tool FAMILY_MEMBERS table (the AD-008 KNOWN_BACKENDS precedent): a member
|
|
12
|
+
// that is NOT installed has no manifest on disk to read, so the enumeration + detect/install facts
|
|
13
|
+
// must live here. A drift-guard test (family-registry.test.mjs) pins FAMILY_MEMBERS to the 5 in-repo
|
|
14
|
+
// capability.json files, so the table cannot silently drift from the manifests it mirrors.
|
|
15
|
+
//
|
|
16
|
+
// Pure, dependency-injectable (fs/env/home/validator are deps), dependency-free, Node >= 18. No
|
|
17
|
+
// side effects on import (the isDirectRun idiom) — tests import the helpers with nothing run.
|
|
18
|
+
|
|
19
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import { resolveDir } from './detect-backends.mjs';
|
|
24
|
+
import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
25
|
+
import { START_MARKER, excludePath } from './hide-footprint.mjs';
|
|
26
|
+
import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL } from './engine-source.mjs';
|
|
27
|
+
|
|
28
|
+
// ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
|
|
29
|
+
export const NOT_INSTALLED = 'not-installed';
|
|
30
|
+
export const UNSUPPORTED_SCHEMA = 'unsupported-schema';
|
|
31
|
+
export const INVALID_MANIFEST = 'invalid-manifest';
|
|
32
|
+
export const STUB = 'stub';
|
|
33
|
+
export const FOREIGN = 'foreign';
|
|
34
|
+
export const OK = 'ok';
|
|
35
|
+
// The marker could not be probed (a non-ENOENT fs error — EACCES/EIO). Surfaced explicitly instead of
|
|
36
|
+
// being masked as not-installed (no silent failure); uninstall treats it as "do not touch" (skip).
|
|
37
|
+
export const UNKNOWN = 'unknown';
|
|
38
|
+
|
|
39
|
+
// ── the unified registry ───────────────────────────────────────────────────────
|
|
40
|
+
// One entry per family member. `installed` is the detect.installed spec (env + home-relative default
|
|
41
|
+
// + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
|
|
42
|
+
// `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
|
|
43
|
+
// `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
|
|
44
|
+
// Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
|
|
45
|
+
// (release-engineering / release-marketing) are deliberately NOT here — they are not family members
|
|
46
|
+
// (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
|
|
47
|
+
export const FAMILY_MEMBERS = [
|
|
48
|
+
{
|
|
49
|
+
name: 'agent-workflow-kit',
|
|
50
|
+
kind: 'composition-root',
|
|
51
|
+
installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
|
|
52
|
+
deployed: { file: 'docs/ai/.workflow-version' },
|
|
53
|
+
npm: '@sabaiway/agent-workflow-kit',
|
|
54
|
+
wrapperCmds: [],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'agent-workflow-memory',
|
|
58
|
+
kind: 'memory-substrate',
|
|
59
|
+
installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
|
|
60
|
+
deployed: { file: 'docs/ai/.memory-version' },
|
|
61
|
+
npm: '@sabaiway/agent-workflow-memory',
|
|
62
|
+
wrapperCmds: [],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'agent-workflow-engine',
|
|
66
|
+
kind: 'methodology-engine',
|
|
67
|
+
installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
|
|
68
|
+
deployed: null,
|
|
69
|
+
npm: '@sabaiway/agent-workflow-engine',
|
|
70
|
+
wrapperCmds: [],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'codex-cli-bridge',
|
|
74
|
+
kind: 'execution-backend',
|
|
75
|
+
installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
|
|
76
|
+
deployed: null,
|
|
77
|
+
npm: null,
|
|
78
|
+
wrapperCmds: ['codex-exec', 'codex-review'],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'antigravity-cli-bridge',
|
|
82
|
+
kind: 'execution-backend',
|
|
83
|
+
installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
|
|
84
|
+
deployed: null,
|
|
85
|
+
npm: null,
|
|
86
|
+
wrapperCmds: ['agy-run'],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
|
|
91
|
+
// uninstaller warns before removing one (there is no cross-project dependency tracking). All current
|
|
92
|
+
// members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
|
|
93
|
+
export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
|
|
94
|
+
|
|
95
|
+
// ── pure probes ──────────────────────────────────────────────────────────────────
|
|
96
|
+
// Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a file) | 'unknown' (a
|
|
97
|
+
// non-ENOENT fs error, e.g. EACCES). 'unknown' is NOT collapsed to 'absent': a permission error must
|
|
98
|
+
// surface, never be masked as not-installed (no silent failure) — and uninstall then leaves it alone.
|
|
99
|
+
const probeMarker = (path, deps = {}) => {
|
|
100
|
+
const exists = deps.exists ?? existsSync;
|
|
101
|
+
const stat = deps.stat ?? statSync;
|
|
102
|
+
try {
|
|
103
|
+
if (!exists(path)) return 'absent';
|
|
104
|
+
return stat(path).isFile() ? 'present' : 'absent';
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return err && err.code === 'ENOENT' ? 'absent' : 'unknown';
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Pure manifestState classifier — the detect-backends precedence, generalized to a member's own
|
|
111
|
+
// expected name + kind: not-installed → unsupported-schema → invalid-manifest → stub → foreign → ok.
|
|
112
|
+
const classifyState = (markerPresent, report, member) => {
|
|
113
|
+
if (!markerPresent) return NOT_INSTALLED;
|
|
114
|
+
if (report.result === UNSUPPORTED) return UNSUPPORTED_SCHEMA;
|
|
115
|
+
if (report.result === INVALID) return INVALID_MANIFEST;
|
|
116
|
+
if (report.available === false) return STUB;
|
|
117
|
+
if (report.kind !== member.kind || report.name !== member.name) return FOREIGN;
|
|
118
|
+
return OK;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── the SKILL axis ─────────────────────────────────────────────────────────────
|
|
122
|
+
// classifyMember → { name, kind, installed, skillDir, manifestState, version }. Reuses resolveDir
|
|
123
|
+
// (detect-backends), validateManifest + readAuthoritativeVersion (the manifest validator) — one
|
|
124
|
+
// authoritative version reader, no second drifting source. `version` is set only for an `ok` member.
|
|
125
|
+
export const classifyMember = (member, deps = {}) => {
|
|
126
|
+
const validate = deps.validate ?? validateManifest;
|
|
127
|
+
const readVersion = deps.readVersion ?? readAuthoritativeVersion;
|
|
128
|
+
const getenv = deps.getenv ?? process.env;
|
|
129
|
+
const home = deps.home ?? os.homedir();
|
|
130
|
+
|
|
131
|
+
const skillDir = resolveDir({ env: member.installed.env, default: member.installed.default }, getenv, home);
|
|
132
|
+
const marker = probeMarker(join(skillDir, member.installed.file), deps);
|
|
133
|
+
// A marker we cannot probe (EACCES/EIO) → 'unknown': reported, but NOT installed (so uninstall never
|
|
134
|
+
// removes a dir whose ownership it could not verify). Distinct from 'not-installed' (genuinely absent).
|
|
135
|
+
if (marker === 'unknown') {
|
|
136
|
+
return { name: member.name, kind: member.kind, installed: false, skillDir, manifestState: UNKNOWN, version: null };
|
|
137
|
+
}
|
|
138
|
+
const markerPresent = marker === 'present';
|
|
139
|
+
const report = markerPresent ? validate(skillDir) : { result: NOT_INSTALLED };
|
|
140
|
+
const manifestState = classifyState(markerPresent, report, member);
|
|
141
|
+
const installed = manifestState !== NOT_INSTALLED;
|
|
142
|
+
const version = manifestState === OK ? readVersion(skillDir).version ?? null : null;
|
|
143
|
+
|
|
144
|
+
return { name: member.name, kind: member.kind, installed, skillDir: installed ? skillDir : null, manifestState, version };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// An engine OLDER than 1.2.0 has a valid manifest + version but ships no orchestration-recipes
|
|
148
|
+
// fragment (references/orchestration-slot.md), so it cannot supply the recipes pointer the kit
|
|
149
|
+
// injects. surveyFamily attaches a plain-language caveat to that engine row instead of a bare "ok".
|
|
150
|
+
// The check mirrors what a RECONCILE actually does — `readEngineFragment(..., { rel: orchestration })`
|
|
151
|
+
// validates the manifest AND reads the fragment — so an absent, non-file, OR present-but-unreadable
|
|
152
|
+
// fragment all surface as a caveat (status never claims "ok" for a fragment the reconcile would STOP
|
|
153
|
+
// on), and a current, readable fragment never gets the caveat. Read-only, best-effort.
|
|
154
|
+
export const surveyFamily = (deps = {}) =>
|
|
155
|
+
FAMILY_MEMBERS.map((member) => {
|
|
156
|
+
const row = classifyMember(member, deps);
|
|
157
|
+
if (row.kind === 'methodology-engine' && row.manifestState === OK && row.skillDir) {
|
|
158
|
+
const orchUsable = (() => {
|
|
159
|
+
try {
|
|
160
|
+
readEngineFragment(row.skillDir, { source: 'default', rel: ORCHESTRATION_FRAGMENT_REL, ...deps });
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false; // absent / non-file / unreadable fragment → the engine can't supply the pointer
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
if (!orchUsable) {
|
|
167
|
+
row.caveat = 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return row;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── the DEPLOY axis ──────────────────────────────────────────────────────────────
|
|
174
|
+
// Read a one-line semver stamp (docs/ai/.workflow-version etc.). Returns the trimmed version or null.
|
|
175
|
+
const readStamp = (path, deps = {}) => {
|
|
176
|
+
const exists = deps.exists ?? existsSync;
|
|
177
|
+
const read = deps.readFile ?? readFileSync;
|
|
178
|
+
try {
|
|
179
|
+
if (!exists(path)) return null;
|
|
180
|
+
const v = String(read(path, 'utf8')).trim();
|
|
181
|
+
return v.length ? v : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Is our hidden-mode managed fence present? Resolve the exclude file via the SAME git-path-aware path
|
|
188
|
+
// hide-footprint uses (`git rev-parse --git-path info/exclude`), so a linked worktree / submodule is
|
|
189
|
+
// handled correctly (not the hardcoded `.git/info/exclude`). If git is unavailable or this is not a
|
|
190
|
+
// repo, fall back to the conventional path; any read error → not present (best-effort, read-only).
|
|
191
|
+
const hasHiddenFence = (projectDir, deps = {}) => {
|
|
192
|
+
const exists = deps.exists ?? existsSync;
|
|
193
|
+
const read = deps.readFile ?? readFileSync;
|
|
194
|
+
const ep = (() => {
|
|
195
|
+
try {
|
|
196
|
+
return excludePath(deps, projectDir);
|
|
197
|
+
} catch {
|
|
198
|
+
return join(projectDir, '.git', 'info', 'exclude');
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
201
|
+
try {
|
|
202
|
+
return exists(ep) && String(read(ep, 'utf8')).includes(START_MARKER);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
|
|
209
|
+
// docs/ai/ exists, and whether the hidden-mode fence is present. Pure (fs reads only, all injectable),
|
|
210
|
+
// no git subprocess — the read-only `status` view must never mutate or spawn anything.
|
|
211
|
+
export const surveyProject = (projectDir, deps = {}) => {
|
|
212
|
+
const exists = deps.exists ?? existsSync;
|
|
213
|
+
const dir = resolve(projectDir);
|
|
214
|
+
const stamps = FAMILY_MEMBERS
|
|
215
|
+
.filter((m) => m.deployed)
|
|
216
|
+
.map((m) => ({ name: m.name, file: m.deployed.file, version: readStamp(join(dir, m.deployed.file), deps) }));
|
|
217
|
+
const docsAiPresent = (() => {
|
|
218
|
+
try {
|
|
219
|
+
return exists(join(dir, 'docs', 'ai'));
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
|
|
225
|
+
return { dir, deployed, docsAiPresent, hiddenFence: hasHiddenFence(dir, deps), stamps };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ── report ───────────────────────────────────────────────────────────────────────
|
|
229
|
+
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
230
|
+
|
|
231
|
+
export const formatStatus = (family, project = null) => {
|
|
232
|
+
const lines = ['agent-workflow family — installed skills (skill axis)', ''];
|
|
233
|
+
for (const m of family) {
|
|
234
|
+
const ver = m.version ? `v${m.version}` : '—';
|
|
235
|
+
lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
|
|
236
|
+
if (m.caveat) lines.push(` ↳ ${m.caveat}`);
|
|
237
|
+
}
|
|
238
|
+
if (project) {
|
|
239
|
+
lines.push('', `project deployment (${project.dir})`, '');
|
|
240
|
+
if (!project.deployed) {
|
|
241
|
+
lines.push(' no agent-workflow deployment detected here (no docs/ai, no version stamp).');
|
|
242
|
+
} else {
|
|
243
|
+
for (const s of project.stamps) {
|
|
244
|
+
lines.push(` ${pad(s.file, 26)}${s.version ?? '—'}`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(` ${pad('docs/ai present', 26)}${project.docsAiPresent ? 'yes' : 'no'}`);
|
|
247
|
+
lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return lines.join('\n');
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ── CLI ────────────────────────────────────────────────────────────────────────
|
|
254
|
+
const parseArgs = (argv) => {
|
|
255
|
+
const dirFlag = argv.indexOf('--dir');
|
|
256
|
+
return { help: argv.includes('--help') || argv.includes('-h'), dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const main = (argv) => {
|
|
260
|
+
const args = parseArgs(argv);
|
|
261
|
+
if (args.help) {
|
|
262
|
+
console.log(`family-registry — read-only view of the agent-workflow family.
|
|
263
|
+
|
|
264
|
+
Usage:
|
|
265
|
+
node family-registry.mjs [--dir <project>] # skill axis always; deploy axis when --dir is given
|
|
266
|
+
|
|
267
|
+
Detection only — never writes, never commits, never runs a subscription CLI.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const family = surveyFamily();
|
|
271
|
+
const project = args.dir ? surveyProject(args.dir) : null;
|
|
272
|
+
console.log(formatStatus(family, project));
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
276
|
+
if (isDirectRun) main(process.argv.slice(2));
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
FAMILY_MEMBERS,
|
|
8
|
+
classifyMember,
|
|
9
|
+
surveyFamily,
|
|
10
|
+
surveyProject,
|
|
11
|
+
NOT_INSTALLED,
|
|
12
|
+
UNSUPPORTED_SCHEMA,
|
|
13
|
+
INVALID_MANIFEST,
|
|
14
|
+
STUB,
|
|
15
|
+
FOREIGN,
|
|
16
|
+
OK,
|
|
17
|
+
UNKNOWN,
|
|
18
|
+
} from './family-registry.mjs';
|
|
19
|
+
import { VALID, INVALID, UNSUPPORTED } from './manifest/validate.mjs';
|
|
20
|
+
import { START_MARKER } from './hide-footprint.mjs';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const REPO_ROOT = resolve(__dirname, '../..'); // agent-workflow-kit/tools → repo root
|
|
24
|
+
|
|
25
|
+
// Build classifyMember deps that present the marker, with an injectable validate/readVersion.
|
|
26
|
+
const installedDeps = ({ report, version = '9.9.9', home = '/home/test' }) => ({
|
|
27
|
+
exists: () => true,
|
|
28
|
+
stat: () => ({ isFile: () => true }),
|
|
29
|
+
getenv: {},
|
|
30
|
+
home,
|
|
31
|
+
validate: () => report,
|
|
32
|
+
readVersion: () => ({ version }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const KIT = FAMILY_MEMBERS.find((m) => m.name === 'agent-workflow-kit');
|
|
36
|
+
|
|
37
|
+
// ── classifyMember ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('classifyMember', () => {
|
|
40
|
+
it('marks an absent marker as not-installed (no skillDir, no version)', () => {
|
|
41
|
+
const r = classifyMember(KIT, { exists: () => false, getenv: {}, home: '/home/test' });
|
|
42
|
+
assert.equal(r.manifestState, NOT_INSTALLED);
|
|
43
|
+
assert.equal(r.installed, false);
|
|
44
|
+
assert.equal(r.skillDir, null);
|
|
45
|
+
assert.equal(r.version, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('classifies a valid matching manifest as ok and reports the authoritative version', () => {
|
|
49
|
+
const r = classifyMember(KIT, installedDeps({
|
|
50
|
+
report: { result: VALID, name: 'agent-workflow-kit', kind: 'composition-root', available: true },
|
|
51
|
+
version: '1.12.0',
|
|
52
|
+
}));
|
|
53
|
+
assert.equal(r.manifestState, OK);
|
|
54
|
+
assert.equal(r.installed, true);
|
|
55
|
+
assert.equal(r.version, '1.12.0');
|
|
56
|
+
assert.match(r.skillDir, /\.claude\/skills\/agent-workflow-kit$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('classifies a wrong name/kind as foreign (never ours, never removed by uninstall)', () => {
|
|
60
|
+
const r = classifyMember(KIT, installedDeps({
|
|
61
|
+
report: { result: VALID, name: 'something-else', kind: 'composition-root', available: true },
|
|
62
|
+
}));
|
|
63
|
+
assert.equal(r.manifestState, FOREIGN);
|
|
64
|
+
assert.equal(r.version, null); // version only for ok
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('classifies available:false as a stub', () => {
|
|
68
|
+
const r = classifyMember(KIT, installedDeps({
|
|
69
|
+
report: { result: VALID, name: 'agent-workflow-kit', kind: 'composition-root', available: false },
|
|
70
|
+
}));
|
|
71
|
+
assert.equal(r.manifestState, STUB);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('maps validator INVALID → invalid-manifest and UNSUPPORTED → unsupported-schema', () => {
|
|
75
|
+
assert.equal(classifyMember(KIT, installedDeps({ report: { result: INVALID } })).manifestState, INVALID_MANIFEST);
|
|
76
|
+
assert.equal(classifyMember(KIT, installedDeps({ report: { result: UNSUPPORTED } })).manifestState, UNSUPPORTED_SCHEMA);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('surfaces an EACCES marker probe as "unknown" — never masked as not-installed', () => {
|
|
80
|
+
const eacces = () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); };
|
|
81
|
+
const r = classifyMember(KIT, { exists: eacces, getenv: {}, home: '/home/test' });
|
|
82
|
+
assert.equal(r.manifestState, UNKNOWN);
|
|
83
|
+
assert.equal(r.installed, false); // not removed — ownership could not be verified
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resolves the skill dir from the env override when set (resolveDir reuse)', () => {
|
|
87
|
+
const r = classifyMember(KIT, {
|
|
88
|
+
exists: () => true,
|
|
89
|
+
stat: () => ({ isFile: () => true }),
|
|
90
|
+
getenv: { AGENT_WORKFLOW_KIT_DIR: '/custom/kit' },
|
|
91
|
+
home: '/home/test',
|
|
92
|
+
validate: () => ({ result: VALID, name: 'agent-workflow-kit', kind: 'composition-root', available: true }),
|
|
93
|
+
readVersion: () => ({ version: '1.12.0' }),
|
|
94
|
+
});
|
|
95
|
+
assert.equal(r.skillDir, '/custom/kit');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('surveyFamily', () => {
|
|
100
|
+
it('returns one row per member; all not-installed when no markers present', () => {
|
|
101
|
+
const rows = surveyFamily({ exists: () => false, getenv: {}, home: '/home/test' });
|
|
102
|
+
assert.equal(rows.length, FAMILY_MEMBERS.length);
|
|
103
|
+
assert.ok(rows.every((r) => r.manifestState === NOT_INSTALLED));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Only the engine member validates as ok (its name+kind match); the others go FOREIGN under this
|
|
107
|
+
// shared validate stub — so only the engine row is eligible for the orchestration-fragment caveat.
|
|
108
|
+
const engineValidate = (dir) =>
|
|
109
|
+
String(dir).includes('agent-workflow-engine')
|
|
110
|
+
? { result: VALID, name: 'agent-workflow-engine', kind: 'methodology-engine', available: true }
|
|
111
|
+
: { result: VALID, name: 'x', kind: 'x', available: true };
|
|
112
|
+
|
|
113
|
+
// The caveat mirrors the reconcile: it reads the orchestration fragment (readEngineFragment), so an
|
|
114
|
+
// absent / non-file / unreadable fragment all surface; a current readable fragment does not.
|
|
115
|
+
const engineDeps = (over) => ({
|
|
116
|
+
exists: () => true, // SKILL.md marker present (classifyMember)
|
|
117
|
+
stat: () => ({ isFile: () => true }),
|
|
118
|
+
getenv: {},
|
|
119
|
+
home: '/home/test',
|
|
120
|
+
validate: engineValidate,
|
|
121
|
+
readVersion: () => ({ version: '1.2.0' }),
|
|
122
|
+
...over,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('an OK engine MISSING the orchestration fragment gets a plain caveat', () => {
|
|
126
|
+
const rows = surveyFamily(engineDeps({
|
|
127
|
+
readVersion: () => ({ version: '1.1.0' }),
|
|
128
|
+
statType: (p) => (String(p).endsWith('orchestration-slot.md') ? null : 'dir'), // fragment ABSENT
|
|
129
|
+
}));
|
|
130
|
+
const engine = rows.find((r) => r.kind === 'methodology-engine');
|
|
131
|
+
assert.equal(engine.manifestState, OK);
|
|
132
|
+
assert.ok(engine.caveat, 'an engine without the recipes fragment carries a caveat');
|
|
133
|
+
assert.match(engine.caveat, /recipes pointer|too old|incomplete/i);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a current engine WITH a readable orchestration fragment carries NO caveat', () => {
|
|
137
|
+
const rows = surveyFamily(engineDeps({
|
|
138
|
+
statType: (p) => (String(p).endsWith('orchestration-slot.md') ? 'file' : 'dir'),
|
|
139
|
+
readFileSync: () => '> orchestration recipes pointer', // present + readable
|
|
140
|
+
}));
|
|
141
|
+
const engine = rows.find((r) => r.kind === 'methodology-engine');
|
|
142
|
+
assert.equal(engine.manifestState, OK);
|
|
143
|
+
assert.ok(!engine.caveat);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('a broken engine whose orchestration "fragment" is a DIRECTORY is NOT a false "ok"', () => {
|
|
147
|
+
const rows = surveyFamily(engineDeps({
|
|
148
|
+
statType: () => 'dir', // orchestration path is a directory, not a file
|
|
149
|
+
}));
|
|
150
|
+
assert.ok(rows.find((r) => r.kind === 'methodology-engine').caveat, 'a non-file fragment is caveated');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a current engine whose orchestration fragment is PRESENT but UNREADABLE is NOT a false "ok"', () => {
|
|
154
|
+
const rows = surveyFamily(engineDeps({
|
|
155
|
+
statType: (p) => (String(p).endsWith('orchestration-slot.md') ? 'file' : 'dir'), // present as a file
|
|
156
|
+
readFileSync: () => {
|
|
157
|
+
throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); // but unreadable
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
const engine = rows.find((r) => r.kind === 'methodology-engine');
|
|
161
|
+
assert.ok(engine.caveat, 'an unreadable fragment is caveated (mirrors the reconcile STOP), not reported clean');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── surveyProject ────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('surveyProject', () => {
|
|
168
|
+
const projectDeps = ({ files }) => ({
|
|
169
|
+
exists: (p) => Object.prototype.hasOwnProperty.call(files, p) || Object.keys(files).some((k) => k === p),
|
|
170
|
+
readFile: (p) => {
|
|
171
|
+
if (!Object.prototype.hasOwnProperty.call(files, p)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
172
|
+
return files[p];
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('reports deployed + stamps + docs/ai + hidden fence', () => {
|
|
177
|
+
const dir = '/proj';
|
|
178
|
+
const files = {
|
|
179
|
+
[join(dir, 'docs/ai/.workflow-version')]: '1.3.0\n',
|
|
180
|
+
[join(dir, 'docs/ai/.memory-version')]: '1.1.1\n',
|
|
181
|
+
[join(dir, 'docs', 'ai')]: '',
|
|
182
|
+
[join(dir, '.git', 'info', 'exclude')]: `# user rule\n${START_MARKER}\n/AGENTS.md\n`,
|
|
183
|
+
};
|
|
184
|
+
const r = surveyProject(dir, projectDeps({ files }));
|
|
185
|
+
assert.equal(r.deployed, true);
|
|
186
|
+
assert.equal(r.docsAiPresent, true);
|
|
187
|
+
assert.equal(r.hiddenFence, true);
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
r.stamps.map((s) => [s.name, s.version]),
|
|
190
|
+
[['agent-workflow-kit', '1.3.0'], ['agent-workflow-memory', '1.1.1']],
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('reports not-deployed when there is no docs/ai and no stamp', () => {
|
|
195
|
+
const r = surveyProject('/empty', projectDeps({ files: {} }));
|
|
196
|
+
assert.equal(r.deployed, false);
|
|
197
|
+
assert.equal(r.hiddenFence, false);
|
|
198
|
+
assert.ok(r.stamps.every((s) => s.version === null));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── drift-guard: FAMILY_MEMBERS ⟷ the 5 in-repo capability.json (the AD-008 lockstep pattern) ──────
|
|
203
|
+
|
|
204
|
+
describe('FAMILY_MEMBERS drift-guard', () => {
|
|
205
|
+
const readManifest = (memberName) =>
|
|
206
|
+
JSON.parse(readFileSync(resolve(REPO_ROOT, memberName, 'capability.json'), 'utf8'));
|
|
207
|
+
|
|
208
|
+
// deduped roles[].cmd, in first-seen order (mirrors detect-backends' wrapperCmds derivation).
|
|
209
|
+
const dedupedCmds = (manifest) => {
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const role of Object.values(manifest.roles ?? {})) {
|
|
213
|
+
if (role && typeof role.cmd === 'string' && !seen.has(role.cmd)) {
|
|
214
|
+
seen.add(role.cmd);
|
|
215
|
+
out.push(role.cmd);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
it('has exactly the five family members and no release skills', () => {
|
|
222
|
+
assert.equal(FAMILY_MEMBERS.length, 5);
|
|
223
|
+
const names = FAMILY_MEMBERS.map((m) => m.name);
|
|
224
|
+
assert.ok(!names.includes('release-engineering'));
|
|
225
|
+
assert.ok(!names.includes('release-marketing'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
for (const member of FAMILY_MEMBERS) {
|
|
229
|
+
it(`${member.name}: name/kind/detect.installed/deployed/npm/wrapperCmds match the in-repo manifest`, () => {
|
|
230
|
+
const m = readManifest(member.name);
|
|
231
|
+
assert.equal(m.name, member.name);
|
|
232
|
+
assert.equal(m.kind, member.kind);
|
|
233
|
+
|
|
234
|
+
assert.equal(m.detect.installed.env, member.installed.env);
|
|
235
|
+
assert.equal(m.detect.installed.default, member.installed.default);
|
|
236
|
+
assert.equal(m.detect.installed.file, member.installed.file);
|
|
237
|
+
|
|
238
|
+
if (member.deployed) assert.equal(m.detect.deployed.file, member.deployed.file);
|
|
239
|
+
else assert.equal(m.detect?.deployed ?? null, null);
|
|
240
|
+
|
|
241
|
+
if (member.npm) assert.equal(m.install.npm, member.npm);
|
|
242
|
+
else assert.equal(m.install?.npm ?? null, null);
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(dedupedCmds(m), member.wrapperCmds);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
package/tools/fs-safe.mjs
CHANGED
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
// unit-testable without touching the real filesystem; the defaults are Node's SYNC fs (matching the
|
|
5
5
|
// tools/ detector style). Dependency-free, Node >= 18.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Five primitives:
|
|
8
8
|
// assertContainedRealPath — refuse to write through/into a symlink, or to a dest outside a root.
|
|
9
9
|
// copyTreeRefresh — recursive copy that OVERWRITES regular files (refresh), SKIPS a symlink
|
|
10
10
|
// whose dest already exists (additive), and guards every dest component.
|
|
11
11
|
// linkManaged — create/keep ONLY a symlink we own; STOP (typed ManagedLinkConflict) on
|
|
12
12
|
// a foreign symlink or a non-symlink dest; refuse a symlinked source.
|
|
13
|
+
// removeTreeManaged — the inverse of copyTreeRefresh: recursively remove a dir/file ONLY when
|
|
14
|
+
// it (and its path) is not reached through a symlink and stays within root.
|
|
15
|
+
// unlinkManaged — the inverse of linkManaged: remove ONLY a symlink whose target is ours;
|
|
16
|
+
// STOP (typed ManagedLinkConflict) on a foreign symlink or a non-symlink.
|
|
13
17
|
|
|
14
18
|
import {
|
|
15
19
|
lstatSync, existsSync, mkdirSync, readdirSync, copyFileSync, readlinkSync, symlinkSync,
|
|
20
|
+
rmSync, unlinkSync,
|
|
16
21
|
} from 'node:fs';
|
|
17
22
|
import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
|
|
18
23
|
|
|
@@ -130,3 +135,47 @@ export const linkManaged = (src, dest, root, deps = {}) => {
|
|
|
130
135
|
{ dest, expected: src, found: target },
|
|
131
136
|
);
|
|
132
137
|
};
|
|
138
|
+
|
|
139
|
+
// Recursively remove a managed dir/file — the inverse of copyTreeRefresh. `assertContainedRealPath`
|
|
140
|
+
// guards `target` first: it refuses a `target` outside `root`, and refuses when `root`, any
|
|
141
|
+
// intermediate component, OR `target` itself is a symlink — so we never delete *through* or *at* a
|
|
142
|
+
// symlink (a symlinked skill dir is a STOP, not a follow-and-delete). A recursive `rm` does NOT
|
|
143
|
+
// follow symlinks *inside* the tree (Node unlinks a symlink entry rather than recursing into its
|
|
144
|
+
// target), so an internal symlink is removed safely without touching what it points at. Outcomes:
|
|
145
|
+
// 'removed', or 'noop' when the target is already absent. Dependency-injected (lstat / rm).
|
|
146
|
+
export const removeTreeManaged = (target, root, deps = {}) => {
|
|
147
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
148
|
+
const rm = deps.rm ?? ((p) => rmSync(p, { recursive: true, force: true }));
|
|
149
|
+
assertContainedRealPath(root, target, deps);
|
|
150
|
+
if (lstatNoFollow(target, lstat) === null) return 'noop';
|
|
151
|
+
rm(target);
|
|
152
|
+
return 'removed';
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Remove ONLY a symlink we own — the inverse of linkManaged. Guards the PARENT chain (root +
|
|
156
|
+
// intermediate dirs) the same way linkManaged does (the leaf IS the managed symlink, so it is
|
|
157
|
+
// inspected, not traversal-rejected). Outcomes: 'unlinked' (a symlink whose resolved target is our
|
|
158
|
+
// `expectedSrc` — including a dangling-but-ours link), 'noop' (dest absent), or a thrown
|
|
159
|
+
// ManagedLinkConflict (a non-symlink, or a symlink pointing elsewhere — never removed).
|
|
160
|
+
export const unlinkManaged = (dest, expectedSrc, root, deps = {}) => {
|
|
161
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
162
|
+
const readlink = deps.readlink ?? readlinkSync;
|
|
163
|
+
const unlink = deps.unlink ?? unlinkSync;
|
|
164
|
+
|
|
165
|
+
assertContainedRealPath(root, dirname(dest), deps);
|
|
166
|
+
const existing = lstatNoFollow(dest, lstat);
|
|
167
|
+
if (existing === null) return 'noop';
|
|
168
|
+
if (!existing.isSymbolicLink()) {
|
|
169
|
+
throw managedLinkConflict(`refusing to remove a non-symlink at ${dest}`, { dest, found: 'file' });
|
|
170
|
+
}
|
|
171
|
+
const target = readlink(dest);
|
|
172
|
+
const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(dest), target);
|
|
173
|
+
if (resolvedTarget !== resolve(expectedSrc)) {
|
|
174
|
+
throw managedLinkConflict(
|
|
175
|
+
`refusing to remove a foreign symlink at ${dest} (points at ${target}, not our ${expectedSrc})`,
|
|
176
|
+
{ dest, expected: expectedSrc, found: target },
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
unlink(dest);
|
|
180
|
+
return 'unlinked';
|
|
181
|
+
};
|