@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.
@@ -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
- // Three primitives:
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
+ };