@sabaiway/agent-workflow-kit 3.7.0 → 3.8.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 +33 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +17 -2
- package/tools/doc-parity.mjs +7 -3
- package/tools/worktrees.mjs +116 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 3.8.0 — cleanup never deletes a node_modules it cannot prove ephemeral (AD-069)
|
|
8
|
+
|
|
9
|
+
Routine non-abandon `worktrees cleanup` used to delete an ignored user-built `node_modules`
|
|
10
|
+
silently: `node_modules` sat unconditionally in the provision-owned root lists, so real user data
|
|
11
|
+
was treated as removable provision footprint. The ownership call is now made LIVE, at cleanup
|
|
12
|
+
time, from what deleting the node would destroy — never from who created it, and never from the
|
|
13
|
+
handoff record:
|
|
14
|
+
|
|
15
|
+
- **The gate** classifies the worktree's `node_modules` with one no-follow lstat and (for a
|
|
16
|
+
symlink) one buffer-form readlink: EPHEMERAL exactly when the raw target bytes equal MAIN's
|
|
17
|
+
`node_modules` path — a relative or re-encoded target that merely resolves to main stays
|
|
18
|
+
foreign, and the target's kind is irrelevant. Everything else — directory, file, special node —
|
|
19
|
+
is FOREIGN. The lane is tracked-first: a tracked path or tracked descendant wins over any
|
|
20
|
+
ignore rule, and an absent node with a live index entry is never clean-absent.
|
|
21
|
+
- **The one exemption** is the ignored-lane matching symlink (exactly what provision creates):
|
|
22
|
+
the ignored inventory skips it and plain `git worktree remove` unlinks it — after the gate's
|
|
23
|
+
verdict is RE-PROVEN, same class same lane, immediately before the irreversible remove. Any
|
|
24
|
+
change in between, or any probe error at any point, is a fail-closed STOP with no remove call.
|
|
25
|
+
- **Every other state stops surgically**, and the recovery matches the lane and kind: ignored/
|
|
26
|
+
untracked symlink, file, or special node → the exact single-node `rm`; directory → the
|
|
27
|
+
recursive form; then re-run cleanup (`--abandon` always named second). A TRACKED
|
|
28
|
+
`node_modules` never gets an `rm` — land its removal from MAIN instead. Probe errors offer no
|
|
29
|
+
removal command at all. A clean-absent verdict follows the legacy path unchanged, so landing a
|
|
30
|
+
tracked `node_modules` removal still converges. `--abandon` behavior is unchanged.
|
|
31
|
+
- The contract sentence ships as the exported `CLEANUP_OWNERSHIP_RULE`, emitted on every
|
|
32
|
+
ownership STOP and pinned into `references/modes/worktrees.md` by a new doc-parity binding.
|
|
33
|
+
|
|
34
|
+
Honest residuals, stated in the mode doc: the revalidation→remove window stays open (git performs
|
|
35
|
+
the removal; no door there, deliberately); content that git cannot see — or that appears after
|
|
36
|
+
the inventories inside a reset-restored tracked directory — remains a pre-existing generic
|
|
37
|
+
worktree-removal residual, no longer masked by a false ownership claim; on Windows a strict-bytes
|
|
38
|
+
mismatch degrades to the surgical STOP, never a deletion.
|
|
39
|
+
|
|
7
40
|
## 3.7.0 — the worktrees-dir advisor item can finally converge (AD-068)
|
|
8
41
|
|
|
9
42
|
The `recommendations` advisor's `worktrees-dir` item used to fire forever: its only convergence
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '3.
|
|
6
|
+
version: '3.8.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -54,8 +54,23 @@ own verbatim error through the existing Git-error surface.
|
|
|
54
54
|
- `cleanup <slug> [--branch <name>] [--abandon]` — take the same transient lock and remove a LANDED
|
|
55
55
|
worktree fail-closed after live landed-verification against main HEAD. Verification uses exactly
|
|
56
56
|
the land exclusions (`docs/ai`, `docs/plans`), then checks untracked and ignored content before a
|
|
57
|
-
plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral
|
|
58
|
-
`node_modules
|
|
57
|
+
plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral —
|
|
58
|
+
except `node_modules`, which is never assumed provision-owned:
|
|
59
|
+
node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed.
|
|
60
|
+
The verdict is
|
|
61
|
+
computed once, after landed verification and before the untracked/ignored inventories (it also
|
|
62
|
+
catches tracked `node_modules` and empty untracked directories git never lists); the lane is
|
|
63
|
+
tracked-first (a tracked path or tracked descendant wins over any ignore rule; an absent node with
|
|
64
|
+
a live index entry is never clean-absent); the single exempt state is re-proven — same class, same
|
|
65
|
+
lane — immediately before `git worktree remove`, and any deviation or probe error is a fail-closed
|
|
66
|
+
STOP with no removal performed. STOP recovery is surgical and lane-specific: untracked/ignored
|
|
67
|
+
symlink, file, or special node → a single-node `rm`; directory → the recursive form; then re-run
|
|
68
|
+
cleanup — `--abandon` is always named second; a tracked `node_modules` never gets an `rm` (land the
|
|
69
|
+
removal from MAIN instead); a probe error gets no removal command at all. A clean-absent verdict
|
|
70
|
+
follows the legacy cleanup path and carries no post-reset ownership proof: git-invisible or ignored
|
|
71
|
+
content appearing after the inventories, or surviving inside a reset-restored tracked directory,
|
|
72
|
+
remains a pre-existing generic worktree-removal residual (not `node_modules`-specific). On Windows
|
|
73
|
+
a strict-bytes mismatch degrades to the surgical STOP — fail-closed friction, never deletion.
|
|
59
74
|
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
60
75
|
requires the handoff identity, and is the only path where `--force` may appear.
|
|
61
76
|
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from './recommendations.mjs';
|
|
35
35
|
import { SKIPPED_READONLY } from './setup-backends.mjs';
|
|
36
36
|
import { LATENT_ARM_NOTICE } from './review-state.mjs';
|
|
37
|
-
import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE } from './worktrees.mjs';
|
|
37
|
+
import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE } from './worktrees.mjs';
|
|
38
38
|
|
|
39
39
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
40
|
|
|
@@ -105,6 +105,9 @@ export const BINDINGS = Object.freeze([
|
|
|
105
105
|
valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
|
|
106
106
|
valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
|
|
107
107
|
valueBinding('no-dependencies-posture', NO_DEPENDENCIES_POSTURE, NO_DEPENDENCIES_POSTURE, [WORKTREES_DOC]),
|
|
108
|
+
// The cleanup-ownership contract (AD-069): the exact live sentence every ownership STOP emits —
|
|
109
|
+
// a reworded mode doc dropping the class × lane contract fails this pin plus the gate.
|
|
110
|
+
valueBinding('cleanup-ownership-rule', CLEANUP_OWNERSHIP_RULE, CLEANUP_OWNERSHIP_RULE, [WORKTREES_DOC]),
|
|
108
111
|
].map((b) => Object.freeze(b)));
|
|
109
112
|
|
|
110
113
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -151,8 +154,9 @@ Usage:
|
|
|
151
154
|
A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
|
|
152
155
|
table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
|
|
153
156
|
contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
|
|
154
|
-
degrade token, the review-state clean-tree latent-arm notice,
|
|
155
|
-
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture)
|
|
157
|
+
degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
|
|
158
|
+
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), and
|
|
159
|
+
the worktrees cleanup-ownership rule — to
|
|
156
160
|
the exact token its references/modes/*.md contract must carry, and
|
|
157
161
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
158
162
|
or an absent token FAILS CLOSED.
|
package/tools/worktrees.mjs
CHANGED
|
@@ -937,6 +937,10 @@ export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declar
|
|
|
937
937
|
// The recorded node_modules mode for that same verdict: provision neither advised nor created a
|
|
938
938
|
// node_modules for this worktree.
|
|
939
939
|
export const NODE_MODULES_NONE = 'no-dependencies';
|
|
940
|
+
// The cleanup-ownership contract (AD-069): ownership is information content, decided live at
|
|
941
|
+
// cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
|
|
942
|
+
// into the worktrees mode doc; every ownership STOP emits it.
|
|
943
|
+
export const CLEANUP_OWNERSHIP_RULE = "node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed";
|
|
940
944
|
|
|
941
945
|
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
942
946
|
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
@@ -2266,7 +2270,6 @@ const provisionKnownRoots = (identity) => {
|
|
|
2266
2270
|
...registryRoots(),
|
|
2267
2271
|
...identity.record.includes.map(safeRecordedPath),
|
|
2268
2272
|
PLANS_REL,
|
|
2269
|
-
'node_modules',
|
|
2270
2273
|
];
|
|
2271
2274
|
if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');
|
|
2272
2275
|
return [...new Set(roots)];
|
|
@@ -2277,7 +2280,6 @@ const provisionKnownDirectoryRoots = ({ root, identity, fs }) => {
|
|
|
2277
2280
|
...KIT_OWN_PATHS.filter(isDirPattern),
|
|
2278
2281
|
...KNOWN_FOOTPRINT.filter((entry) => entry.type === 'dir').map((entry) => entry.pattern),
|
|
2279
2282
|
PLANS_REL,
|
|
2280
|
-
'node_modules',
|
|
2281
2283
|
].map((path) => normalizeSlashes(path).replace(/^\//, '').replace(/\/$/, ''));
|
|
2282
2284
|
for (const include of identity.record.includes.map(safeRecordedPath)) {
|
|
2283
2285
|
const kind = classifyNodeNoFollow(join(root, include), fs).kind;
|
|
@@ -2294,7 +2296,7 @@ const rootCoveringPath = (path, roots) => roots.find((root) => {
|
|
|
2294
2296
|
return path === root || path.startsWith(`${root}/`);
|
|
2295
2297
|
}) ?? null;
|
|
2296
2298
|
|
|
2297
|
-
const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
2299
|
+
const foreignIgnoredPaths = ({ root, git, identity, fs, exemptNodeModules = false }) => {
|
|
2298
2300
|
const result = gitRead(
|
|
2299
2301
|
git, ['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=all'], root,
|
|
2300
2302
|
'git status --ignored failed',
|
|
@@ -2305,6 +2307,9 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
|
2305
2307
|
for (const rawPath of entry.paths) {
|
|
2306
2308
|
const normalized = normalizeSlashes(rawPath);
|
|
2307
2309
|
const path = normalized.replace(/\/$/, '');
|
|
2310
|
+
// The ONE ownership exemption (AD-069): the gate has already proven this exact node an
|
|
2311
|
+
// ignored-lane matching symlink; it is neither re-probed nor reported here.
|
|
2312
|
+
if (exemptNodeModules && path === NODE_MODULES_REL) continue;
|
|
2308
2313
|
const kind = classifyNodeNoFollow(join(root, path), fs).kind;
|
|
2309
2314
|
const rootType = kind === 'plain-directory' || kind === 'symlink-to-directory'
|
|
2310
2315
|
? 'directory'
|
|
@@ -2327,6 +2332,88 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
|
2327
2332
|
}).map(([path]) => path);
|
|
2328
2333
|
};
|
|
2329
2334
|
|
|
2335
|
+
// ── the node_modules ownership gate (AD-069) ──────────────────────────────────────────────
|
|
2336
|
+
// Class {absent, ephemeral, foreign} × lane {tracked, ignored, untracked}, decided live from
|
|
2337
|
+
// exactly one no-follow lstat + (symlink only) one buffer-form readlink + the git lane probes.
|
|
2338
|
+
// The record is never consulted; every probe error is FOREIGN fail-closed with no recovery
|
|
2339
|
+
// command. The single exempt state — the ignored-lane matching link — is re-proven immediately
|
|
2340
|
+
// before the irreversible worktree remove.
|
|
2341
|
+
|
|
2342
|
+
const NODE_MODULES_REL = 'node_modules';
|
|
2343
|
+
|
|
2344
|
+
const nodeModulesInspectStop = (nmPath, detail) => stop(
|
|
2345
|
+
`cleanup stopped: cannot inspect ${nmPath} (${detail})\n${CLEANUP_OWNERSHIP_RULE}`,
|
|
2346
|
+
);
|
|
2347
|
+
|
|
2348
|
+
const nodeModulesSnapshot = ({ wtRoot, mainRoot, git, fs }) => {
|
|
2349
|
+
const nmPath = join(wtRoot, NODE_MODULES_REL);
|
|
2350
|
+
const node = (() => {
|
|
2351
|
+
try {
|
|
2352
|
+
return { stat: fs.lstat(nmPath) };
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
return error?.code === 'ENOENT' ? { stat: null } : { error: error?.code ?? 'fs error' };
|
|
2355
|
+
}
|
|
2356
|
+
})();
|
|
2357
|
+
if (node.error) throw nodeModulesInspectStop(nmPath, node.error);
|
|
2358
|
+
const trackedProbe = git(['ls-files', '--cached', '-z', '--', NODE_MODULES_REL], wtRoot);
|
|
2359
|
+
if (trackedProbe.status !== 0) {
|
|
2360
|
+
throw nodeModulesInspectStop(nmPath, `git ls-files failed: ${(trackedProbe.stderr || trackedProbe.stdout).trim()}`);
|
|
2361
|
+
}
|
|
2362
|
+
const tracked = trackedProbe.stdout.length > 0;
|
|
2363
|
+
if (node.stat === null) {
|
|
2364
|
+
return { klass: 'absent', kind: 'absent', lane: tracked ? 'tracked' : null, nmPath };
|
|
2365
|
+
}
|
|
2366
|
+
const lane = tracked ? 'tracked' : (() => {
|
|
2367
|
+
const probe = git(['check-ignore', '--', NODE_MODULES_REL], wtRoot);
|
|
2368
|
+
if (probe.status === 0) return 'ignored';
|
|
2369
|
+
if (probe.status === 1) return 'untracked';
|
|
2370
|
+
throw nodeModulesInspectStop(nmPath, `git check-ignore failed: ${(probe.stderr || probe.stdout).trim()}`);
|
|
2371
|
+
})();
|
|
2372
|
+
if (!node.stat.isSymbolicLink()) {
|
|
2373
|
+
const kind = node.stat.isDirectory() ? 'directory' : node.stat.isFile() ? 'file' : 'special';
|
|
2374
|
+
return { klass: 'foreign', kind, lane, nmPath };
|
|
2375
|
+
}
|
|
2376
|
+
const target = (() => {
|
|
2377
|
+
try {
|
|
2378
|
+
return { bytes: fs.readlink(nmPath, { encoding: 'buffer' }) };
|
|
2379
|
+
} catch (error) {
|
|
2380
|
+
return { error: error?.code ?? 'fs error' };
|
|
2381
|
+
}
|
|
2382
|
+
})();
|
|
2383
|
+
if (target.error) throw nodeModulesInspectStop(nmPath, target.error);
|
|
2384
|
+
// Raw BYTES against MAIN's node_modules path — never decoded, never resolved: a relative or
|
|
2385
|
+
// re-encoded form that merely RESOLVES to main stays foreign, and the target's kind is
|
|
2386
|
+
// irrelevant (unlink touches only the link).
|
|
2387
|
+
const matches = Buffer.compare(target.bytes, Buffer.from(join(mainRoot, NODE_MODULES_REL))) === 0;
|
|
2388
|
+
return { klass: matches ? 'ephemeral' : 'foreign', kind: 'symlink', lane, nmPath };
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
const nodeModulesExemptVerdict = (snapshot) => snapshot.klass === 'ephemeral' && snapshot.lane === 'ignored';
|
|
2392
|
+
|
|
2393
|
+
const composeNodeModulesStop = ({ snapshot, slug, branch, changed = false }) => {
|
|
2394
|
+
const { klass, kind, lane, nmPath } = snapshot;
|
|
2395
|
+
const kindDesc = kind === 'symlink'
|
|
2396
|
+
? (klass === 'ephemeral' ? 'a main-matching symlink' : 'a foreign symlink')
|
|
2397
|
+
: kind === 'directory' ? 'a directory'
|
|
2398
|
+
: kind === 'file' ? 'a regular file'
|
|
2399
|
+
: kind === 'special' ? 'a special node' : 'absent';
|
|
2400
|
+
const head = `cleanup stopped: ${nmPath} is ${kindDesc} in the ${lane} lane`
|
|
2401
|
+
+ `${changed ? ' (changed during cleanup)' : ''}\n${CLEANUP_OWNERSHIP_RULE}`;
|
|
2402
|
+
if (lane === 'tracked') {
|
|
2403
|
+
return stop([
|
|
2404
|
+
head,
|
|
2405
|
+
'this node_modules is tracked: removing it by hand only creates drift that stops cleanup earlier — land its removal from MAIN, then re-run cleanup',
|
|
2406
|
+
destructiveRecovery({ slug, branch }),
|
|
2407
|
+
].join('\n'));
|
|
2408
|
+
}
|
|
2409
|
+
const removal = kind === 'directory' ? `rm -rf -- ${shellQuoteArg(nmPath)}` : `rm -- ${shellQuoteArg(nmPath)}`;
|
|
2410
|
+
return stop([
|
|
2411
|
+
head,
|
|
2412
|
+
`remove it yourself, then re-run cleanup: ${removal}`,
|
|
2413
|
+
destructiveRecovery({ slug, branch }),
|
|
2414
|
+
].join('\n'));
|
|
2415
|
+
};
|
|
2416
|
+
|
|
2330
2417
|
const composeCleanupCommand = ({ slug, branch, abandon }) =>
|
|
2331
2418
|
`cleanup ${shellQuoteArg(slug)}` +
|
|
2332
2419
|
`${branch === `${DEFAULT_BRANCH_PREFIX}${slug}` ? '' : ` --branch ${shellQuoteArg(branch)}`}` +
|
|
@@ -2380,6 +2467,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2380
2467
|
|
|
2381
2468
|
const knownRoots = provisionKnownRoots(identity);
|
|
2382
2469
|
let removalRoots = [];
|
|
2470
|
+
let nodeModulesExempt = false;
|
|
2383
2471
|
if (!flags.abandon) {
|
|
2384
2472
|
const mainHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve main HEAD').stdout.trim();
|
|
2385
2473
|
// Transfer verification excludes docs/ai and docs/plans; their tracked drift is checked separately before reset.
|
|
@@ -2422,6 +2510,16 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2422
2510
|
);
|
|
2423
2511
|
}
|
|
2424
2512
|
|
|
2513
|
+
// The ownership gate (AD-069) runs BEFORE the inventories: it also catches tracked
|
|
2514
|
+
// node_modules and empty untracked directories git never lists. Clean-absent proceeds on
|
|
2515
|
+
// the legacy path with no post-reset ownership arm; the single exemption is re-proven
|
|
2516
|
+
// below, immediately before the irreversible remove.
|
|
2517
|
+
const ownership = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
|
|
2518
|
+
if (!nodeModulesExemptVerdict(ownership) && !(ownership.klass === 'absent' && ownership.lane !== 'tracked')) {
|
|
2519
|
+
throw composeNodeModulesStop({ snapshot: ownership, slug, branch });
|
|
2520
|
+
}
|
|
2521
|
+
nodeModulesExempt = nodeModulesExemptVerdict(ownership);
|
|
2522
|
+
|
|
2425
2523
|
const untracked = untrackedPaths(git, entry.path);
|
|
2426
2524
|
const foreign = [];
|
|
2427
2525
|
for (const path of untracked) {
|
|
@@ -2435,7 +2533,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2435
2533
|
destructiveRecovery({ slug, branch }),
|
|
2436
2534
|
);
|
|
2437
2535
|
}
|
|
2438
|
-
const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs });
|
|
2536
|
+
const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs, exemptNodeModules: nodeModulesExempt });
|
|
2439
2537
|
if (foreignIgnored.length > 0) {
|
|
2440
2538
|
throw stop(`foreign ignored content requires --abandon:\n${foreignIgnored.join('\n')}`);
|
|
2441
2539
|
}
|
|
@@ -2455,6 +2553,20 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2455
2553
|
for (const rel of removalRoots) {
|
|
2456
2554
|
removeNodeNoFollow({ root: entry.path, abs: join(entry.path, rel), fs, label: rel });
|
|
2457
2555
|
}
|
|
2556
|
+
if (nodeModulesExempt) {
|
|
2557
|
+
// The authoritative re-proof (AD-069): the verdict authorizing the irreversible remove
|
|
2558
|
+
// postdates the last tree-mutating operation. Strict equality with the exempt state —
|
|
2559
|
+
// never re-authorization: ANY deviation fails closed with no remove call.
|
|
2560
|
+
const revalidated = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
|
|
2561
|
+
if (!nodeModulesExemptVerdict(revalidated)) {
|
|
2562
|
+
if (revalidated.klass === 'absent' && revalidated.lane !== 'tracked') {
|
|
2563
|
+
throw stop(
|
|
2564
|
+
`cleanup stopped: ${revalidated.nmPath} changed during cleanup (now absent) — re-run cleanup\n${CLEANUP_OWNERSHIP_RULE}`,
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
throw composeNodeModulesStop({ snapshot: revalidated, slug, branch, changed: true });
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2458
2570
|
}
|
|
2459
2571
|
|
|
2460
2572
|
removeWorktree({ root, entry, branch, abandon: flags.abandon, git });
|