@sabaiway/agent-workflow-kit 3.7.0 → 3.9.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 +60 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +36 -2
- package/tools/doc-parity.mjs +11 -3
- package/tools/worktrees.mjs +339 -31
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,66 @@ 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.9.0 — the --include copy door proves what it copies (AD-070)
|
|
8
|
+
|
|
9
|
+
Nothing bound the node the provision copy walk read to the node `--include` preflight approved:
|
|
10
|
+
a source swapped after preflight was copied as approved, and the path-based queue guard could not
|
|
11
|
+
see a node that had become the shared series index by identity (a hardlink, a swap) rather than
|
|
12
|
+
by path. The gap is closed at the existing descriptor door:
|
|
13
|
+
|
|
14
|
+
- **Preflight identity, captured before any git mutation.** The `--include` loop records each
|
|
15
|
+
root's {device, inode, kind} from the canonical node. A root that is neither a regular file
|
|
16
|
+
nor a directory — or an erroring identity probe — refuses BEFORE `git worktree add`: no
|
|
17
|
+
worktree is left behind.
|
|
18
|
+
- **Three tiers at the walk.** A file root is verified at the door — the open descriptor must
|
|
19
|
+
match the preflight identity, and a node that is no longer a plain regular file stops before
|
|
20
|
+
any destination exists. A directory root gets a walk-start recheck (a recheck, not a binding).
|
|
21
|
+
Child files keep the existing same-call lstat↔open identity; the child walk stays path-based
|
|
22
|
+
(a stated residual).
|
|
23
|
+
- **The door-time queue refusal, every copied include file.** With the source descriptor open,
|
|
24
|
+
the door opens the lexical queue path (following links, non-blocking — a FIFO-shaped queue
|
|
25
|
+
classifies non-regular and stops), fstats the open descriptor, and compares identities with
|
|
26
|
+
both descriptors open: never copy the node that IS the door-time queue. Queue identity is
|
|
27
|
+
established at descriptor-open time and never cached across crossings — absence is not cached
|
|
28
|
+
either. A truly absent queue keeps the lexical guard alone; a dangling, unreadable, or
|
|
29
|
+
non-regular queue is unprovable and stops the copy. The queue descriptor closes exactly once;
|
|
30
|
+
a close error surfaces without destination residue.
|
|
31
|
+
- **The contract ships as an exported constant** (`INCLUDE_IDENTITY_RULE`), emitted on every
|
|
32
|
+
include-identity STOP and doc-parity-pinned into the worktrees mode doc.
|
|
33
|
+
|
|
34
|
+
## 3.8.0 — cleanup never deletes a node_modules it cannot prove ephemeral (AD-069)
|
|
35
|
+
|
|
36
|
+
Routine non-abandon `worktrees cleanup` used to delete an ignored user-built `node_modules`
|
|
37
|
+
silently: `node_modules` sat unconditionally in the provision-owned root lists, so real user data
|
|
38
|
+
was treated as removable provision footprint. The ownership call is now made LIVE, at cleanup
|
|
39
|
+
time, from what deleting the node would destroy — never from who created it, and never from the
|
|
40
|
+
handoff record:
|
|
41
|
+
|
|
42
|
+
- **The gate** classifies the worktree's `node_modules` with one no-follow lstat and (for a
|
|
43
|
+
symlink) one buffer-form readlink: EPHEMERAL exactly when the raw target bytes equal MAIN's
|
|
44
|
+
`node_modules` path — a relative or re-encoded target that merely resolves to main stays
|
|
45
|
+
foreign, and the target's kind is irrelevant. Everything else — directory, file, special node —
|
|
46
|
+
is FOREIGN. The lane is tracked-first: a tracked path or tracked descendant wins over any
|
|
47
|
+
ignore rule, and an absent node with a live index entry is never clean-absent.
|
|
48
|
+
- **The one exemption** is the ignored-lane matching symlink (exactly what provision creates):
|
|
49
|
+
the ignored inventory skips it and plain `git worktree remove` unlinks it — after the gate's
|
|
50
|
+
verdict is RE-PROVEN, same class same lane, immediately before the irreversible remove. Any
|
|
51
|
+
change in between, or any probe error at any point, is a fail-closed STOP with no remove call.
|
|
52
|
+
- **Every other state stops surgically**, and the recovery matches the lane and kind: ignored/
|
|
53
|
+
untracked symlink, file, or special node → the exact single-node `rm`; directory → the
|
|
54
|
+
recursive form; then re-run cleanup (`--abandon` always named second). A TRACKED
|
|
55
|
+
`node_modules` never gets an `rm` — land its removal from MAIN instead. Probe errors offer no
|
|
56
|
+
removal command at all. A clean-absent verdict follows the legacy path unchanged, so landing a
|
|
57
|
+
tracked `node_modules` removal still converges. `--abandon` behavior is unchanged.
|
|
58
|
+
- The contract sentence ships as the exported `CLEANUP_OWNERSHIP_RULE`, emitted on every
|
|
59
|
+
ownership STOP and pinned into `references/modes/worktrees.md` by a new doc-parity binding.
|
|
60
|
+
|
|
61
|
+
Honest residuals, stated in the mode doc: the revalidation→remove window stays open (git performs
|
|
62
|
+
the removal; no door there, deliberately); content that git cannot see — or that appears after
|
|
63
|
+
the inventories inside a reset-restored tracked directory — remains a pre-existing generic
|
|
64
|
+
worktree-removal residual, no longer masked by a false ownership claim; on Windows a strict-bytes
|
|
65
|
+
mismatch degrades to the surgical STOP, never a deletion.
|
|
66
|
+
|
|
7
67
|
## 3.7.0 — the worktrees-dir advisor item can finally converge (AD-068)
|
|
8
68
|
|
|
9
69
|
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.9.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.9.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",
|
|
@@ -33,6 +33,25 @@ own verbatim error through the existing Git-error surface.
|
|
|
33
33
|
is read from exactly one REQUIRED `## Provision record` section (a decoy field elsewhere cannot
|
|
34
34
|
hijack identity); a missing or repeated section, or a duplicated single-valued field, is a typed
|
|
35
35
|
STOP, never last-wins.
|
|
36
|
+
`--include` sources are identity-bound: preflight records each include root's identity (device,
|
|
37
|
+
inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
|
|
38
|
+
regular file nor a directory — or whose identity probe fails — is refused before any mutation.
|
|
39
|
+
An include that overlaps a path provision itself populates (the frozen registry footprint, the
|
|
40
|
+
seeded plan, the handoff) or another include root is refused pre-mutation too — an overlapped
|
|
41
|
+
destination would meet the copy-if-missing kept-exit and skip the identity door. On a FRESH
|
|
42
|
+
(non-resume) provision an include destination that already exists at walk time is a fail-closed
|
|
43
|
+
STOP (nothing legitimate pre-populates it — filesystem aliasing the overlap compare missed is
|
|
44
|
+
caught at the door). On `--resume`
|
|
45
|
+
an include destination already present from the prior run is KEPT (copy-if-missing by design):
|
|
46
|
+
the door proves what THIS run copies, never re-proves prior content.
|
|
47
|
+
An --include source is copied only through the identity door: a file include must still match the identity preflight recorded (device, inode, kind), a directory include root is re-checked at walk start, and every copied file is proven, with both descriptors open, not to be the node that IS the door-time queue — an absent queue keeps the lexical guard alone, and anything unprovable stops the copy.
|
|
48
|
+
The queue identity is read at descriptor-open time (following links, non-blocking — a
|
|
49
|
+
FIFO-shaped queue classifies non-regular and stops) and is never cached across door crossings;
|
|
50
|
+
a dangling, unreadable, or non-regular queue stops the copy. Honest residuals: the child
|
|
51
|
+
path-walk under a directory include stays path-based, the walk-start root recheck leaves a
|
|
52
|
+
recheck→walk window, and the identity/queue compares are point-in-time inode proofs, not
|
|
53
|
+
pathname bindings — a node recreated at the same device and inode within the window passes,
|
|
54
|
+
inherent to an inode proof — a self-discipline door, not a security boundary.
|
|
36
55
|
- `list` — read-only: every worktree of this repo with slug (from the handoff file; none →
|
|
37
56
|
"unknown (foreign)"), path, branch, base OID (the worktree HEAD — under the v1 no-commit bar
|
|
38
57
|
that IS the provision base; a manual satellite commit moves it, and land derives its own base
|
|
@@ -54,8 +73,23 @@ own verbatim error through the existing Git-error surface.
|
|
|
54
73
|
- `cleanup <slug> [--branch <name>] [--abandon]` — take the same transient lock and remove a LANDED
|
|
55
74
|
worktree fail-closed after live landed-verification against main HEAD. Verification uses exactly
|
|
56
75
|
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
|
|
76
|
+
plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral —
|
|
77
|
+
except `node_modules`, which is never assumed provision-owned:
|
|
78
|
+
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.
|
|
79
|
+
The verdict is
|
|
80
|
+
computed once, after landed verification and before the untracked/ignored inventories (it also
|
|
81
|
+
catches tracked `node_modules` and empty untracked directories git never lists); the lane is
|
|
82
|
+
tracked-first (a tracked path or tracked descendant wins over any ignore rule; an absent node with
|
|
83
|
+
a live index entry is never clean-absent); the single exempt state is re-proven — same class, same
|
|
84
|
+
lane — immediately before `git worktree remove`, and any deviation or probe error is a fail-closed
|
|
85
|
+
STOP with no removal performed. STOP recovery is surgical and lane-specific: untracked/ignored
|
|
86
|
+
symlink, file, or special node → a single-node `rm`; directory → the recursive form; then re-run
|
|
87
|
+
cleanup — `--abandon` is always named second; a tracked `node_modules` never gets an `rm` (land the
|
|
88
|
+
removal from MAIN instead); a probe error gets no removal command at all. A clean-absent verdict
|
|
89
|
+
follows the legacy cleanup path and carries no post-reset ownership proof: git-invisible or ignored
|
|
90
|
+
content appearing after the inventories, or surviving inside a reset-restored tracked directory,
|
|
91
|
+
remains a pre-existing generic worktree-removal residual (not `node_modules`-specific). On Windows
|
|
92
|
+
a strict-bytes mismatch degrades to the surgical STOP — fail-closed friction, never deletion.
|
|
59
93
|
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
60
94
|
requires the handoff identity, and is the only path where `--force` may appear.
|
|
61
95
|
|
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, INCLUDE_IDENTITY_RULE } from './worktrees.mjs';
|
|
38
38
|
|
|
39
39
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
40
|
|
|
@@ -105,6 +105,13 @@ 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]),
|
|
111
|
+
// The include-identity contract (F3): the exact live sentence every include-identity STOP emits —
|
|
112
|
+
// a reworded mode doc dropping the preflight-binding × door-time-queue contract fails this pin
|
|
113
|
+
// plus the gate.
|
|
114
|
+
valueBinding('include-identity-rule', INCLUDE_IDENTITY_RULE, INCLUDE_IDENTITY_RULE, [WORKTREES_DOC]),
|
|
108
115
|
].map((b) => Object.freeze(b)));
|
|
109
116
|
|
|
110
117
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -151,8 +158,9 @@ Usage:
|
|
|
151
158
|
A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
|
|
152
159
|
table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
|
|
153
160
|
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)
|
|
161
|
+
degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
|
|
162
|
+
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
|
|
163
|
+
worktrees cleanup-ownership rule, and the worktrees include-identity rule — to
|
|
156
164
|
the exact token its references/modes/*.md contract must carry, and
|
|
157
165
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
158
166
|
or an absent token FAILS CLOSED.
|
package/tools/worktrees.mjs
CHANGED
|
@@ -200,23 +200,126 @@ const readFileNoFollow = (fs, abs) => {
|
|
|
200
200
|
|
|
201
201
|
const NOFOLLOW_WRITE = fsC.O_WRONLY | fsC.O_CREAT | fsC.O_EXCL | (fsC.O_NOFOLLOW ?? 0);
|
|
202
202
|
const COPY_BUFFER_BYTES = 64 * 1024;
|
|
203
|
-
|
|
203
|
+
|
|
204
|
+
// The include-identity door (F3). `door` rides ONLY the --include copy lane: { queuePath } on
|
|
205
|
+
// every crossing, plus { identity } for a FILE include root (a directory root is re-checked at
|
|
206
|
+
// walk start instead; its children keep the same-call lstat↔open identity — path-based walk is
|
|
207
|
+
// a stated residual). Every STOP here emits INCLUDE_IDENTITY_RULE.
|
|
208
|
+
const includeIdentityStop = (rel, cause) => stop(`--include: ${cause}: ${rel}\n${INCLUDE_IDENTITY_RULE}`);
|
|
209
|
+
|
|
210
|
+
// The fresh-preexist STOPs carry their own surgical recovery so the generic worktree-kept NOTE
|
|
211
|
+
// never steers the operator into a blind --resume over the very node the door refused.
|
|
212
|
+
const INCLUDE_PREEXIST_CAUSE = 'the include destination already exists at walk time — inspect the unexpected destination and remove it (single node: rm; directory: rm -rf), then finish with --resume';
|
|
213
|
+
|
|
214
|
+
// Equal / ancestor / descendant, canonized PER PLATFORM: on a backslash-separator platform
|
|
215
|
+
// `relative()` returns backslashes and path comparison is case-insensitive, so separators
|
|
216
|
+
// normalize and case folds (fail-closed for a refusal guard — more refusals, never fewer); on
|
|
217
|
+
// POSIX the compare stays LITERAL — a backslash is a valid filename character and case is
|
|
218
|
+
// significant, so normalizing would conflate distinct names into false refusals. The separator
|
|
219
|
+
// is injectable so both platforms' semantics are test-pinned from one host.
|
|
220
|
+
export const includeRelsOverlap = (a, b, { separator = sep } = {}) => {
|
|
221
|
+
const canon = (p) => (separator === '\\' ? normalizeSlashes(p).toLowerCase() : p);
|
|
222
|
+
const left = canon(a);
|
|
223
|
+
const right = canon(b);
|
|
224
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Follows links deliberately (the door must land on the THEN-CURRENT canonical queue node);
|
|
228
|
+
// O_NONBLOCK keeps a FIFO-shaped queue from blocking the door — its fstat classifies it.
|
|
229
|
+
const QUEUE_DOOR_READ = fsC.O_RDONLY | (fsC.O_NONBLOCK ?? 0);
|
|
230
|
+
|
|
231
|
+
// Runs while the SOURCE descriptor is open: open the LEXICAL queue path, fstat the OPEN
|
|
232
|
+
// descriptor, compare identities with both descriptors open. lstat-ENOENT (truly absent) keeps
|
|
233
|
+
// the lexical guard alone; a dangling link, unreadable, non-regular, or erroring queue is
|
|
234
|
+
// unprovable → fail-closed STOP. Queue identity is never cached across crossings.
|
|
235
|
+
const assertSourceIsNotDoorTimeQueue = ({ sourceStat, queuePath, rel, fs }) => {
|
|
236
|
+
// Absence is proven at the OPEN, never at the lstat alone — a queue born between the two
|
|
237
|
+
// still meets the descriptor compare. lstat's job is to tell truly-absent (both ENOENT)
|
|
238
|
+
// from a dangling link (lstat succeeds, open ENOENT), which stays a fail-closed STOP.
|
|
239
|
+
const lstatAbsent = (() => {
|
|
240
|
+
try {
|
|
241
|
+
fs.lstat(queuePath);
|
|
242
|
+
return false;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err?.code === 'ENOENT') return true;
|
|
245
|
+
throw includeIdentityStop(rel, `cannot probe the shared series index (${err?.code ?? 'fs error'})`);
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
const handle = { fd: null };
|
|
249
|
+
const outcome = { error: null };
|
|
250
|
+
try {
|
|
251
|
+
try {
|
|
252
|
+
handle.fd = fs.open(queuePath, QUEUE_DOOR_READ);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err?.code === 'ENOENT' && lstatAbsent) return;
|
|
255
|
+
if (err?.code === 'ENOENT') throw includeIdentityStop(rel, 'the shared series index is a dangling link at copy time');
|
|
256
|
+
throw includeIdentityStop(rel, `cannot open the shared series index (${err?.code ?? 'fs error'})`);
|
|
257
|
+
}
|
|
258
|
+
const queueStat = (() => {
|
|
259
|
+
try {
|
|
260
|
+
return fs.fstat(handle.fd);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
throw includeIdentityStop(rel, `cannot inspect the shared series index (${err?.code ?? 'fs error'})`);
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
if (!queueStat.isFile()) throw includeIdentityStop(rel, 'the shared series index is not a regular file at copy time');
|
|
266
|
+
if (queueStat.dev === sourceStat.dev && queueStat.ino === sourceStat.ino) {
|
|
267
|
+
throw includeIdentityStop(rel, 'the source IS the door-time queue');
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
outcome.error = error;
|
|
271
|
+
}
|
|
272
|
+
if (handle.fd !== null) {
|
|
273
|
+
try {
|
|
274
|
+
fs.close(handle.fd);
|
|
275
|
+
} catch (closeError) {
|
|
276
|
+
if (!outcome.error) {
|
|
277
|
+
outcome.error = includeIdentityStop(rel, `cannot close the shared series index descriptor (${closeError?.code ?? 'fs error'})`);
|
|
278
|
+
} else {
|
|
279
|
+
outcome.error.message += ` (additionally: the shared series index descriptor failed to close: ${closeError?.code ?? 'fs error'})`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (outcome.error) throw outcome.error;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const copyFileNoFollow = ({ srcAbs, dstAbs, sourceStat, rel, fs, door = null, wtRoot }) => {
|
|
204
287
|
const handles = { source: null, destination: null };
|
|
205
288
|
const closeErrors = [];
|
|
206
289
|
const outcome = { error: null };
|
|
207
290
|
try {
|
|
291
|
+
const sourceWindowStop = () => (door
|
|
292
|
+
? includeIdentityStop(rel, 'the source changed between lstat and open')
|
|
293
|
+
: stop(`copy source changed between lstat and open: ${rel}`));
|
|
208
294
|
try {
|
|
209
295
|
handles.source = fs.open(srcAbs, NOFOLLOW_READ);
|
|
210
296
|
} catch (error) {
|
|
211
297
|
if (error?.code === 'ENOENT' || error?.code === 'ELOOP') {
|
|
212
|
-
throw
|
|
298
|
+
throw sourceWindowStop();
|
|
213
299
|
}
|
|
300
|
+
if (door) throw includeIdentityStop(rel, `cannot prove the source identity (${error?.code ?? 'fs error'})`);
|
|
214
301
|
throw error;
|
|
215
302
|
}
|
|
216
|
-
const descriptorStat =
|
|
303
|
+
const descriptorStat = (() => {
|
|
304
|
+
try {
|
|
305
|
+
return fs.fstat(handles.source);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (door) throw includeIdentityStop(rel, `cannot prove the source identity (${error?.code ?? 'fs error'})`);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
217
311
|
if (!descriptorStat.isFile() || descriptorStat.dev !== sourceStat.dev || descriptorStat.ino !== sourceStat.ino) {
|
|
218
|
-
throw
|
|
312
|
+
throw sourceWindowStop();
|
|
219
313
|
}
|
|
314
|
+
if (door?.identity && (descriptorStat.dev !== door.identity.dev || descriptorStat.ino !== door.identity.ino)) {
|
|
315
|
+
throw includeIdentityStop(rel, 'the source is not the node preflight approved');
|
|
316
|
+
}
|
|
317
|
+
if (door) assertSourceIsNotDoorTimeQueue({ sourceStat: descriptorStat, queuePath: door.queuePath, rel, fs });
|
|
318
|
+
// Destination preparation runs only AFTER every source-side proof — a refusal must leave no
|
|
319
|
+
// fresh parent-directory residue, in either lane.
|
|
320
|
+
guardDst(fs, wtRoot, dirname(dstAbs));
|
|
321
|
+
fs.mkdir(dirname(dstAbs));
|
|
322
|
+
guardDst(fs, wtRoot, dstAbs);
|
|
220
323
|
try {
|
|
221
324
|
// O_EXCL closes the create race; O_NOFOLLOW is defense-in-depth for nonstandard link handling.
|
|
222
325
|
handles.destination = fs.open(dstAbs, NOFOLLOW_WRITE, sourceStat.mode & 0o666);
|
|
@@ -246,14 +349,32 @@ const copyFileNoFollow = ({ srcAbs, dstAbs, sourceStat, rel, fs }) => {
|
|
|
246
349
|
try {
|
|
247
350
|
fs.close(handles[key]);
|
|
248
351
|
} catch (error) {
|
|
249
|
-
closeErrors.push(error);
|
|
352
|
+
closeErrors.push({ key, error });
|
|
250
353
|
}
|
|
251
354
|
}
|
|
252
355
|
const withDestinationState = (error) => Object.assign(error, {
|
|
253
356
|
copyDoorDestinationCreated: handles.destination !== null,
|
|
254
357
|
});
|
|
255
|
-
|
|
256
|
-
|
|
358
|
+
// Every close failure surfaces with its OWN descriptor name, in close order — a latched
|
|
359
|
+
// primary error carries them all appended; without one, the first failure leads and the rest
|
|
360
|
+
// still ride along. The suffix ALSO travels as a field because the copy walk re-wraps
|
|
361
|
+
// non-STOP primaries into its own message (which would otherwise drop the close names).
|
|
362
|
+
const closeSuffix = (failures) => failures
|
|
363
|
+
.map(({ key, error }) => ` (additionally: the ${key} descriptor failed to close: ${error?.code ?? 'fs error'})`)
|
|
364
|
+
.join('');
|
|
365
|
+
if (outcome.error) {
|
|
366
|
+
const suffix = closeSuffix(closeErrors);
|
|
367
|
+
outcome.error.message += suffix;
|
|
368
|
+
if (suffix) outcome.error.closeFailureSuffix = suffix;
|
|
369
|
+
throw withDestinationState(outcome.error);
|
|
370
|
+
}
|
|
371
|
+
if (closeErrors.length > 0) {
|
|
372
|
+
const [first, ...rest] = closeErrors;
|
|
373
|
+
const suffix = ` (the ${first.key} descriptor failed to close)${closeSuffix(rest)}`;
|
|
374
|
+
first.error.message = `${first.error.message ?? ''}${suffix}`;
|
|
375
|
+
first.error.closeFailureSuffix = suffix;
|
|
376
|
+
throw withDestinationState(first.error);
|
|
377
|
+
}
|
|
257
378
|
};
|
|
258
379
|
|
|
259
380
|
const isInside = (root, path) => {
|
|
@@ -664,7 +785,7 @@ const failAfterCopy = ({ cause, dstAbs, wtRoot, fs }) => {
|
|
|
664
785
|
throw stop(`${primary} — partial destination removed; re-run provision`);
|
|
665
786
|
};
|
|
666
787
|
|
|
667
|
-
const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied }) => {
|
|
788
|
+
const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null }) => {
|
|
668
789
|
if (EXCLUDED_BASENAMES.has(basename(srcAbs))) {
|
|
669
790
|
report.push(` skip (session sidecar): ${rel}`);
|
|
670
791
|
return;
|
|
@@ -673,11 +794,21 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied }) => {
|
|
|
673
794
|
try {
|
|
674
795
|
st = fs.lstat(srcAbs);
|
|
675
796
|
} catch (err) {
|
|
797
|
+
if (door) throw includeIdentityStop(rel, `cannot prove the source identity (${err?.code ?? 'fs error'})`);
|
|
676
798
|
throw stop(`copy failed (${err?.code ?? 'fs error'}) reading ${rel}`);
|
|
677
799
|
}
|
|
800
|
+
// A preflight-approved FILE include routes ONLY to the regular-file door — a node that is no
|
|
801
|
+
// longer a plain regular file (kind is part of the preflight identity) stops before any branch
|
|
802
|
+
// could create a destination.
|
|
803
|
+
if (door?.identity && (st.isSymbolicLink() || !st.isFile())) {
|
|
804
|
+
throw includeIdentityStop(rel, 'the source is not the node preflight approved');
|
|
805
|
+
}
|
|
678
806
|
try {
|
|
679
807
|
if (st.isSymbolicLink()) {
|
|
680
808
|
if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
|
|
809
|
+
// Fresh-provision include lane: an existing destination is aliasing the overlap
|
|
810
|
+
// comparator missed (nothing legitimate pre-populates it) — fail closed, never "kept".
|
|
811
|
+
if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
681
812
|
report.push(` kept (already present): ${rel}`);
|
|
682
813
|
return;
|
|
683
814
|
}
|
|
@@ -716,22 +847,25 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied }) => {
|
|
|
716
847
|
if (lstatNoFollow(fs.lstat, dstAbs) === null) {
|
|
717
848
|
guardDst(fs, wtRoot, dstAbs);
|
|
718
849
|
fs.mkdir(dstAbs);
|
|
850
|
+
} else if (door?.fresh) {
|
|
851
|
+
// Mirrors the file/symlink kept-exit STOPs: on a fresh provision no include destination
|
|
852
|
+
// node may pre-exist — an existing nested directory is aliasing or foreign content that
|
|
853
|
+
// must never fall under the include's ownership.
|
|
854
|
+
throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
719
855
|
}
|
|
720
856
|
for (const entry of fs.readdir(srcAbs)) {
|
|
721
|
-
copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied });
|
|
857
|
+
copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied, door });
|
|
722
858
|
}
|
|
723
859
|
} else if (st.isFile()) {
|
|
724
860
|
if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
|
|
861
|
+
if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
725
862
|
report.push(` kept (already present): ${rel}`);
|
|
726
863
|
return;
|
|
727
864
|
}
|
|
728
|
-
guardDst(fs, wtRoot, dirname(dstAbs));
|
|
729
|
-
fs.mkdir(dirname(dstAbs));
|
|
730
|
-
guardDst(fs, wtRoot, dstAbs);
|
|
731
865
|
try {
|
|
732
|
-
copyFileNoFollow({ srcAbs, dstAbs, sourceStat: st, rel, fs });
|
|
866
|
+
copyFileNoFollow({ srcAbs, dstAbs, sourceStat: st, rel, fs, door, wtRoot });
|
|
733
867
|
} catch (err) {
|
|
734
|
-
const cause = err?.code === WORKTREES_STOP ? err : stop(`copy failed (${err?.code ?? 'fs error'}) at ${rel}`);
|
|
868
|
+
const cause = err?.code === WORKTREES_STOP ? err : stop(`copy failed (${err?.code ?? 'fs error'}) at ${rel}${err?.closeFailureSuffix ?? ''}`);
|
|
735
869
|
if (err?.copyDoorDestinationCreated !== true) throw cause;
|
|
736
870
|
failAfterCopy({ cause, dstAbs, wtRoot, fs });
|
|
737
871
|
}
|
|
@@ -937,6 +1071,11 @@ export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declar
|
|
|
937
1071
|
// The recorded node_modules mode for that same verdict: provision neither advised nor created a
|
|
938
1072
|
// node_modules for this worktree.
|
|
939
1073
|
export const NODE_MODULES_NONE = 'no-dependencies';
|
|
1074
|
+
// The cleanup-ownership contract (AD-069): ownership is information content, decided live at
|
|
1075
|
+
// cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
|
|
1076
|
+
// into the worktrees mode doc; every ownership STOP emits it.
|
|
1077
|
+
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";
|
|
1078
|
+
export const INCLUDE_IDENTITY_RULE = 'An --include source is copied only through the identity door: a file include must still match the identity preflight recorded (device, inode, kind), a directory include root is re-checked at walk start, and every copied file is proven, with both descriptors open, not to be the node that IS the door-time queue — an absent queue keeps the lexical guard alone, and anything unprovable stops the copy';
|
|
940
1079
|
|
|
941
1080
|
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
942
1081
|
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
@@ -1074,19 +1213,22 @@ const assertIncludeRoundTrips = (rel) => {
|
|
|
1074
1213
|
// to smuggle) falls back to the lexical compare alone; any other realpath failure (EACCES/EIO)
|
|
1075
1214
|
// means the canonical identity cannot be established, and a silent fallback would quietly disable
|
|
1076
1215
|
// the guard it exists to enforce.
|
|
1077
|
-
const assertIncludeNeverCopiesTheQueue = ({ rootReal, incReal, inc, fs }) => {
|
|
1216
|
+
const assertIncludeNeverCopiesTheQueue = ({ rootReal, incReal, inc, fs, contract = false }) => {
|
|
1217
|
+
// `contract` marks the WALK-TIME (point-of-use) call: its failures are include-identity
|
|
1218
|
+
// refusals and carry the door contract; the pre-mutation preflight call keeps the plain form.
|
|
1219
|
+
const withContract = (message) => stop(contract ? `${message}\n${INCLUDE_IDENTITY_RULE}` : message);
|
|
1078
1220
|
const queueLexical = join(rootReal, PLANS_REL, QUEUE_BASENAME);
|
|
1079
1221
|
const queuePaths = [queueLexical];
|
|
1080
1222
|
try {
|
|
1081
1223
|
queuePaths.push(fs.realpath(queueLexical));
|
|
1082
1224
|
} catch (err) {
|
|
1083
1225
|
if (err?.code !== 'ENOENT') {
|
|
1084
|
-
throw
|
|
1226
|
+
throw withContract(`--include: cannot resolve the shared series index path (${err?.code ?? 'error'}), so the queue-copy guard cannot establish its canonical identity: ${PLANS_REL}/${QUEUE_BASENAME} — fix the path (or drop the --include) and re-run`);
|
|
1085
1227
|
}
|
|
1086
1228
|
}
|
|
1087
1229
|
for (const queuePath of queuePaths) {
|
|
1088
1230
|
if (incReal === queuePath || isInside(incReal, queuePath)) {
|
|
1089
|
-
throw
|
|
1231
|
+
throw withContract(`--include would copy the SHARED series index (${PLANS_REL}/${QUEUE_BASENAME}) into the worktree: ${inc}. The index lives only in main and is read there — a local copy silently diverges.`);
|
|
1090
1232
|
}
|
|
1091
1233
|
}
|
|
1092
1234
|
};
|
|
@@ -1178,20 +1320,45 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1178
1320
|
// It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
|
|
1179
1321
|
// realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
|
|
1180
1322
|
// redirect an include at the shared series index between the check and the copy.
|
|
1181
|
-
const provisionIncludes = ({ rootReal, wtRoot, includeSources, git, fs, report, copied }) => {
|
|
1323
|
+
const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied }) => {
|
|
1182
1324
|
const recorded = [];
|
|
1183
|
-
|
|
1325
|
+
const queuePath = join(rootReal, PLANS_REL, QUEUE_BASENAME);
|
|
1326
|
+
for (const { rel, real, identity } of includeSources) {
|
|
1184
1327
|
// Defence in depth: re-assert the queue-copy prohibition on the canonical path at the POINT OF
|
|
1185
1328
|
// USE, so it holds where the copy happens and not only where the path was first checked.
|
|
1186
|
-
assertIncludeNeverCopiesTheQueue({ rootReal, incReal: real, inc: rel, fs });
|
|
1187
|
-
|
|
1329
|
+
assertIncludeNeverCopiesTheQueue({ rootReal, incReal: real, inc: rel, fs, contract: true });
|
|
1330
|
+
if (identity.kind === 'directory') {
|
|
1331
|
+
// The walk-start ROOT recheck (a recheck, not a binding — the child walk stays path-based;
|
|
1332
|
+
// a FILE root instead verifies its descriptor against the preflight identity at the door).
|
|
1333
|
+
const live = (() => {
|
|
1334
|
+
try {
|
|
1335
|
+
return fs.lstat(real);
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
throw includeIdentityStop(rel, `cannot re-probe the include root (${err?.code ?? 'fs error'})`);
|
|
1338
|
+
}
|
|
1339
|
+
})();
|
|
1340
|
+
if (!live.isDirectory() || live.dev !== identity.dev || live.ino !== identity.ino) {
|
|
1341
|
+
throw includeIdentityStop(rel, 'the include root is not the node preflight approved');
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const probeRel = identity.kind === 'directory' ? `${rel}/` : rel;
|
|
1188
1345
|
if (!checkIgnored(git, probeRel, wtRoot)) {
|
|
1189
1346
|
throw stop(
|
|
1190
1347
|
`--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
|
|
1191
1348
|
'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
|
|
1192
1349
|
);
|
|
1193
1350
|
}
|
|
1194
|
-
|
|
1351
|
+
// On a FRESH provision nothing may legitimately pre-populate an IGNORED include destination
|
|
1352
|
+
// (overlaps refuse pre-mutation; a tracked rel was just refused above), so an existing
|
|
1353
|
+
// destination root is filesystem aliasing the comparator missed — fail closed at the door.
|
|
1354
|
+
// `--resume` keeps the copy-if-missing kept-exit (the stated prior-run residual).
|
|
1355
|
+
if (!resume && lstatNoFollow(fs.lstat, join(wtRoot, rel)) !== null) {
|
|
1356
|
+
throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
1357
|
+
}
|
|
1358
|
+
const door = identity.kind === 'file'
|
|
1359
|
+
? { identity, queuePath, fresh: !resume }
|
|
1360
|
+
: { queuePath, fresh: !resume };
|
|
1361
|
+
copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied, door });
|
|
1195
1362
|
recorded.push(rel);
|
|
1196
1363
|
}
|
|
1197
1364
|
return recorded;
|
|
@@ -1567,6 +1734,16 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1567
1734
|
const seed = validateSeedPlan({ root, rootReal, planFlag: flags.plan, asFlag: flags.as ?? null, fs });
|
|
1568
1735
|
|
|
1569
1736
|
const sources = assertProvisionSourcesContained({ root, rootReal, fs, statFollow: deps.stat ?? statSync });
|
|
1737
|
+
// FROZEN before any git mutation and used for BOTH the overlap refusal below and the copy loop
|
|
1738
|
+
// in finishProvision — a re-computed set could admit a registry path that appeared after
|
|
1739
|
+
// preflight inside an include root and copy it through the doorless copy-if-missing lane.
|
|
1740
|
+
const provisionSet = provisionCopySet(root, deps);
|
|
1741
|
+
const provisionSetRels = provisionSet.map((pattern) => patternToProbe(pattern).replace(/\/$/, ''));
|
|
1742
|
+
const reservedRels = [
|
|
1743
|
+
...provisionSetRels,
|
|
1744
|
+
`${PLANS_REL}/${seed.name}`,
|
|
1745
|
+
`${PLANS_REL}/${handoffBasename(slug)}`,
|
|
1746
|
+
];
|
|
1570
1747
|
const includeSources = [];
|
|
1571
1748
|
for (const inc of flags.include) {
|
|
1572
1749
|
const incAbs = resolve(root, inc);
|
|
@@ -1580,7 +1757,30 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1580
1757
|
assertIncludeNeverCopiesTheQueue({ rootReal, incReal, inc, fs });
|
|
1581
1758
|
const rel = relative(rootReal, incReal);
|
|
1582
1759
|
assertIncludeRoundTrips(rel);
|
|
1583
|
-
|
|
1760
|
+
// Preflight identity (F3): {dev, ino, kind} of the canonical node, captured BEFORE any git
|
|
1761
|
+
// mutation. A root that is neither a regular file nor a directory, or an erroring probe,
|
|
1762
|
+
// refuses pre-mutation (a plain usage STOP — the door contract applies to the copy walk).
|
|
1763
|
+
const incNode = (() => {
|
|
1764
|
+
try {
|
|
1765
|
+
return fs.lstat(incReal);
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
throw stop(`--include: cannot establish the identity of ${inc} (${err?.code ?? 'fs error'}) — fix the path (or drop the --include) and re-run`);
|
|
1768
|
+
}
|
|
1769
|
+
})();
|
|
1770
|
+
const kind = incNode.isDirectory() ? 'directory' : incNode.isFile() ? 'file' : null;
|
|
1771
|
+
if (kind === null) throw stop(`--include must be a regular file or a directory: ${inc}`);
|
|
1772
|
+
// Overlap refusal (pre-mutation): an include rel that another provision lane also populates
|
|
1773
|
+
// (the frozen registry footprint, the seeded plan, the handoff) — or another include root —
|
|
1774
|
+
// would meet the copy-if-missing kept-exit and skip the identity door entirely.
|
|
1775
|
+
const reserved = reservedRels.find((r) => includeRelsOverlap(rel, r));
|
|
1776
|
+
if (reserved !== undefined) {
|
|
1777
|
+
throw stop(`--include overlaps a path provision itself populates (${reserved}): ${inc} — the footprint, the seeded plan, and the handoff are copied by provision; drop the --include`);
|
|
1778
|
+
}
|
|
1779
|
+
const clashing = includeSources.find((prior) => includeRelsOverlap(rel, prior.rel));
|
|
1780
|
+
if (clashing !== undefined) {
|
|
1781
|
+
throw stop(`--include roots overlap: ${clashing.rel} and ${rel} — name each copied path once`);
|
|
1782
|
+
}
|
|
1783
|
+
includeSources.push({ rel, real: incReal, identity: { dev: incNode.dev, ino: incNode.ino, kind } });
|
|
1584
1784
|
}
|
|
1585
1785
|
assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
|
|
1586
1786
|
// The TARGET path reaches the record too — the `install` field embeds the worktree dir — and it
|
|
@@ -1627,7 +1827,7 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1627
1827
|
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1628
1828
|
// hand back the exact finish command, never just the local cause
|
|
1629
1829
|
try {
|
|
1630
|
-
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, git, deps, fs, report, log });
|
|
1830
|
+
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
|
|
1631
1831
|
} catch (err) {
|
|
1632
1832
|
if (!flags.resume && err?.message) {
|
|
1633
1833
|
err.message += `\nNOTE: the worktree at ${targetReal} (branch ${branch}) was created and KEPT — finish with: ${composeProvisionArgv({ root, slug, flags: { ...flags, resume: true } })} (or reclaim it with the consented cleanup).`;
|
|
@@ -1636,18 +1836,18 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1636
1836
|
}
|
|
1637
1837
|
};
|
|
1638
1838
|
|
|
1639
|
-
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, git, deps, fs, report, log }) => {
|
|
1839
|
+
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
|
|
1640
1840
|
writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
|
|
1641
1841
|
|
|
1642
1842
|
const copied = new Set();
|
|
1643
1843
|
report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
|
|
1644
|
-
for (const pattern of
|
|
1844
|
+
for (const pattern of provisionSet) {
|
|
1645
1845
|
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
1646
1846
|
copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied });
|
|
1647
1847
|
}
|
|
1648
1848
|
|
|
1649
1849
|
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
|
|
1650
|
-
const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, git, fs, report, copied });
|
|
1850
|
+
const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied });
|
|
1651
1851
|
// Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
|
|
1652
1852
|
// lane and the record must state the SAME verdict.
|
|
1653
1853
|
const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
|
|
@@ -2266,7 +2466,6 @@ const provisionKnownRoots = (identity) => {
|
|
|
2266
2466
|
...registryRoots(),
|
|
2267
2467
|
...identity.record.includes.map(safeRecordedPath),
|
|
2268
2468
|
PLANS_REL,
|
|
2269
|
-
'node_modules',
|
|
2270
2469
|
];
|
|
2271
2470
|
if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');
|
|
2272
2471
|
return [...new Set(roots)];
|
|
@@ -2277,7 +2476,6 @@ const provisionKnownDirectoryRoots = ({ root, identity, fs }) => {
|
|
|
2277
2476
|
...KIT_OWN_PATHS.filter(isDirPattern),
|
|
2278
2477
|
...KNOWN_FOOTPRINT.filter((entry) => entry.type === 'dir').map((entry) => entry.pattern),
|
|
2279
2478
|
PLANS_REL,
|
|
2280
|
-
'node_modules',
|
|
2281
2479
|
].map((path) => normalizeSlashes(path).replace(/^\//, '').replace(/\/$/, ''));
|
|
2282
2480
|
for (const include of identity.record.includes.map(safeRecordedPath)) {
|
|
2283
2481
|
const kind = classifyNodeNoFollow(join(root, include), fs).kind;
|
|
@@ -2294,7 +2492,7 @@ const rootCoveringPath = (path, roots) => roots.find((root) => {
|
|
|
2294
2492
|
return path === root || path.startsWith(`${root}/`);
|
|
2295
2493
|
}) ?? null;
|
|
2296
2494
|
|
|
2297
|
-
const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
2495
|
+
const foreignIgnoredPaths = ({ root, git, identity, fs, exemptNodeModules = false }) => {
|
|
2298
2496
|
const result = gitRead(
|
|
2299
2497
|
git, ['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=all'], root,
|
|
2300
2498
|
'git status --ignored failed',
|
|
@@ -2305,6 +2503,9 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
|
2305
2503
|
for (const rawPath of entry.paths) {
|
|
2306
2504
|
const normalized = normalizeSlashes(rawPath);
|
|
2307
2505
|
const path = normalized.replace(/\/$/, '');
|
|
2506
|
+
// The ONE ownership exemption (AD-069): the gate has already proven this exact node an
|
|
2507
|
+
// ignored-lane matching symlink; it is neither re-probed nor reported here.
|
|
2508
|
+
if (exemptNodeModules && path === NODE_MODULES_REL) continue;
|
|
2308
2509
|
const kind = classifyNodeNoFollow(join(root, path), fs).kind;
|
|
2309
2510
|
const rootType = kind === 'plain-directory' || kind === 'symlink-to-directory'
|
|
2310
2511
|
? 'directory'
|
|
@@ -2327,6 +2528,88 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
|
|
|
2327
2528
|
}).map(([path]) => path);
|
|
2328
2529
|
};
|
|
2329
2530
|
|
|
2531
|
+
// ── the node_modules ownership gate (AD-069) ──────────────────────────────────────────────
|
|
2532
|
+
// Class {absent, ephemeral, foreign} × lane {tracked, ignored, untracked}, decided live from
|
|
2533
|
+
// exactly one no-follow lstat + (symlink only) one buffer-form readlink + the git lane probes.
|
|
2534
|
+
// The record is never consulted; every probe error is FOREIGN fail-closed with no recovery
|
|
2535
|
+
// command. The single exempt state — the ignored-lane matching link — is re-proven immediately
|
|
2536
|
+
// before the irreversible worktree remove.
|
|
2537
|
+
|
|
2538
|
+
const NODE_MODULES_REL = 'node_modules';
|
|
2539
|
+
|
|
2540
|
+
const nodeModulesInspectStop = (nmPath, detail) => stop(
|
|
2541
|
+
`cleanup stopped: cannot inspect ${nmPath} (${detail})\n${CLEANUP_OWNERSHIP_RULE}`,
|
|
2542
|
+
);
|
|
2543
|
+
|
|
2544
|
+
const nodeModulesSnapshot = ({ wtRoot, mainRoot, git, fs }) => {
|
|
2545
|
+
const nmPath = join(wtRoot, NODE_MODULES_REL);
|
|
2546
|
+
const node = (() => {
|
|
2547
|
+
try {
|
|
2548
|
+
return { stat: fs.lstat(nmPath) };
|
|
2549
|
+
} catch (error) {
|
|
2550
|
+
return error?.code === 'ENOENT' ? { stat: null } : { error: error?.code ?? 'fs error' };
|
|
2551
|
+
}
|
|
2552
|
+
})();
|
|
2553
|
+
if (node.error) throw nodeModulesInspectStop(nmPath, node.error);
|
|
2554
|
+
const trackedProbe = git(['ls-files', '--cached', '-z', '--', NODE_MODULES_REL], wtRoot);
|
|
2555
|
+
if (trackedProbe.status !== 0) {
|
|
2556
|
+
throw nodeModulesInspectStop(nmPath, `git ls-files failed: ${(trackedProbe.stderr || trackedProbe.stdout).trim()}`);
|
|
2557
|
+
}
|
|
2558
|
+
const tracked = trackedProbe.stdout.length > 0;
|
|
2559
|
+
if (node.stat === null) {
|
|
2560
|
+
return { klass: 'absent', kind: 'absent', lane: tracked ? 'tracked' : null, nmPath };
|
|
2561
|
+
}
|
|
2562
|
+
const lane = tracked ? 'tracked' : (() => {
|
|
2563
|
+
const probe = git(['check-ignore', '--', NODE_MODULES_REL], wtRoot);
|
|
2564
|
+
if (probe.status === 0) return 'ignored';
|
|
2565
|
+
if (probe.status === 1) return 'untracked';
|
|
2566
|
+
throw nodeModulesInspectStop(nmPath, `git check-ignore failed: ${(probe.stderr || probe.stdout).trim()}`);
|
|
2567
|
+
})();
|
|
2568
|
+
if (!node.stat.isSymbolicLink()) {
|
|
2569
|
+
const kind = node.stat.isDirectory() ? 'directory' : node.stat.isFile() ? 'file' : 'special';
|
|
2570
|
+
return { klass: 'foreign', kind, lane, nmPath };
|
|
2571
|
+
}
|
|
2572
|
+
const target = (() => {
|
|
2573
|
+
try {
|
|
2574
|
+
return { bytes: fs.readlink(nmPath, { encoding: 'buffer' }) };
|
|
2575
|
+
} catch (error) {
|
|
2576
|
+
return { error: error?.code ?? 'fs error' };
|
|
2577
|
+
}
|
|
2578
|
+
})();
|
|
2579
|
+
if (target.error) throw nodeModulesInspectStop(nmPath, target.error);
|
|
2580
|
+
// Raw BYTES against MAIN's node_modules path — never decoded, never resolved: a relative or
|
|
2581
|
+
// re-encoded form that merely RESOLVES to main stays foreign, and the target's kind is
|
|
2582
|
+
// irrelevant (unlink touches only the link).
|
|
2583
|
+
const matches = Buffer.compare(target.bytes, Buffer.from(join(mainRoot, NODE_MODULES_REL))) === 0;
|
|
2584
|
+
return { klass: matches ? 'ephemeral' : 'foreign', kind: 'symlink', lane, nmPath };
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
const nodeModulesExemptVerdict = (snapshot) => snapshot.klass === 'ephemeral' && snapshot.lane === 'ignored';
|
|
2588
|
+
|
|
2589
|
+
const composeNodeModulesStop = ({ snapshot, slug, branch, changed = false }) => {
|
|
2590
|
+
const { klass, kind, lane, nmPath } = snapshot;
|
|
2591
|
+
const kindDesc = kind === 'symlink'
|
|
2592
|
+
? (klass === 'ephemeral' ? 'a main-matching symlink' : 'a foreign symlink')
|
|
2593
|
+
: kind === 'directory' ? 'a directory'
|
|
2594
|
+
: kind === 'file' ? 'a regular file'
|
|
2595
|
+
: kind === 'special' ? 'a special node' : 'absent';
|
|
2596
|
+
const head = `cleanup stopped: ${nmPath} is ${kindDesc} in the ${lane} lane`
|
|
2597
|
+
+ `${changed ? ' (changed during cleanup)' : ''}\n${CLEANUP_OWNERSHIP_RULE}`;
|
|
2598
|
+
if (lane === 'tracked') {
|
|
2599
|
+
return stop([
|
|
2600
|
+
head,
|
|
2601
|
+
'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',
|
|
2602
|
+
destructiveRecovery({ slug, branch }),
|
|
2603
|
+
].join('\n'));
|
|
2604
|
+
}
|
|
2605
|
+
const removal = kind === 'directory' ? `rm -rf -- ${shellQuoteArg(nmPath)}` : `rm -- ${shellQuoteArg(nmPath)}`;
|
|
2606
|
+
return stop([
|
|
2607
|
+
head,
|
|
2608
|
+
`remove it yourself, then re-run cleanup: ${removal}`,
|
|
2609
|
+
destructiveRecovery({ slug, branch }),
|
|
2610
|
+
].join('\n'));
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2330
2613
|
const composeCleanupCommand = ({ slug, branch, abandon }) =>
|
|
2331
2614
|
`cleanup ${shellQuoteArg(slug)}` +
|
|
2332
2615
|
`${branch === `${DEFAULT_BRANCH_PREFIX}${slug}` ? '' : ` --branch ${shellQuoteArg(branch)}`}` +
|
|
@@ -2380,6 +2663,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2380
2663
|
|
|
2381
2664
|
const knownRoots = provisionKnownRoots(identity);
|
|
2382
2665
|
let removalRoots = [];
|
|
2666
|
+
let nodeModulesExempt = false;
|
|
2383
2667
|
if (!flags.abandon) {
|
|
2384
2668
|
const mainHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve main HEAD').stdout.trim();
|
|
2385
2669
|
// Transfer verification excludes docs/ai and docs/plans; their tracked drift is checked separately before reset.
|
|
@@ -2422,6 +2706,16 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2422
2706
|
);
|
|
2423
2707
|
}
|
|
2424
2708
|
|
|
2709
|
+
// The ownership gate (AD-069) runs BEFORE the inventories: it also catches tracked
|
|
2710
|
+
// node_modules and empty untracked directories git never lists. Clean-absent proceeds on
|
|
2711
|
+
// the legacy path with no post-reset ownership arm; the single exemption is re-proven
|
|
2712
|
+
// below, immediately before the irreversible remove.
|
|
2713
|
+
const ownership = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
|
|
2714
|
+
if (!nodeModulesExemptVerdict(ownership) && !(ownership.klass === 'absent' && ownership.lane !== 'tracked')) {
|
|
2715
|
+
throw composeNodeModulesStop({ snapshot: ownership, slug, branch });
|
|
2716
|
+
}
|
|
2717
|
+
nodeModulesExempt = nodeModulesExemptVerdict(ownership);
|
|
2718
|
+
|
|
2425
2719
|
const untracked = untrackedPaths(git, entry.path);
|
|
2426
2720
|
const foreign = [];
|
|
2427
2721
|
for (const path of untracked) {
|
|
@@ -2435,7 +2729,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2435
2729
|
destructiveRecovery({ slug, branch }),
|
|
2436
2730
|
);
|
|
2437
2731
|
}
|
|
2438
|
-
const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs });
|
|
2732
|
+
const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs, exemptNodeModules: nodeModulesExempt });
|
|
2439
2733
|
if (foreignIgnored.length > 0) {
|
|
2440
2734
|
throw stop(`foreign ignored content requires --abandon:\n${foreignIgnored.join('\n')}`);
|
|
2441
2735
|
}
|
|
@@ -2455,6 +2749,20 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2455
2749
|
for (const rel of removalRoots) {
|
|
2456
2750
|
removeNodeNoFollow({ root: entry.path, abs: join(entry.path, rel), fs, label: rel });
|
|
2457
2751
|
}
|
|
2752
|
+
if (nodeModulesExempt) {
|
|
2753
|
+
// The authoritative re-proof (AD-069): the verdict authorizing the irreversible remove
|
|
2754
|
+
// postdates the last tree-mutating operation. Strict equality with the exempt state —
|
|
2755
|
+
// never re-authorization: ANY deviation fails closed with no remove call.
|
|
2756
|
+
const revalidated = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
|
|
2757
|
+
if (!nodeModulesExemptVerdict(revalidated)) {
|
|
2758
|
+
if (revalidated.klass === 'absent' && revalidated.lane !== 'tracked') {
|
|
2759
|
+
throw stop(
|
|
2760
|
+
`cleanup stopped: ${revalidated.nmPath} changed during cleanup (now absent) — re-run cleanup\n${CLEANUP_OWNERSHIP_RULE}`,
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
throw composeNodeModulesStop({ snapshot: revalidated, slug, branch, changed: true });
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2458
2766
|
}
|
|
2459
2767
|
|
|
2460
2768
|
removeWorktree({ root, entry, branch, abandon: flags.abandon, git });
|