@sabaiway/agent-workflow-kit 1.10.0 → 1.12.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,250 @@
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
+
27
+ // ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
28
+ export const NOT_INSTALLED = 'not-installed';
29
+ export const UNSUPPORTED_SCHEMA = 'unsupported-schema';
30
+ export const INVALID_MANIFEST = 'invalid-manifest';
31
+ export const STUB = 'stub';
32
+ export const FOREIGN = 'foreign';
33
+ export const OK = 'ok';
34
+ // The marker could not be probed (a non-ENOENT fs error — EACCES/EIO). Surfaced explicitly instead of
35
+ // being masked as not-installed (no silent failure); uninstall treats it as "do not touch" (skip).
36
+ export const UNKNOWN = 'unknown';
37
+
38
+ // ── the unified registry ───────────────────────────────────────────────────────
39
+ // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
40
+ // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
41
+ // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
42
+ // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
43
+ // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
44
+ // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
45
+ // (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
46
+ export const FAMILY_MEMBERS = [
47
+ {
48
+ name: 'agent-workflow-kit',
49
+ kind: 'composition-root',
50
+ installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
51
+ deployed: { file: 'docs/ai/.workflow-version' },
52
+ npm: '@sabaiway/agent-workflow-kit',
53
+ wrapperCmds: [],
54
+ },
55
+ {
56
+ name: 'agent-workflow-memory',
57
+ kind: 'memory-substrate',
58
+ installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
59
+ deployed: { file: 'docs/ai/.memory-version' },
60
+ npm: '@sabaiway/agent-workflow-memory',
61
+ wrapperCmds: [],
62
+ },
63
+ {
64
+ name: 'agent-workflow-engine',
65
+ kind: 'methodology-engine',
66
+ installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
67
+ deployed: null,
68
+ npm: '@sabaiway/agent-workflow-engine',
69
+ wrapperCmds: [],
70
+ },
71
+ {
72
+ name: 'codex-cli-bridge',
73
+ kind: 'execution-backend',
74
+ installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
75
+ deployed: null,
76
+ npm: null,
77
+ wrapperCmds: ['codex-exec', 'codex-review'],
78
+ },
79
+ {
80
+ name: 'antigravity-cli-bridge',
81
+ kind: 'execution-backend',
82
+ installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
83
+ deployed: null,
84
+ npm: null,
85
+ wrapperCmds: ['agy-run'],
86
+ },
87
+ ];
88
+
89
+ // A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
90
+ // uninstaller warns before removing one (there is no cross-project dependency tracking). All current
91
+ // members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
92
+ export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
93
+
94
+ // ── pure probes ──────────────────────────────────────────────────────────────────
95
+ // Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a file) | 'unknown' (a
96
+ // non-ENOENT fs error, e.g. EACCES). 'unknown' is NOT collapsed to 'absent': a permission error must
97
+ // surface, never be masked as not-installed (no silent failure) — and uninstall then leaves it alone.
98
+ const probeMarker = (path, deps = {}) => {
99
+ const exists = deps.exists ?? existsSync;
100
+ const stat = deps.stat ?? statSync;
101
+ try {
102
+ if (!exists(path)) return 'absent';
103
+ return stat(path).isFile() ? 'present' : 'absent';
104
+ } catch (err) {
105
+ return err && err.code === 'ENOENT' ? 'absent' : 'unknown';
106
+ }
107
+ };
108
+
109
+ // Pure manifestState classifier — the detect-backends precedence, generalized to a member's own
110
+ // expected name + kind: not-installed → unsupported-schema → invalid-manifest → stub → foreign → ok.
111
+ const classifyState = (markerPresent, report, member) => {
112
+ if (!markerPresent) return NOT_INSTALLED;
113
+ if (report.result === UNSUPPORTED) return UNSUPPORTED_SCHEMA;
114
+ if (report.result === INVALID) return INVALID_MANIFEST;
115
+ if (report.available === false) return STUB;
116
+ if (report.kind !== member.kind || report.name !== member.name) return FOREIGN;
117
+ return OK;
118
+ };
119
+
120
+ // ── the SKILL axis ─────────────────────────────────────────────────────────────
121
+ // classifyMember → { name, kind, installed, skillDir, manifestState, version }. Reuses resolveDir
122
+ // (detect-backends), validateManifest + readAuthoritativeVersion (the manifest validator) — one
123
+ // authoritative version reader, no second drifting source. `version` is set only for an `ok` member.
124
+ export const classifyMember = (member, deps = {}) => {
125
+ const validate = deps.validate ?? validateManifest;
126
+ const readVersion = deps.readVersion ?? readAuthoritativeVersion;
127
+ const getenv = deps.getenv ?? process.env;
128
+ const home = deps.home ?? os.homedir();
129
+
130
+ const skillDir = resolveDir({ env: member.installed.env, default: member.installed.default }, getenv, home);
131
+ const marker = probeMarker(join(skillDir, member.installed.file), deps);
132
+ // A marker we cannot probe (EACCES/EIO) → 'unknown': reported, but NOT installed (so uninstall never
133
+ // removes a dir whose ownership it could not verify). Distinct from 'not-installed' (genuinely absent).
134
+ if (marker === 'unknown') {
135
+ return { name: member.name, kind: member.kind, installed: false, skillDir, manifestState: UNKNOWN, version: null };
136
+ }
137
+ const markerPresent = marker === 'present';
138
+ const report = markerPresent ? validate(skillDir) : { result: NOT_INSTALLED };
139
+ const manifestState = classifyState(markerPresent, report, member);
140
+ const installed = manifestState !== NOT_INSTALLED;
141
+ const version = manifestState === OK ? readVersion(skillDir).version ?? null : null;
142
+
143
+ return { name: member.name, kind: member.kind, installed, skillDir: installed ? skillDir : null, manifestState, version };
144
+ };
145
+
146
+ export const surveyFamily = (deps = {}) => FAMILY_MEMBERS.map((member) => classifyMember(member, deps));
147
+
148
+ // ── the DEPLOY axis ──────────────────────────────────────────────────────────────
149
+ // Read a one-line semver stamp (docs/ai/.workflow-version etc.). Returns the trimmed version or null.
150
+ const readStamp = (path, deps = {}) => {
151
+ const exists = deps.exists ?? existsSync;
152
+ const read = deps.readFile ?? readFileSync;
153
+ try {
154
+ if (!exists(path)) return null;
155
+ const v = String(read(path, 'utf8')).trim();
156
+ return v.length ? v : null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ };
161
+
162
+ // Is our hidden-mode managed fence present? Resolve the exclude file via the SAME git-path-aware path
163
+ // hide-footprint uses (`git rev-parse --git-path info/exclude`), so a linked worktree / submodule is
164
+ // handled correctly (not the hardcoded `.git/info/exclude`). If git is unavailable or this is not a
165
+ // repo, fall back to the conventional path; any read error → not present (best-effort, read-only).
166
+ const hasHiddenFence = (projectDir, deps = {}) => {
167
+ const exists = deps.exists ?? existsSync;
168
+ const read = deps.readFile ?? readFileSync;
169
+ const ep = (() => {
170
+ try {
171
+ return excludePath(deps, projectDir);
172
+ } catch {
173
+ return join(projectDir, '.git', 'info', 'exclude');
174
+ }
175
+ })();
176
+ try {
177
+ return exists(ep) && String(read(ep, 'utf8')).includes(START_MARKER);
178
+ } catch {
179
+ return false;
180
+ }
181
+ };
182
+
183
+ // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
184
+ // docs/ai/ exists, and whether the hidden-mode fence is present. Pure (fs reads only, all injectable),
185
+ // no git subprocess — the read-only `status` view must never mutate or spawn anything.
186
+ export const surveyProject = (projectDir, deps = {}) => {
187
+ const exists = deps.exists ?? existsSync;
188
+ const dir = resolve(projectDir);
189
+ const stamps = FAMILY_MEMBERS
190
+ .filter((m) => m.deployed)
191
+ .map((m) => ({ name: m.name, file: m.deployed.file, version: readStamp(join(dir, m.deployed.file), deps) }));
192
+ const docsAiPresent = (() => {
193
+ try {
194
+ return exists(join(dir, 'docs', 'ai'));
195
+ } catch {
196
+ return false;
197
+ }
198
+ })();
199
+ const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
200
+ return { dir, deployed, docsAiPresent, hiddenFence: hasHiddenFence(dir, deps), stamps };
201
+ };
202
+
203
+ // ── report ───────────────────────────────────────────────────────────────────────
204
+ const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
205
+
206
+ export const formatStatus = (family, project = null) => {
207
+ const lines = ['agent-workflow family — installed skills (skill axis)', ''];
208
+ for (const m of family) {
209
+ const ver = m.version ? `v${m.version}` : '—';
210
+ lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
211
+ }
212
+ if (project) {
213
+ lines.push('', `project deployment (${project.dir})`, '');
214
+ if (!project.deployed) {
215
+ lines.push(' no agent-workflow deployment detected here (no docs/ai, no version stamp).');
216
+ } else {
217
+ for (const s of project.stamps) {
218
+ lines.push(` ${pad(s.file, 26)}${s.version ?? '—'}`);
219
+ }
220
+ lines.push(` ${pad('docs/ai present', 26)}${project.docsAiPresent ? 'yes' : 'no'}`);
221
+ lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
222
+ }
223
+ }
224
+ return lines.join('\n');
225
+ };
226
+
227
+ // ── CLI ────────────────────────────────────────────────────────────────────────
228
+ const parseArgs = (argv) => {
229
+ const dirFlag = argv.indexOf('--dir');
230
+ return { help: argv.includes('--help') || argv.includes('-h'), dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined };
231
+ };
232
+
233
+ const main = (argv) => {
234
+ const args = parseArgs(argv);
235
+ if (args.help) {
236
+ console.log(`family-registry — read-only view of the agent-workflow family.
237
+
238
+ Usage:
239
+ node family-registry.mjs [--dir <project>] # skill axis always; deploy axis when --dir is given
240
+
241
+ Detection only — never writes, never commits, never runs a subscription CLI.`);
242
+ return;
243
+ }
244
+ const family = surveyFamily();
245
+ const project = args.dir ? surveyProject(args.dir) : null;
246
+ console.log(formatStatus(family, project));
247
+ };
248
+
249
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
250
+ if (isDirectRun) main(process.argv.slice(2));
@@ -0,0 +1,189 @@
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
+
107
+ // ── surveyProject ────────────────────────────────────────────────────────────────
108
+
109
+ describe('surveyProject', () => {
110
+ const projectDeps = ({ files }) => ({
111
+ exists: (p) => Object.prototype.hasOwnProperty.call(files, p) || Object.keys(files).some((k) => k === p),
112
+ readFile: (p) => {
113
+ if (!Object.prototype.hasOwnProperty.call(files, p)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
114
+ return files[p];
115
+ },
116
+ });
117
+
118
+ it('reports deployed + stamps + docs/ai + hidden fence', () => {
119
+ const dir = '/proj';
120
+ const files = {
121
+ [join(dir, 'docs/ai/.workflow-version')]: '1.3.0\n',
122
+ [join(dir, 'docs/ai/.memory-version')]: '1.1.1\n',
123
+ [join(dir, 'docs', 'ai')]: '',
124
+ [join(dir, '.git', 'info', 'exclude')]: `# user rule\n${START_MARKER}\n/AGENTS.md\n`,
125
+ };
126
+ const r = surveyProject(dir, projectDeps({ files }));
127
+ assert.equal(r.deployed, true);
128
+ assert.equal(r.docsAiPresent, true);
129
+ assert.equal(r.hiddenFence, true);
130
+ assert.deepEqual(
131
+ r.stamps.map((s) => [s.name, s.version]),
132
+ [['agent-workflow-kit', '1.3.0'], ['agent-workflow-memory', '1.1.1']],
133
+ );
134
+ });
135
+
136
+ it('reports not-deployed when there is no docs/ai and no stamp', () => {
137
+ const r = surveyProject('/empty', projectDeps({ files: {} }));
138
+ assert.equal(r.deployed, false);
139
+ assert.equal(r.hiddenFence, false);
140
+ assert.ok(r.stamps.every((s) => s.version === null));
141
+ });
142
+ });
143
+
144
+ // ── drift-guard: FAMILY_MEMBERS ⟷ the 5 in-repo capability.json (the AD-008 lockstep pattern) ──────
145
+
146
+ describe('FAMILY_MEMBERS drift-guard', () => {
147
+ const readManifest = (memberName) =>
148
+ JSON.parse(readFileSync(resolve(REPO_ROOT, memberName, 'capability.json'), 'utf8'));
149
+
150
+ // deduped roles[].cmd, in first-seen order (mirrors detect-backends' wrapperCmds derivation).
151
+ const dedupedCmds = (manifest) => {
152
+ const seen = new Set();
153
+ const out = [];
154
+ for (const role of Object.values(manifest.roles ?? {})) {
155
+ if (role && typeof role.cmd === 'string' && !seen.has(role.cmd)) {
156
+ seen.add(role.cmd);
157
+ out.push(role.cmd);
158
+ }
159
+ }
160
+ return out;
161
+ };
162
+
163
+ it('has exactly the five family members and no release skills', () => {
164
+ assert.equal(FAMILY_MEMBERS.length, 5);
165
+ const names = FAMILY_MEMBERS.map((m) => m.name);
166
+ assert.ok(!names.includes('release-engineering'));
167
+ assert.ok(!names.includes('release-marketing'));
168
+ });
169
+
170
+ for (const member of FAMILY_MEMBERS) {
171
+ it(`${member.name}: name/kind/detect.installed/deployed/npm/wrapperCmds match the in-repo manifest`, () => {
172
+ const m = readManifest(member.name);
173
+ assert.equal(m.name, member.name);
174
+ assert.equal(m.kind, member.kind);
175
+
176
+ assert.equal(m.detect.installed.env, member.installed.env);
177
+ assert.equal(m.detect.installed.default, member.installed.default);
178
+ assert.equal(m.detect.installed.file, member.installed.file);
179
+
180
+ if (member.deployed) assert.equal(m.detect.deployed.file, member.deployed.file);
181
+ else assert.equal(m.detect?.deployed ?? null, null);
182
+
183
+ if (member.npm) assert.equal(m.install.npm, member.npm);
184
+ else assert.equal(m.install?.npm ?? null, null);
185
+
186
+ assert.deepEqual(dedupedCmds(m), member.wrapperCmds);
187
+ });
188
+ }
189
+ });
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
 
@@ -44,7 +49,10 @@ export const assertContainedRealPath = (root, dest, deps = {}) => {
44
49
  const lstat = deps.lstat ?? lstatSync;
45
50
  const ln = (p) => lstatNoFollow(p, lstat);
46
51
  const rel = relative(root, dest);
47
- if (rel.startsWith('..') || isAbsolute(rel)) {
52
+ // A true escape is `..` exactly or a `..`-prefixed PATH SEGMENT (`../x`) — NOT any string starting
53
+ // with the two chars "..": a legitimately-contained child literally named `..foo` has rel `..foo`,
54
+ // which the old `rel.startsWith('..')` wrongly rejected (Issue-004 — same fix as the engine/memory installers).
55
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
48
56
  throw new Error(`[agent-workflow-kit] refusing to write outside the target dir: ${dest}`);
49
57
  }
50
58
  if (ln(root)?.isSymbolicLink()) {
@@ -127,3 +135,47 @@ export const linkManaged = (src, dest, root, deps = {}) => {
127
135
  { dest, expected: src, found: target },
128
136
  );
129
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
+ };