@sabaiway/agent-workflow-kit 3.8.0 → 3.10.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 CHANGED
@@ -4,6 +4,58 @@ 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.10.0 — install advice reads the worktree checkout (AD-071)
8
+
9
+ The install advice picked its package manager from MAIN's `package.json` and lockfiles while the
10
+ printed command targets the SATELLITE (`cd <worktree> && <manager> install`) — so a dirty MAIN
11
+ lockfile could push the advice into ambiguity or the wrong manager, and a MAIN advanced past the
12
+ satellite's base steered a satellite it no longer describes. The evidence and the target are now
13
+ the SAME tree:
14
+
15
+ - **Manager evidence reads the worktree's own live checkout** — the `packageManager` field and
16
+ the lockfile scan, the same live lane the dependency-free proof (3.6.0) and the node_modules
17
+ symlink probe already use. MAIN's mutable working tree never steers manager selection; MAIN
18
+ state legitimately steers only the symlink lane (the shared-cache link and its unlink-first
19
+ posture), unchanged.
20
+ - **The contract ships in the worktrees mode doc as one pinned sentence** (a named test locks
21
+ the exact wording): all manifest/lockfile install evidence is read from the worktree's own
22
+ live files at the moment the posture is resolved — deliberately NOT «exactly HEAD», because a
23
+ `post-checkout` hook can shape the checkout before the posture is resolved.
24
+ - **No resume-tolerance smuggling.** The shipped clean-tree `--resume` STOP is pinned byte-exact
25
+ by a real-git test; dirty-resume semantics remain a queued redesign. A satellite behind an
26
+ advanced MAIN now gets advice about ITSELF — pinned by a real-git test where MAIN's lockfile
27
+ advance does not re-steer the satellite's refreshed record.
28
+ - **Honest residual:** a manager signal living only in MAIN's working tree (e.g. an ignored,
29
+ uncommitted lockfile) no longer steers the advice — the checkout is the truth even when that
30
+ yields the npm default.
31
+
32
+ ## 3.9.0 — the --include copy door proves what it copies (AD-070)
33
+
34
+ Nothing bound the node the provision copy walk read to the node `--include` preflight approved:
35
+ a source swapped after preflight was copied as approved, and the path-based queue guard could not
36
+ see a node that had become the shared series index by identity (a hardlink, a swap) rather than
37
+ by path. The gap is closed at the existing descriptor door:
38
+
39
+ - **Preflight identity, captured before any git mutation.** The `--include` loop records each
40
+ root's {device, inode, kind} from the canonical node. A root that is neither a regular file
41
+ nor a directory — or an erroring identity probe — refuses BEFORE `git worktree add`: no
42
+ worktree is left behind.
43
+ - **Three tiers at the walk.** A file root is verified at the door — the open descriptor must
44
+ match the preflight identity, and a node that is no longer a plain regular file stops before
45
+ any destination exists. A directory root gets a walk-start recheck (a recheck, not a binding).
46
+ Child files keep the existing same-call lstat↔open identity; the child walk stays path-based
47
+ (a stated residual).
48
+ - **The door-time queue refusal, every copied include file.** With the source descriptor open,
49
+ the door opens the lexical queue path (following links, non-blocking — a FIFO-shaped queue
50
+ classifies non-regular and stops), fstats the open descriptor, and compares identities with
51
+ both descriptors open: never copy the node that IS the door-time queue. Queue identity is
52
+ established at descriptor-open time and never cached across crossings — absence is not cached
53
+ either. A truly absent queue keeps the lexical guard alone; a dangling, unreadable, or
54
+ non-regular queue is unprovable and stops the copy. The queue descriptor closes exactly once;
55
+ a close error surfaces without destination residue.
56
+ - **The contract ships as an exported constant** (`INCLUDE_IDENTITY_RULE`), emitted on every
57
+ include-identity STOP and doc-parity-pinned into the worktrees mode doc.
58
+
7
59
  ## 3.8.0 — cleanup never deletes a node_modules it cannot prove ephemeral (AD-069)
