From 517a7cac75f65abcfb47b15dbbfd1a3d3d48f273 Mon Sep 17 00:00:00 2001
From: Dan Mestas <93289084+danmestas@users.noreply.github.com>
Date: Mon, 25 May 2026 05:44:29 -0500
Subject: [PATCH] feat: suit off cleans up injected components (+
--keep-injected)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
suit off iterated only the up-applied files[] and then deleted the
lockfile, orphaning any inject-recorded files on disk — fatal for an
inject-only lockfile (files:[], injected:[...]) created when a worker was
launched via stateless prepare.
- extract detectDrift() + removeTrackedFile() + RemovalState so up-applied
and injected files traverse one removal contract (drift scan, additive
block-strip, replace unlink, dir-prune, tallies)
- default off now removes injected files too, then deletes the lockfile
- --keep-injected: remove only up files, preserve injected files on disk,
rewrite the lockfile with files:[] + reset resolution + injected intact;
degrades to a full off when there are no injected entries
- off now accepts --project/--home
(was cwd-only), matching current
Lifecycle verified end-to-end: inject -> off --keep-injected (preserves) ->
off (clean teardown, no orphans).
---
src/ac.ts | 33 +++-
src/lib/ac/off.ts | 327 ++++++++++++++++++++++++++-------------
src/tests/ac/off.test.ts | 285 ++++++++++++++++++++++++++++++++++
3 files changed, 537 insertions(+), 108 deletions(-)
diff --git a/src/ac.ts b/src/ac.ts
index 6d15d5c..991e2b9 100644
--- a/src/ac.ts
+++ b/src/ac.ts
@@ -705,14 +705,39 @@ async function main(): Promise {
}
if (cmd === 'off') {
+ // `suit off` defaults to the cwd project, but accepts `--project `
+ // (or `--home `, the alias `suit inject` uses) so an operator can tear
+ // down a lockfile — including its `injected` list — at an inject target
+ // that isn't the cwd. `--keep-injected` removes only the up-applied files
+ // and preserves the injected components.
const rest = argv.slice(1);
+ let projectDir = dirs.projectDir;
let force = false;
+ let keepInjected = false;
let err: string | null = null;
- for (const a of rest) {
- if (a === '--force') {
+ for (let i = 0; i < rest.length; i++) {
+ const arg = rest[i];
+ let flag = arg;
+ let eqValue: string | undefined;
+ const eq = arg.indexOf('=');
+ if (arg.startsWith('--') && eq !== -1) {
+ flag = arg.slice(0, eq);
+ eqValue = arg.slice(eq + 1);
+ }
+ if (flag === '--force') {
force = true;
+ } else if (flag === '--keep-injected') {
+ keepInjected = true;
+ } else if (flag === '--project' || flag === '--home') {
+ const value = eqValue ?? rest[i + 1];
+ if (value === undefined || value.startsWith('-')) {
+ err = err ?? `suit off: ${flag} requires a value`;
+ continue;
+ }
+ projectDir = path.resolve(value);
+ if (eqValue === undefined) i += 1;
} else {
- err = err ?? `suit off: unrecognized argument "${a}"`;
+ err = err ?? `suit off: unrecognized argument "${arg}"`;
}
}
if (err) {
@@ -720,7 +745,7 @@ async function main(): Promise {
return 2;
}
return runOff(
- { projectDir: dirs.projectDir, force },
+ { projectDir, force, keepInjected },
{
stdout: (s) => process.stdout.write(s),
stderr: (s) => process.stderr.write(s),
diff --git a/src/lib/ac/off.ts b/src/lib/ac/off.ts
index 753cfc2..d6b48ef 100644
--- a/src/lib/ac/off.ts
+++ b/src/lib/ac/off.ts
@@ -5,6 +5,19 @@
* unless `--force`), prunes now-empty parent directories that the apply created,
* and finally removes the lockfile + `.suit/` dir if it was solely ours.
*
+ * Injection awareness (slice e/4): a lockfile may also carry an `injected` list
+ * (components pushed by `suit inject`). Those files use the IDENTICAL LockEntry
+ * removal contract as `up`-applied `files[]`, so both flow through one shared
+ * per-file path here — drift scan, delete/strip, dir-prune, and tallies all
+ * treat an injected file the same as an up file. Without this, an inject-only
+ * lockfile (files:[], injected:[...]) would have its lockfile deleted while the
+ * injected files were orphaned on disk.
+ *
+ * `--keep-injected`: remove only the `up`-applied files and REWRITE the lockfile
+ * with an empty `files`/reset `resolution` but the `injected` list preserved, so
+ * a worker's injected components survive a teardown of the up-applied outfit. If
+ * there are no injected entries, it degrades to a normal full `off`.
+ *
* Idempotency: a missing lockfile is a no-op (exit 0 with a friendly message).
* Drift collection is greedy — without `--force`, every hand-edited file is
* reported in one batch before exiting non-zero, so the user sees the full list
@@ -12,12 +25,27 @@
*/
import fs from 'node:fs/promises';
import path from 'node:path';
-import { LOCKFILE_PATH, deleteLockfile, readLockfile, sha256OfBuffer, sha256OfFile } from '../lockfile.js';
+import {
+ LOCKFILE_PATH,
+ deleteLockfile,
+ readLockfile,
+ sha256OfBuffer,
+ sha256OfFile,
+ writeLockfile,
+ type LockEntry,
+} from '../lockfile.js';
import { extractSuitBlockFull, stripSuitBlocks } from '../writer.js';
export interface RunOffArgs {
projectDir: string;
force: boolean;
+ /**
+ * Remove only the `up`-applied `files[]`; leave every `injected[].files` on
+ * disk and rewrite (rather than delete) the lockfile so the injected entries
+ * survive. No-op extra behavior when the lockfile has no injected entries —
+ * in that case `off` is a normal full teardown.
+ */
+ keepInjected?: boolean;
}
export interface RunOffDeps {
@@ -64,6 +92,124 @@ async function pruneEmptyAncestors(projectDir: string, startRel: string): Promis
}
}
+/**
+ * Drift verdict for a single tracked file. `null` means "no drift to report"
+ * (in-sync, already stripped, or already gone). A string means the relative
+ * path drifted and should be collected for the batch refusal.
+ */
+async function detectDrift(projectDir: string, f: LockEntry): Promise {
+ const full = path.join(projectDir, f.path);
+ let exists = false;
+ try {
+ await fs.stat(full);
+ exists = true;
+ } catch {
+ exists = false;
+ }
+ if (!exists) return null; // already gone, no drift to detect
+
+ if (f.mode === 'additive') {
+ // For additive entries the recorded sha is the marker-block hash, not the
+ // whole-file hash. Find the block; absent → already stripped (no drift),
+ // present-but-mutated → drift.
+ const fileContent = await fs.readFile(full, 'utf8');
+ const blockFull = extractSuitBlockFull(fileContent);
+ if (blockFull === null) return null; // already stripped — nothing to do
+ const blockSha = sha256OfBuffer(blockFull);
+ return blockSha !== f.sha256 ? f.path : null;
+ }
+
+ const currentSha = await sha256OfFile(full);
+ return currentSha !== f.sha256 ? f.path : null;
+}
+
+/** Mutable tallies threaded through the per-file removal pass. */
+interface RemovalState {
+ removedDirs: Set;
+ removed: number;
+ skippedMissing: number;
+ forcedDrift: string[];
+}
+
+/**
+ * Remove a single tracked file using the contract shared by `up`-applied and
+ * injected entries:
+ * - missing on disk → counted as skippedMissing, no-op
+ * - additive → strip the marker block; delete the host file if it becomes
+ * empty, otherwise write the remaining user content back
+ * - replace → unlink the whole file
+ * Under `--force`, a sha mismatch is recorded in `forcedDrift` (informational,
+ * not fatal — the drift refusal already happened in the preflight). Removed
+ * parents are registered for the later dir-prune pass.
+ */
+async function removeTrackedFile(
+ projectDir: string,
+ f: LockEntry,
+ force: boolean,
+ state: RemovalState,
+): Promise {
+ const full = path.join(projectDir, f.path);
+ let exists = false;
+ try {
+ await fs.stat(full);
+ exists = true;
+ } catch {
+ exists = false;
+ }
+ if (!exists) {
+ state.skippedMissing++;
+ return;
+ }
+
+ if (f.mode === 'additive') {
+ const before = await fs.readFile(full, 'utf8');
+ const blockFull = extractSuitBlockFull(before);
+ if (force && blockFull !== null) {
+ const blockSha = sha256OfBuffer(blockFull);
+ if (blockSha !== f.sha256) state.forcedDrift.push(f.path);
+ }
+ const after = stripSuitBlocks(before);
+ if (after.trim().length === 0) {
+ await fs.unlink(full);
+ const parentRel = path.posix.dirname(f.path);
+ if (parentRel && parentRel !== '.' && parentRel !== '/') {
+ state.removedDirs.add(parentRel);
+ }
+ } else {
+ await fs.writeFile(full, after);
+ }
+ state.removed++;
+ return;
+ }
+
+ if (force) {
+ // Re-check sha for the report. A mismatch under --force is informational.
+ try {
+ const currentSha = await sha256OfFile(full);
+ if (currentSha !== f.sha256) state.forcedDrift.push(f.path);
+ } catch {
+ // best-effort — proceed with delete
+ }
+ }
+
+ try {
+ await fs.unlink(full);
+ state.removed++;
+ const parentRel = path.posix.dirname(f.path);
+ if (parentRel && parentRel !== '.' && parentRel !== '/') {
+ state.removedDirs.add(parentRel);
+ }
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === 'ENOENT') {
+ // raced with something else removing it; treat as missing
+ state.skippedMissing++;
+ return;
+ }
+ throw err;
+ }
+}
+
export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise {
const lock = await readLockfile(args.projectDir);
if (!lock) {
@@ -71,33 +217,24 @@ export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise c.files);
+ // `--keep-injected` only means anything if there's something to keep.
+ const keepInjected = args.keepInjected === true && injectedComponents.length > 0;
+
+ // Stage 1: drift preflight (non-force only). Collect ALL hand-edited files —
+ // up-applied AND injected — in one pass so the user sees the full list before
+ // deciding how to recover. A drifted injected file reads the same as a
+ // drifted up file. Under --keep-injected we skip injected files (we're
+ // leaving them on disk, so their on-disk state is none of our business).
if (!args.force) {
const drifted: string[] = [];
- for (const f of lock.files) {
- const full = path.join(args.projectDir, f.path);
- let exists = false;
- try {
- await fs.stat(full);
- exists = true;
- } catch {
- exists = false;
- }
- if (!exists) continue; // already gone, no drift to detect
-
- if (f.mode === 'additive') {
- // For additive entries the recorded sha is the marker-block hash, not
- // the whole-file hash. Find the block; absent or mutated → drift.
- const fileContent = await fs.readFile(full, 'utf8');
- const blockFull = extractSuitBlockFull(fileContent);
- if (blockFull === null) continue; // already stripped — nothing to do
- const blockSha = sha256OfBuffer(blockFull);
- if (blockSha !== f.sha256) drifted.push(f.path);
- } else {
- const currentSha = await sha256OfFile(full);
- if (currentSha !== f.sha256) drifted.push(f.path);
- }
+ const scanTargets: LockEntry[] = keepInjected
+ ? [...lock.files]
+ : [...lock.files, ...injectedFiles];
+ for (const f of scanTargets) {
+ const drift = await detectDrift(args.projectDir, f);
+ if (drift !== null) drifted.push(drift);
}
if (drifted.length > 0) {
for (const p of drifted) {
@@ -111,107 +248,89 @@ export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise();
- let removed = 0;
- let skippedMissing = 0;
- const forcedDrift: string[] = [];
+ // Stage 2: remove tracked files via the shared per-file path. up-applied
+ // files always; injected files unless --keep-injected.
+ const state: RemovalState = {
+ removedDirs: new Set(),
+ removed: 0,
+ skippedMissing: 0,
+ forcedDrift: [],
+ };
for (const f of lock.files) {
- const full = path.join(args.projectDir, f.path);
- let exists = false;
- try {
- await fs.stat(full);
- exists = true;
- } catch {
- exists = false;
- }
- if (!exists) {
- skippedMissing++;
- continue;
- }
-
- if (f.mode === 'additive') {
- // Strip just the marker block from the file. If the file becomes empty
- // (only whitespace) after the strip, delete it; otherwise write the
- // remaining user content back. Drift report happens above; under
- // --force we strip regardless.
- const before = await fs.readFile(full, 'utf8');
- const blockFull = extractSuitBlockFull(before);
- if (args.force && blockFull !== null) {
- const blockSha = sha256OfBuffer(blockFull);
- if (blockSha !== f.sha256) forcedDrift.push(f.path);
- }
- const after = stripSuitBlocks(before);
- if (after.trim().length === 0) {
- await fs.unlink(full);
- const parentRel = path.posix.dirname(f.path);
- if (parentRel && parentRel !== '.' && parentRel !== '/') {
- removedDirs.add(parentRel);
- }
- } else {
- await fs.writeFile(full, after);
- }
- removed++;
- continue;
- }
-
- if (args.force) {
- // Re-check sha for the report. A mismatch under --force is informational,
- // not fatal — we delete anyway.
- try {
- const currentSha = await sha256OfFile(full);
- if (currentSha !== f.sha256) forcedDrift.push(f.path);
- } catch {
- // best-effort — proceed with delete
- }
- }
+ await removeTrackedFile(args.projectDir, f, args.force, state);
+ }
- try {
- await fs.unlink(full);
- removed++;
- const parentRel = path.posix.dirname(f.path);
- if (parentRel && parentRel !== '.' && parentRel !== '/') {
- removedDirs.add(parentRel);
- }
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === 'ENOENT') {
- // raced with something else removing it; treat as missing
- skippedMissing++;
- continue;
+ let injectedRemovedFiles = 0;
+ let injectedRemovedComponents = 0;
+ if (!keepInjected) {
+ for (const c of injectedComponents) {
+ const before = state.removed;
+ for (const f of c.files) {
+ await removeTrackedFile(args.projectDir, f, args.force, state);
}
- throw err;
+ const thisComponentRemoved = state.removed - before;
+ injectedRemovedFiles += thisComponentRemoved;
+ if (thisComponentRemoved > 0) injectedRemovedComponents++;
}
}
// Stage 3: prune empty parent directories. Sort by depth (deepest first) so
// each climb starts at a leaf and walks up cleanly.
- const dirsByDepth = Array.from(removedDirs).sort(
+ const dirsByDepth = Array.from(state.removedDirs).sort(
(a, b) => b.split('/').length - a.split('/').length,
);
for (const dir of dirsByDepth) {
await pruneEmptyAncestors(args.projectDir, dir);
}
- // Stage 4: delete the lockfile and (if empty) the `.suit/` dir.
- await deleteLockfile(args.projectDir);
+ // Stage 4: lockfile teardown. Default removes everything then deletes the
+ // lockfile. --keep-injected (with injected entries present) rewrites it with
+ // an empty up-resolution but the injected list intact.
+ if (keepInjected) {
+ const rewritten = {
+ ...lock,
+ files: [],
+ resolution: {
+ outfit: null,
+ ...(lock.resolution.fit !== undefined ? { fit: null } : {}),
+ cut: null,
+ accessories: [],
+ },
+ injected: injectedComponents,
+ };
+ await writeLockfile(args.projectDir, rewritten);
+ } else {
+ await deleteLockfile(args.projectDir);
+ }
// Stage 5: report.
- deps.stdout(`Removed ${removed} file${removed === 1 ? '' : 's'} from ${args.projectDir}\n`);
- if (skippedMissing > 0) {
+ deps.stdout(`Removed ${state.removed} file${state.removed === 1 ? '' : 's'} from ${args.projectDir}\n`);
+ if (state.skippedMissing > 0) {
+ deps.stdout(
+ `Skipped ${state.skippedMissing} already-missing file${state.skippedMissing === 1 ? '' : 's'}\n`,
+ );
+ }
+ if (injectedRemovedFiles > 0) {
deps.stdout(
- `Skipped ${skippedMissing} already-missing file${skippedMissing === 1 ? '' : 's'}\n`,
+ `Removed ${injectedRemovedFiles} injected file(s) (${injectedRemovedComponents} component(s))\n`,
);
}
- if (forcedDrift.length > 0) {
+ if (keepInjected) {
+ const keptFiles = injectedFiles.length;
deps.stdout(
- `Force-deleted ${forcedDrift.length} hand-edited file${forcedDrift.length === 1 ? '' : 's'}:\n`,
+ `Kept ${keptFiles} injected file(s) (${injectedComponents.length} component(s)); lockfile retained\n`,
);
- for (const p of forcedDrift) deps.stdout(` ${p}\n`);
}
- deps.stdout(`Removed lockfile: ${path.join(args.projectDir, LOCKFILE_PATH)}\n`);
+ if (state.forcedDrift.length > 0) {
+ deps.stdout(
+ `Force-deleted ${state.forcedDrift.length} hand-edited file${state.forcedDrift.length === 1 ? '' : 's'}:\n`,
+ );
+ for (const p of state.forcedDrift) deps.stdout(` ${p}\n`);
+ }
+ if (!keepInjected) {
+ deps.stdout(`Removed lockfile: ${path.join(args.projectDir, LOCKFILE_PATH)}\n`);
+ }
return 0;
}
diff --git a/src/tests/ac/off.test.ts b/src/tests/ac/off.test.ts
index ed0637d..b47277e 100644
--- a/src/tests/ac/off.test.ts
+++ b/src/tests/ac/off.test.ts
@@ -317,3 +317,288 @@ describe('runOff — .suit/ dir cleanup', () => {
expect(await fileExists(path.join(proj, '.suit', 'project-overlay.md'))).toBe(true);
});
});
+
+// --- injected component removal (slice e/4) -------------------------------
+
+/** Marker-wrapped additive block, mirroring writer.ts's SUIT_BLOCK shape. */
+function additiveBlock(name: string, body: string): string {
+ return `\n${body}\n`;
+}
+
+describe('runOff — injected file removal (both up + injected)', () => {
+ it('removes up files AND injected files, then deletes the lockfile', async () => {
+ const proj = await mkProject();
+ const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n');
+ const inj1 = await plant(proj, '.claude/skills/phil/SKILL.md', 'phil body\n', 'skills/phil');
+ const inj2 = await plant(proj, '.claude/skills/norm/SKILL.md', 'norm body\n', 'skills/norm');
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: 'backend', cut: null, accessories: [] },
+ files: [up1],
+ injected: [
+ {
+ component: 'philosophy',
+ injectedAt: new Date().toISOString(),
+ files: [inj1, inj2],
+ },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(cap.err.join('')).toBe('');
+ expect(code).toBe(0);
+
+ expect(await fileExists(path.join(proj, up1.path))).toBe(false);
+ expect(await fileExists(path.join(proj, inj1.path))).toBe(false);
+ expect(await fileExists(path.join(proj, inj2.path))).toBe(false);
+ expect(await readLockfile(proj)).toBeNull();
+
+ const out = cap.out.join('');
+ expect(out).toMatch(/Removed 3 files/);
+ expect(out).toMatch(/Removed 2 injected file\(s\) \(1 component\(s\)\)/);
+ expect(out).toMatch(/Removed lockfile:/);
+ });
+});
+
+describe('runOff — inject-only lockfile (the orphan bug)', () => {
+ it('removes injected files and deletes the lockfile when files:[]', async () => {
+ const proj = await mkProject();
+ const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a\n', 'skills/a');
+ const inj2 = await plant(proj, '.claude/skills/b/SKILL.md', 'b\n', 'skills/b');
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: null, cut: null, accessories: [] },
+ files: [],
+ injected: [
+ {
+ component: 'philosophy',
+ injectedAt: new Date().toISOString(),
+ files: [inj1, inj2],
+ },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(cap.err.join('')).toBe('');
+ expect(code).toBe(0);
+
+ // Injected files gone (no longer orphaned).
+ expect(await fileExists(path.join(proj, inj1.path))).toBe(false);
+ expect(await fileExists(path.join(proj, inj2.path))).toBe(false);
+ // Lockfile gone.
+ expect(await readLockfile(proj)).toBeNull();
+ expect(await fileExists(path.join(proj, '.suit'))).toBe(false);
+
+ const out = cap.out.join('');
+ expect(out).toMatch(/Removed 2 injected file\(s\) \(1 component\(s\)\)/);
+ });
+});
+
+describe('runOff — drift on an injected file', () => {
+ it('refuses without --force, lists the injected drift, then removes under --force', async () => {
+ const proj = await mkProject();
+ const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a-original\n', 'skills/a');
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: null, cut: null, accessories: [] },
+ files: [],
+ injected: [
+ {
+ component: 'philosophy',
+ injectedAt: new Date().toISOString(),
+ files: [inj1],
+ },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ // Hand-edit the injected file.
+ await fs.writeFile(path.join(proj, inj1.path), 'a-edited\n');
+
+ // Non-force: refuse, list it the same as an up drift.
+ const cap1 = capture();
+ const code1 = await runOff(
+ { projectDir: proj, force: false },
+ { stdout: cap1.push, stderr: cap1.pushE },
+ );
+ expect(code1).toBe(1);
+ const err = cap1.err.join('');
+ expect(err).toMatch(/hand-edited since suit applied it: \.claude\/skills\/a\/SKILL\.md/);
+ expect(err).toMatch(/refusing to delete 1 hand-edited file/);
+ expect(await fileExists(path.join(proj, inj1.path))).toBe(true);
+ expect(await readLockfile(proj)).not.toBeNull();
+
+ // Force: remove anyway, report as forcedDrift.
+ const cap2 = capture();
+ const code2 = await runOff(
+ { projectDir: proj, force: true },
+ { stdout: cap2.push, stderr: cap2.pushE },
+ );
+ expect(code2).toBe(0);
+ expect(await fileExists(path.join(proj, inj1.path))).toBe(false);
+ expect(await readLockfile(proj)).toBeNull();
+ const out = cap2.out.join('');
+ expect(out).toMatch(/Force-deleted 1 hand-edited file:/);
+ expect(out).toMatch(/\.claude\/skills\/a\/SKILL\.md/);
+ });
+});
+
+describe('runOff — additive injected file', () => {
+ it('strips just the marker block, preserving surrounding user content', async () => {
+ const proj = await mkProject();
+ const block = additiveBlock('philosophy', 'INJECTED LINE');
+ const userBefore = '# my notes\n\n';
+ const userAfter = '\n\n# more notes\n';
+ const full = path.join(proj, '.claude/CLAUDE.md');
+ await fs.mkdir(path.dirname(full), { recursive: true });
+ await fs.writeFile(full, userBefore + block + userAfter);
+
+ const inj1 = {
+ path: '.claude/CLAUDE.md',
+ sha256: sha256OfBuffer(block),
+ sourceComponent: 'accessories/philosophy',
+ mode: 'additive' as const,
+ };
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: null, cut: null, accessories: [] },
+ files: [],
+ injected: [
+ { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1] },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(code).toBe(0);
+ expect(cap.err.join('')).toBe('');
+
+ // File survives, block gone, user content kept.
+ expect(await fileExists(full)).toBe(true);
+ const remaining = await fs.readFile(full, 'utf8');
+ expect(remaining).not.toMatch(/INJECTED LINE/);
+ expect(remaining).toMatch(/my notes/);
+ expect(remaining).toMatch(/more notes/);
+ });
+
+ it('deletes the host file when the additive injected block was its only content', async () => {
+ const proj = await mkProject();
+ const block = additiveBlock('philosophy', 'ONLY CONTENT');
+ const full = path.join(proj, '.claude/skills/x/SKILL.md');
+ await fs.mkdir(path.dirname(full), { recursive: true });
+ await fs.writeFile(full, block);
+
+ const inj1 = {
+ path: '.claude/skills/x/SKILL.md',
+ sha256: sha256OfBuffer(block),
+ sourceComponent: 'skills/x',
+ mode: 'additive' as const,
+ };
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: null, cut: null, accessories: [] },
+ files: [],
+ injected: [
+ { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1] },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(code).toBe(0);
+ expect(await fileExists(full)).toBe(false);
+ expect(await fileExists(path.join(proj, '.claude/skills/x'))).toBe(false);
+ });
+});
+
+describe('runOff --keep-injected', () => {
+ it('removes up files, keeps injected files, and rewrites the lockfile', async () => {
+ const proj = await mkProject();
+ const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n');
+ const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a\n', 'skills/a');
+ const inj2 = await plant(proj, '.claude/skills/b/SKILL.md', 'b\n', 'skills/b');
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: 'backend', fit: 'go', cut: 'executing', accessories: ['pr-policy'] },
+ files: [up1],
+ injected: [
+ { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1, inj2] },
+ ],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false, keepInjected: true },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(cap.err.join('')).toBe('');
+ expect(code).toBe(0);
+
+ // Up file gone.
+ expect(await fileExists(path.join(proj, up1.path))).toBe(false);
+ // Injected files survive.
+ expect(await fileExists(path.join(proj, inj1.path))).toBe(true);
+ expect(await fileExists(path.join(proj, inj2.path))).toBe(true);
+
+ // Lockfile retained, rewritten: files cleared, resolution reset, injected kept.
+ const after = await readLockfile(proj);
+ expect(after).not.toBeNull();
+ expect(after!.files).toEqual([]);
+ expect(after!.resolution.outfit).toBeNull();
+ expect(after!.resolution.cut).toBeNull();
+ expect(after!.resolution.accessories).toEqual([]);
+ expect(after!.injected).toHaveLength(1);
+ expect(after!.injected![0].files).toHaveLength(2);
+
+ const out = cap.out.join('');
+ expect(out).toMatch(/Kept 2 injected file\(s\) \(1 component\(s\)\); lockfile retained/);
+ expect(out).not.toMatch(/Removed lockfile:/);
+ });
+
+ it('behaves like a full off (deletes lockfile) when there are no injected entries', async () => {
+ const proj = await mkProject();
+ const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n');
+ const lock: Lockfile = {
+ schemaVersion: 1,
+ appliedAt: new Date().toISOString(),
+ resolution: { outfit: 'backend', cut: null, accessories: [] },
+ files: [up1],
+ };
+ await writeLockfile(proj, lock);
+
+ const cap = capture();
+ const code = await runOff(
+ { projectDir: proj, force: false, keepInjected: true },
+ { stdout: cap.push, stderr: cap.pushE },
+ );
+ expect(code).toBe(0);
+ expect(await fileExists(path.join(proj, up1.path))).toBe(false);
+ expect(await readLockfile(proj)).toBeNull();
+ expect(cap.out.join('')).toMatch(/Removed lockfile:/);
+ });
+});