8
60
 
9
61
  Routine non-abandon `worktrees cleanup` used to delete an ignored user-built `node_modules`
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.8.0'
6
+ version: '3.10.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "3.8.0",
6
+ "version": "3.10.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "3.8.0",
3
+ "version": "3.10.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
@@ -85,8 +104,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
85
104
  isolated-install command when the package manager is unambiguous, the honest install-by-hand
86
105
  advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
87
106
  unlink-first form, because a plain install through the symlink writes into MAIN and is never
88
- presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read: exactly HEAD at provision time, the satellite's own committed state on `--resume`, never MAIN's mutable working tree) the record and the default-lane report both state `no install needed — the project declares no dependencies` and print no install command. A workspace tree is NEVER provably install-free — a workspace install materializes member links and `.bin` shims even with zero dependencies — and anything else the tool cannot enumerate (an absent or unparseable `package.json`, a malformed dependency or scripts field, an install-lifecycle script — dependency-free is not install-free) leaves the posture UNKNOWN and keeps the existing advice: a false "nothing to install" is worse than a redundant hint. `--install` remains an EXPLICIT request and is always answered with the
107
+ presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read: the checkout's LIVE files at the moment the posture is resolved; on `--resume` a dirty tree is then refused by the clean-tree verify, after the record refresh) the record and the default-lane report both state `no install needed — the project declares no dependencies` and print no install command. A workspace tree is NEVER provably install-free — a workspace install materializes member links and `.bin` shims even with zero dependencies — and anything else the tool cannot enumerate (an absent or unparseable `package.json`, a malformed dependency or scripts field, an install-lifecycle script — dependency-free is not install-free) leaves the posture UNKNOWN and keeps the existing advice: a false "nothing to install" is worse than a redundant hint. `--install` remains an EXPLICIT request and is always answered with the
89
108
  isolated-install command.
109
+ All manifest/lockfile install evidence — the dependency-free proof AND the package-manager selection (the `packageManager` field, lockfiles) — is read from the worktree's own LIVE files at the moment the posture is resolved (on `--resume` too, where a dirty tree is then refused by the clean-tree verify); MAIN's mutable working tree never steers manager selection.
90
110
 
91
111
  **Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
92
112
  commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
@@ -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, CLEANUP_OWNERSHIP_RULE } 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
 
@@ -108,6 +108,10 @@ export const BINDINGS = Object.freeze([
108
108
  // The cleanup-ownership contract (AD-069): the exact live sentence every ownership STOP emits —
109
109
  // a reworded mode doc dropping the class × lane contract fails this pin plus the gate.
110
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]),
111
115
  ].map((b) => Object.freeze(b)));
112
116
 
113
117
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -155,8 +159,8 @@ A CLOSED, exported registry binds each live code constant — the autonomy-docto
155
159
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
156
160
  contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
157
161
  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
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
160
164
  the exact token its references/modes/*.md contract must carry, and
161
165
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
162
166
  or an absent token FAILS CLOSED.
@@ -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
- const copyFileNoFollow = ({ srcAbs, dstAbs, sourceStat, rel, fs }) => {
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 stop(`copy source changed between lstat and open: ${rel}`);
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 = fs.fstat(handles.source);
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 stop(`copy source changed between lstat and open: ${rel}`);
312
+ throw sourceWindowStop();
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');
219
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
- if (outcome.error) throw withDestinationState(outcome.error);
256
- if (closeErrors.length > 0) throw withDestinationState(closeErrors[0]);
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
  }
@@ -941,6 +1075,7 @@ export const NODE_MODULES_NONE = 'no-dependencies';
941
1075
  // cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
942
1076
  // into the worktrees mode doc; every ownership STOP emits it.
943
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';
944
1079
 
945
1080
  // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
946
1081
  // is refused rather than written: a newline spills a second line the parser reads as a real field
@@ -1078,19 +1213,22 @@ const assertIncludeRoundTrips = (rel) => {
1078
1213
  // to smuggle) falls back to the lexical compare alone; any other realpath failure (EACCES/EIO)
1079
1214
  // means the canonical identity cannot be established, and a silent fallback would quietly disable
1080
1215
  // the guard it exists to enforce.
1081
- 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);
1082
1220
  const queueLexical = join(rootReal, PLANS_REL, QUEUE_BASENAME);
1083
1221
  const queuePaths = [queueLexical];
1084
1222
  try {
1085
1223
  queuePaths.push(fs.realpath(queueLexical));
1086
1224
  } catch (err) {
1087
1225
  if (err?.code !== 'ENOENT') {
1088
- throw stop(`--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`);
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`);
1089
1227
  }
1090
1228
  }
1091
1229
  for (const queuePath of queuePaths) {
1092
1230
  if (incReal === queuePath || isInside(incReal, queuePath)) {
1093
- throw stop(`--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.`);
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.`);
1094
1232
  }
1095
1233
  }
1096
1234
  };
@@ -1182,20 +1320,45 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1182
1320
  // It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
1183
1321
  // realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
1184
1322
  // redirect an include at the shared series index between the check and the copy.
1185
- const provisionIncludes = ({ rootReal, wtRoot, includeSources, git, fs, report, copied }) => {
1323
+ const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied }) => {
1186
1324
  const recorded = [];
1187
- for (const { rel, real } of includeSources) {
1325
+ const queuePath = join(rootReal, PLANS_REL, QUEUE_BASENAME);
1326
+ for (const { rel, real, identity } of includeSources) {
1188
1327
  // Defence in depth: re-assert the queue-copy prohibition on the canonical path at the POINT OF
1189
1328
  // USE, so it holds where the copy happens and not only where the path was first checked.
1190
- assertIncludeNeverCopiesTheQueue({ rootReal, incReal: real, inc: rel, fs });
1191
- const probeRel = fs.lstat(real).isDirectory() ? `${rel}/` : rel;
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;
1192
1345
  if (!checkIgnored(git, probeRel, wtRoot)) {
1193
1346
  throw stop(
1194
1347
  `--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
1195
1348
  'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
1196
1349
  );
1197
1350
  }
1198
- copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
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 });
1199
1362
  recorded.push(rel);
1200
1363
  }
1201
1364
  return recorded;
@@ -1211,8 +1374,10 @@ const LOCKFILE_MANAGERS = Object.freeze([
1211
1374
  const NEUTRAL_INSTALL_ADVICE =
1212
1375
  'install command not printed — package manager is ambiguous or unknown; install dependencies in the worktree by hand';
1213
1376
 
1214
- const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1215
- const pkg = readFileNoFollow(fs, join(root, 'package.json'));
1377
+ // Advice evidence = the WORKTREE'S OWN LIVE checkout (manifest AND lockfiles): the printed
1378
+ // command runs in the satellite, so what main's working tree happens to hold must not steer it.
1379
+ const resolveInstallAdvice = ({ wtRoot, fs }) => {
1380
+ const pkg = readFileNoFollow(fs, join(wtRoot, 'package.json'));
1216
1381
  let manager = null;
1217
1382
  let inspectLocks = false;
1218
1383
  if (pkg.absent) {
@@ -1243,7 +1408,7 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1243
1408
  for (const [name, candidate] of LOCKFILE_MANAGERS) {
1244
1409
  let st;
1245
1410
  try {
1246
- st = fs.lstat(join(root, name));
1411
+ st = fs.lstat(join(wtRoot, name));
1247
1412
  } catch (err) {
1248
1413
  if (err?.code === 'ENOENT') continue;
1249
1414
  return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
@@ -1318,9 +1483,10 @@ const declaresNativeBuild = (fs, dir) => lstatNoFollow(fs.lstat, join(dir, 'bind
1318
1483
 
1319
1484
  // PROVABLY dependency-free, or nothing — read from the WORKTREE'S OWN LIVE CHECKOUT, never from
1320
1485
  // MAIN's mutable working tree: the evidence is what an install run in THIS worktree would actually
1321
- // read. At provision time that is exactly HEAD; on --resume it follows the session's own edits, in
1322
- // both directions (gained dependencies revoke the proof, shed ones grant it) the same live lane
1323
- // as the node_modules symlink probe. A dirty main manifest must neither grant nor revoke a verdict
1486
+ // read, taken at the moment the posture is resolved (a post-checkout hook may already have shaped
1487
+ // the checkout; on --resume it follows the session's own edits, in both directionsgained
1488
+ // dependencies revoke the proof, shed ones grant it the same live lane
1489
+ // as the node_modules symlink probe). A dirty main manifest must neither grant nor revoke a verdict
1324
1490
  // about content it does not describe. A `workspaces` field of ANY shape
1325
1491
  // is UNKNOWN outright — a workspace install materializes member links and `.bin` shims even with
1326
1492
  // zero dependencies, so a workspace tree is never provably install-free. Everything else the tool
@@ -1349,20 +1515,20 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
1349
1515
  // an earlier provision left — an install through it writes into MAIN, and the posture must never
1350
1516
  // hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
1351
1517
  // "nothing to install" must not ride an install instruction.
1352
- const resolveInstallPosture = ({ root, wtRoot, dependencyFree, fs }) => {
1518
+ const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
1353
1519
  const nmPath = join(wtRoot, 'node_modules');
1354
1520
  const nm = lstatNoFollow(fs.lstat, nmPath);
1355
1521
  if (nm !== null && nm.isSymbolicLink()) {
1356
- const advice = resolveInstallAdvice({ root, wtRoot, fs });
1522
+ const advice = resolveInstallAdvice({ wtRoot, fs });
1357
1523
  const separator = advice.command === null ? ' — ' : ' && ';
1358
1524
  return `the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN) — for isolation remove it first: rm ${shellQuoteArg(nmPath)}${separator}${advice.instruction}`;
1359
1525
  }
1360
1526
  if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
1361
- return resolveInstallAdvice({ root, wtRoot, fs }).instruction;
1527
+ return resolveInstallAdvice({ wtRoot, fs }).instruction;
1362
1528
  };
1363
1529
 
1364
1530
  const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
1365
- const install = resolveInstallAdvice({ root, wtRoot, fs });
1531
+ const install = resolveInstallAdvice({ wtRoot, fs });
1366
1532
  if (installFlag) {
1367
1533
  const dst = join(wtRoot, 'node_modules');
1368
1534
  const existing = lstatNoFollow(fs.lstat, dst);
@@ -1571,6 +1737,16 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1571
1737
  const seed = validateSeedPlan({ root, rootReal, planFlag: flags.plan, asFlag: flags.as ?? null, fs });
1572
1738
 
1573
1739
  const sources = assertProvisionSourcesContained({ root, rootReal, fs, statFollow: deps.stat ?? statSync });
1740
+ // FROZEN before any git mutation and used for BOTH the overlap refusal below and the copy loop
1741
+ // in finishProvision — a re-computed set could admit a registry path that appeared after
1742
+ // preflight inside an include root and copy it through the doorless copy-if-missing lane.
1743
+ const provisionSet = provisionCopySet(root, deps);
1744
+ const provisionSetRels = provisionSet.map((pattern) => patternToProbe(pattern).replace(/\/$/, ''));
1745
+ const reservedRels = [
1746
+ ...provisionSetRels,
1747
+ `${PLANS_REL}/${seed.name}`,
1748
+ `${PLANS_REL}/${handoffBasename(slug)}`,
1749
+ ];
1574
1750
  const includeSources = [];
1575
1751
  for (const inc of flags.include) {
1576
1752
  const incAbs = resolve(root, inc);
@@ -1584,7 +1760,30 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1584
1760
  assertIncludeNeverCopiesTheQueue({ rootReal, incReal, inc, fs });
1585
1761
  const rel = relative(rootReal, incReal);
1586
1762
  assertIncludeRoundTrips(rel);
1587
- includeSources.push({ rel, real: incReal });
1763
+ // Preflight identity (F3): {dev, ino, kind} of the canonical node, captured BEFORE any git
1764
+ // mutation. A root that is neither a regular file nor a directory, or an erroring probe,
1765
+ // refuses pre-mutation (a plain usage STOP — the door contract applies to the copy walk).
1766
+ const incNode = (() => {
1767
+ try {
1768
+ return fs.lstat(incReal);
1769
+ } catch (err) {
1770
+ throw stop(`--include: cannot establish the identity of ${inc} (${err?.code ?? 'fs error'}) — fix the path (or drop the --include) and re-run`);
1771
+ }
1772
+ })();
1773
+ const kind = incNode.isDirectory() ? 'directory' : incNode.isFile() ? 'file' : null;
1774
+ if (kind === null) throw stop(`--include must be a regular file or a directory: ${inc}`);
1775
+ // Overlap refusal (pre-mutation): an include rel that another provision lane also populates
1776
+ // (the frozen registry footprint, the seeded plan, the handoff) — or another include root —
1777
+ // would meet the copy-if-missing kept-exit and skip the identity door entirely.
1778
+ const reserved = reservedRels.find((r) => includeRelsOverlap(rel, r));
1779
+ if (reserved !== undefined) {
1780
+ 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`);
1781
+ }
1782
+ const clashing = includeSources.find((prior) => includeRelsOverlap(rel, prior.rel));
1783
+ if (clashing !== undefined) {
1784
+ throw stop(`--include roots overlap: ${clashing.rel} and ${rel} — name each copied path once`);
1785
+ }
1786
+ includeSources.push({ rel, real: incReal, identity: { dev: incNode.dev, ino: incNode.ino, kind } });
1588
1787
  }
1589
1788
  assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
1590
1789
  // The TARGET path reaches the record too — the `install` field embeds the worktree dir — and it
@@ -1631,7 +1830,7 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1631
1830
  // any failure past this point leaves a real created worktree — the error must say so and
1632
1831
  // hand back the exact finish command, never just the local cause
1633
1832
  try {
1634
- return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, git, deps, fs, report, log });
1833
+ return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
1635
1834
  } catch (err) {
1636
1835
  if (!flags.resume && err?.message) {
1637
1836
  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).`;
@@ -1640,18 +1839,18 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1640
1839
  }
1641
1840
  };
1642
1841
 
1643
- const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, git, deps, fs, report, log }) => {
1842
+ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
1644
1843
  writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
1645
1844
 
1646
1845
  const copied = new Set();
1647
1846
  report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
1648
- for (const pattern of provisionCopySet(root, deps)) {
1847
+ for (const pattern of provisionSet) {
1649
1848
  const rel = patternToProbe(pattern).replace(/\/$/, '');
1650
1849
  copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied });
1651
1850
  }
1652
1851
 
1653
1852
  writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
1654
- const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, git, fs, report, copied });
1853
+ const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied });
1655
1854
  // Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
1656
1855
  // lane and the record must state the SAME verdict.
1657
1856
  const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
@@ -1670,7 +1869,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1670
1869
  includes: includesRecorded,
1671
1870
  nodeModules: nodeModulesMode,
1672
1871
  vscode: vscodeMode,
1673
- install: resolveInstallPosture({ root, wtRoot: targetPath, dependencyFree, fs }),
1872
+ install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
1674
1873
  ...orientationFields({ root, slug }),
1675
1874
  },
1676
1875
  fs,