@sabaiway/agent-workflow-kit 3.4.0 → 3.5.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,36 @@ 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.5.0 — the provision record orients a fresh satellite session (AD-065)
8
+
9
+ The worktrees provision record was an identity stub; a fresh satellite session could not derive
10
+ three facts from its own checkout. The record now carries all three, and the mode doc is pinned to
11
+ the live constants:
12
+
13
+ - **`shared-queue`** — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the verbatim
14
+ rule: the series index is SHARED, read it at that path, never copy it (a machine-local copy
15
+ silently diverges); findings ride the handoff record and main appends them to the index. The rule
16
+ ships only WITH the path — a record from an earlier kit carries neither. `--include` refuses to
17
+ copy the index (or any directory containing it) into the worktree, at preflight AND re-asserted
18
+ at the point of copy on the canonical pair resolved before `git worktree add`.
19
+ - **`landing`** — landing runs FROM MAIN, never from the worktree, with the runnable
20
+ `… land <slug> --prepare` command already `cd`-ing back to main.
21
+ - **`install`** — the resolved install posture: the runnable isolated-install command, or the
22
+ honest by-hand advice when the package manager is ambiguous.
23
+
24
+ The record is line-oriented and parsed back for IDENTITY, so it now REFUSES any value it cannot
25
+ round-trip: control bytes (an injected newline forges a field line or an `## …` heading that
26
+ truncates the section) and U+2028/U+2029 (which write fine and are then silently DROPPED on read —
27
+ a lost field with no error) are a typed STOP, never sanitized. Every value the record will carry is
28
+ validated BEFORE any git mutation — a refusal at compose time would strand a created worktree with
29
+ no handoff, which neither `--resume` nor `cleanup --abandon` can recover. Optional fields are
30
+ omitted when absent, never rendered as `null`, so a record written by an earlier kit survives a
31
+ refresh. Two new `doc-parity` bindings (`queue-shared-rule`, `landing-from-main`) pin
32
+ `references/modes/worktrees.md` to the emitted strings.
33
+
34
+ Second safe slice extracted from the deferred parallel-track work (AD-063) — no
35
+ node_modules-ownership coupling; the provable dependency-free install posture is the next slice.
36
+
7
37
  ## 3.4.0 — review-state names a latent arm on a clean-tree PASS (AD-064)
8
38
 
9
39
  `review-state --check` under a configured `reviewed` or `council` recipe on a clean tree no longer
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.4.0'
6
+ version: '3.5.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.4.0",
6
+ "version": "3.5.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.4.0",
3
+ "version": "3.5.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",
@@ -59,6 +59,20 @@ own verbatim error through the existing Git-error surface.
59
59
  Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
60
60
  requires the handoff identity, and is the only path where `--force` may appear.
61
61
 
62
+ **Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):** identity
63
+ (`slug`, `branch`, `include`, `node_modules`, `vscode-settings`, and after a prepare `prepared-tree`)
64
+ PLUS the three facts a fresh satellite session cannot derive from its own checkout:
65
+
66
+ - `shared-queue` — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the rule the record states verbatim: the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here. Provision never seeds a copy: the queue is deliberately absent from the satellite, and the absolute path is the only pointer — `--include` refuses to copy the index (or any directory containing it) into the worktree.
67
+ - `landing` — landing runs FROM MAIN, never from this worktree, with the runnable
68
+ `… land <slug> --prepare` command already `cd`-ing back to main.
69
+ - `install` — the install posture the tool resolved for THIS worktree: the runnable
70
+ isolated-install command when the package manager is unambiguous, the honest install-by-hand
71
+ advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
72
+ unlink-first form, because a plain install through the symlink writes into MAIN and is never
73
+ presented as isolated. `--install` remains an EXPLICIT request and is always answered with the
74
+ isolated-install command.
75
+
62
76
  **Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
63
77
  commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
64
78
  goes through its one no-follow descriptor door (identity-bound source, exclusive destination,
@@ -98,7 +112,9 @@ free-form session-records digest slot (every section outside `## Provision recor
98
112
  and byte-preserved by the tool).
99
113
 
100
114
  **Ownership:** MAIN owns MAIN-tree files, commits, pushes, releases, the gate matrix, every
101
- docs/ai record, `docs/plans/queue.md`, and all shared git state stash, hooks, repo config,
115
+ docs/ai record, `docs/plans/queue.md` (the satellite READS it at the absolute main path and never
116
+ writes it — its findings ride the handoff and main appends them; see **Provision record** above),
117
+ and all shared git state — stash, hooks, repo config,
102
118
  `.git/info/exclude`, and every ref except the satellite's configured branch. The SATELLITE owns
103
119
  that one branch (`aw/<slug>` or the `--branch` override), its feature edits, its seeded plan, and
104
120
  the user-owned handoff sections; `## Provision record` remains tool-owned. Satellite forbidden
@@ -106,7 +122,8 @@ verbs (the v1 docs-only bar): no `git commit`/`push`/`tag`/`git stash`/history r
106
122
  legal rewrite is the tool-printed `git reset --hard` recovery of the satellite's OWN configured
107
123
  branch (`aw/<slug>` or the `--branch` override) — no kit lifecycle writers
108
124
  (`init`/`upgrade`/`setup`/`hide-footprint`/`install-git-hooks`/`sandbox-masks`/`ack-write`), no
109
- queue.md writes, no version bumps or publishes, no edits to MAIN's files from the satellite
125
+ queue.md writes and no LOCAL queue.md copy findings go into the handoff record, which main folds
126
+ into the index at landing, no version bumps or publishes, no edits to MAIN's files from the satellite
110
127
  session — divergence and the landed verification enforce the observable half; the rest is the
111
128
  stated contract. A symlinked `node_modules` under npm workspaces resolves
112
129
  workspace self-links to MAIN-tree sources — use the printed isolated install when that matters.
@@ -34,6 +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 } from './worktrees.mjs';
37
38
 
38
39
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
39
40
 
@@ -43,6 +44,7 @@ const UPGRADE_DOC = 'references/modes/upgrade.md';
43
44
  const VELOCITY_DOC = 'references/modes/velocity.md';
44
45
  const SETUP_DOC = 'references/modes/setup.md';
45
46
  const REVIEW_STATE_DOC = 'references/modes/review-state.md';
47
+ const WORKTREES_DOC = 'references/modes/worktrees.md';
46
48
 
47
49
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
48
50
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -96,6 +98,11 @@ export const BINDINGS = Object.freeze([
96
98
  // It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
97
99
  // actually emits — a reworded doc dropping the notice fails this pin plus the gate.
98
100
  valueBinding('latent-arm-notice', LATENT_ARM_NOTICE, LATENT_ARM_NOTICE, [REVIEW_STATE_DOC]),
101
+ // The provision-record orientation contract (same "the tool knows and does not say" class): the
102
+ // shared-queue rule and the landing-from-main fact were prose-only bars a doc could silently
103
+ // drop, so both are pinned to the live strings the record actually carries.
104
+ valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
105
+ valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
99
106
  ].map((b) => Object.freeze(b)));
100
107
 
101
108
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -142,7 +149,8 @@ Usage:
142
149
  A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
143
150
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
144
151
  contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
145
- degrade token, and the review-state clean-tree latent-arm notice to the exact token its
152
+ degrade token, the review-state clean-tree latent-arm notice, and the worktrees provision-record
153
+ orientation contract (shared-queue rule, landing-from-main) — to the exact token its
146
154
  references/modes/*.md contract must carry, and
147
155
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
148
156
  or an absent token FAILS CLOSED.
@@ -927,16 +927,58 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
927
927
 
928
928
  // ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
929
929
 
930
- const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, prepared = null }) => [
930
+ // The orientation facts a fresh satellite session cannot derive from its own checkout. They are
931
+ // CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
932
+ export const QUEUE_BASENAME = 'queue.md';
933
+ export const QUEUE_SHARED_RULE =
934
+ 'the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here';
935
+ export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
936
+
937
+ // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
938
+ // is refused rather than written: a newline spills a second line the parser reads as a real field
939
+ // (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
940
+ // the whole section). Values reach here from the repo ROOT path and from --include, both of which
941
+ // may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
942
+ // record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
943
+ // not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
944
+ // a lost field with no error, which is the one outcome this codebase never allows.
945
+ // Fail closed: refuse to write, never sanitize silently.
946
+ const RECORD_CONTROL_BYTE = /[\u0000-\u001F\u007F\u2028\u2029]/;
947
+ const recordValue = (name, value) => {
948
+ const text = String(value);
949
+ if (RECORD_CONTROL_BYTE.test(text)) {
950
+ throw stop(`handoff record: the ${name} value carries a control character (newline/CR/NUL) — refusing to write a record whose fields could be forged by an injected line`);
951
+ }
952
+ // The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
953
+ // — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
954
+ // back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
955
+ if (text !== text.trim()) {
956
+ throw stop(`handoff record: the ${name} value carries leading or trailing whitespace, which the record trims on read — the identity would change across a write→read round-trip: ${JSON.stringify(text)}`);
957
+ }
958
+ return text;
959
+ };
960
+
961
+ // An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
962
+ // earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
963
+ // that kit never wrote must survive the round-trip as absence, not as a literal null string.
964
+ const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
965
+
966
+ const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null }) => [
931
967
  '## Provision record',
932
968
  '',
933
- `- slug: ${slug}`,
934
- `- branch: ${branch}`,
935
- ...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${p}`)),
936
- `- node_modules: ${nodeModules}`,
937
- `- vscode-settings: ${vscode}`,
938
- ...(prepared === null ? [] : [`- prepared-tree: ${prepared}`]),
969
+ `- slug: ${recordValue('slug', slug)}`,
970
+ `- branch: ${recordValue('branch', branch)}`,
971
+ ...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
972
+ `- node_modules: ${recordValue('node_modules', nodeModules)}`,
973
+ `- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
974
+ ...optionalField('install', install),
975
+ ...optionalField('shared-queue', sharedQueue),
976
+ ...optionalField('landing', landing),
977
+ ...optionalField('prepared-tree', prepared),
939
978
  '',
979
+ // The rule says "at the absolute path above", so it ships only WITH that path: a record from an
980
+ // earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
981
+ ...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
940
982
  ].join('\n');
941
983
 
942
984
  export const composeHandoffStub = (fields) => [
@@ -965,10 +1007,11 @@ const locateProvisionRecordSection = (text) => {
965
1007
  export const parseProvisionRecord = (text) => {
966
1008
  const section = locateProvisionRecordSection(text);
967
1009
  const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
968
- const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, prepared: null };
1010
+ const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null };
969
1011
  const single = {
970
1012
  slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
971
1013
  'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
1014
+ install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
972
1015
  };
973
1016
  const seen = new Set();
974
1017
  for (const line of scan) {
@@ -988,11 +1031,67 @@ export const parseProvisionRecord = (text) => {
988
1031
  return record;
989
1032
  };
990
1033
 
991
- const pendingHandoffFields = ({ slug, branch }) =>
992
- ({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending' });
1034
+ // Derived from MAIN's root, so the satellite reads an absolute path and a command that already
1035
+ // cd-s back to main neither is derivable from inside the worktree.
1036
+ const orientationFields = ({ root, slug }) => ({
1037
+ sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
1038
+ landing: `${LANDING_FROM_MAIN} — ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
1039
+ });
1040
+
1041
+ // Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
1042
+ // the repo ROOT, so validating them here validates the root itself.
1043
+ const assertRecordValuesComposable = ({ root, slug, branch }) => {
1044
+ recordValue('slug', slug);
1045
+ recordValue('branch', branch);
1046
+ const orientation = orientationFields({ root, slug });
1047
+ recordValue('shared-queue', orientation.sharedQueue);
1048
+ recordValue('landing', orientation.landing);
1049
+ };
1050
+
1051
+ // An `- include:` value also round-trips through the literal `(none)` empty-list sentinel, so that
1052
+ // exact text reads back as NO value at all and cleanup would not recognize the copied path.
1053
+ // (Edge whitespace is refused inside recordValue — the same trim-on-read hazard for every field.)
1054
+ const assertIncludeRoundTrips = (rel) => {
1055
+ recordValue('include', rel);
1056
+ // No empty-rel arm: an include resolving TO the repo root is already refused by the containment
1057
+ // check above (`isInside` excludes the root itself), so a guard here would be unreachable.
1058
+ if (rel === '(none)') {
1059
+ throw stop('--include resolves to the literal "(none)", which the provision record uses as its empty-list sentinel — rename the path before provisioning');
1060
+ }
1061
+ };
1062
+
1063
+ // The shared series index must never reach a satellite — a machine-local copy silently diverges
1064
+ // from what main and every other worktree are writing, which is the whole point of the
1065
+ // read-only-at-the-absolute-path contract. `--include` is the one lane that could smuggle it in,
1066
+ // by naming the file OR any directory that contains it. The compare runs in BOTH spaces: `incReal`
1067
+ // is canonical, so a queue.md (or docs/plans) that is itself a symlink canonicalizes AWAY from the
1068
+ // lexical queue path and would walk straight through a lexical-only compare while copying the very
1069
+ // content the rule fences. Fail closed: ONLY an ABSENT queue path (ENOENT — nothing exists there
1070
+ // to smuggle) falls back to the lexical compare alone; any other realpath failure (EACCES/EIO)
1071
+ // means the canonical identity cannot be established, and a silent fallback would quietly disable
1072
+ // the guard it exists to enforce.
1073
+ const assertIncludeNeverCopiesTheQueue = ({ rootReal, incReal, inc, fs }) => {
1074
+ const queueLexical = join(rootReal, PLANS_REL, QUEUE_BASENAME);
1075
+ const queuePaths = [queueLexical];
1076
+ try {
1077
+ queuePaths.push(fs.realpath(queueLexical));
1078
+ } catch (err) {
1079
+ if (err?.code !== 'ENOENT') {
1080
+ 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`);
1081
+ }
1082
+ }
1083
+ for (const queuePath of queuePaths) {
1084
+ if (incReal === queuePath || isInside(incReal, queuePath)) {
1085
+ 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.`);
1086
+ }
1087
+ }
1088
+ };
1089
+
1090
+ const pendingHandoffFields = ({ root, slug, branch }) =>
1091
+ ({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
993
1092
 
994
1093
  // The stub is written only when ABSENT; the final record surgically replaces the tool section.
995
- const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
1094
+ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) => {
996
1095
  const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
997
1096
  const cur = readFileNoFollow(fs, dst);
998
1097
  if (cur.bytes) {
@@ -1004,7 +1103,7 @@ const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
1004
1103
  }
1005
1104
  guardDst(fs, wtRoot, dirname(dst));
1006
1105
  fs.mkdir(dirname(dst));
1007
- writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ slug, branch })), fs, { stop: (m) => stop(m) });
1106
+ writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
1008
1107
  };
1009
1108
 
1010
1109
  const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
@@ -1070,26 +1169,25 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1070
1169
  report.push(` seeded plan: ${PLANS_REL}/${name}`);
1071
1170
  };
1072
1171
 
1073
- const provisionIncludes = ({ root, rootReal, wtRoot, includes, git, fs, report, copied }) => {
1172
+ // The include set is resolved and identity-checked in runProvision BEFORE `git worktree add` —
1173
+ // against the queue-copy guard and the round-trip guard, and arrives here as {rel, real} pairs.
1174
+ // It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
1175
+ // realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
1176
+ // redirect an include at the shared series index between the check and the copy.
1177
+ const provisionIncludes = ({ rootReal, wtRoot, includeSources, git, fs, report, copied }) => {
1074
1178
  const recorded = [];
1075
- for (const inc of includes) {
1076
- const srcAbs = resolve(root, inc);
1077
- let srcReal;
1078
- try {
1079
- srcReal = fs.realpath(srcAbs);
1080
- } catch {
1081
- throw stop(`--include: not found: ${inc}`);
1082
- }
1083
- if (!isInside(rootReal, srcReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
1084
- const rel = relative(rootReal, srcReal);
1085
- const probeRel = fs.lstat(srcReal).isDirectory() ? `${rel}/` : rel;
1179
+ for (const { rel, real } of includeSources) {
1180
+ // Defence in depth: re-assert the queue-copy prohibition on the canonical path at the POINT OF
1181
+ // USE, so it holds where the copy happens and not only where the path was first checked.
1182
+ assertIncludeNeverCopiesTheQueue({ rootReal, incReal: real, inc: rel, fs });
1183
+ const probeRel = fs.lstat(real).isDirectory() ? `${rel}/` : rel;
1086
1184
  if (!checkIgnored(git, probeRel, wtRoot)) {
1087
1185
  throw stop(
1088
1186
  `--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
1089
1187
  'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
1090
1188
  );
1091
1189
  }
1092
- copyNode({ srcAbs: srcReal, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
1190
+ copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
1093
1191
  recorded.push(rel);
1094
1192
  }
1095
1193
  return recorded;
@@ -1152,6 +1250,20 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1152
1250
  return { command, instruction: command };
1153
1251
  };
1154
1252
 
1253
+ // What the RECORD states about installing — the resolved posture for THIS worktree (the runnable
1254
+ // isolated-install command, or the honest by-hand advice), never a lane-dependent hint. Probed on
1255
+ // the LIVE worktree after the node_modules step: a plain `cd … && install` through a SYMLINKED
1256
+ // node_modules writes into MAIN — never presented as isolated; the symlink case records the
1257
+ // unlink-first form instead (the same contract the --install lane already prints).
1258
+ const resolveInstallPosture = ({ root, wtRoot, fs }) => {
1259
+ const advice = resolveInstallAdvice({ root, wtRoot, fs });
1260
+ const nmPath = join(wtRoot, 'node_modules');
1261
+ const nm = lstatNoFollow(fs.lstat, nmPath);
1262
+ if (nm === null || !nm.isSymbolicLink()) return advice.instruction;
1263
+ const separator = advice.command === null ? ' — ' : ' && ';
1264
+ 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}`;
1265
+ };
1266
+
1155
1267
  const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, report }) => {
1156
1268
  const install = resolveInstallAdvice({ root, wtRoot, fs });
1157
1269
  if (installFlag) {
@@ -1323,6 +1435,11 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1323
1435
  const branch = flags.branch ?? `${DEFAULT_BRANCH_PREFIX}${slug}`;
1324
1436
  if (flags.plan == null) throw usageStop('provision requires --plan <path> (the ONE feature plan the worktree starts with)');
1325
1437
  const { root, commonDir } = resolveRoots(cwd, git);
1438
+ // Composing the record is the LAST step of provision, so a refusal there would leave a created
1439
+ // worktree with no handoff — which neither --resume nor `cleanup --abandon` can recover, because
1440
+ // both bind on the handoff identity. Every value the record will carry is therefore checked HERE,
1441
+ // before the first fs read and long before `git worktree add`.
1442
+ assertRecordValuesComposable({ root, slug, branch });
1326
1443
  const rootReal = fs.realpath(root);
1327
1444
  const config = loadWorktreesConfig(root, deps);
1328
1445
  const targetAbs = resolveTargetDir({ root, slug, dirFlag: flags.dir ?? null, parentDir: config.parentDir });
@@ -1356,9 +1473,16 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1356
1473
  throw stop(`--include: not found: ${inc}`);
1357
1474
  }
1358
1475
  if (!isInside(rootReal, incReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
1359
- includeSources.push({ rel: relative(rootReal, incReal), real: incReal });
1476
+ assertIncludeNeverCopiesTheQueue({ rootReal, incReal, inc, fs });
1477
+ const rel = relative(rootReal, incReal);
1478
+ assertIncludeRoundTrips(rel);
1479
+ includeSources.push({ rel, real: incReal });
1360
1480
  }
1361
1481
  assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
1482
+ // The TARGET path reaches the record too — the `install` field embeds the worktree dir — and it
1483
+ // is only known here, after --dir/parentDir resolution. Validating it now keeps the whole record
1484
+ // composable BEFORE `git worktree add`, which is the point of every check above.
1485
+ recordValue('target-dir', targetReal);
1362
1486
 
1363
1487
  const probeDir = resolveProbeDir(dirname(targetReal), deps);
1364
1488
  // the probe itself is a create+delete write — on resume it runs only AFTER every identity check
@@ -1399,7 +1523,7 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1399
1523
  // any failure past this point leaves a real created worktree — the error must say so and
1400
1524
  // hand back the exact finish command, never just the local cause
1401
1525
  try {
1402
- return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, git, deps, fs, report, log });
1526
+ return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, git, deps, fs, report, log });
1403
1527
  } catch (err) {
1404
1528
  if (!flags.resume && err?.message) {
1405
1529
  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).`;
@@ -1408,8 +1532,8 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1408
1532
  }
1409
1533
  };
1410
1534
 
1411
- const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, git, deps, fs, report, log }) => {
1412
- writeHandoffStubIfAbsent({ wtRoot: targetPath, slug, branch, fs, report });
1535
+ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, git, deps, fs, report, log }) => {
1536
+ writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
1413
1537
 
1414
1538
  const copied = new Set();
1415
1539
  report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
@@ -1419,7 +1543,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1419
1543
  }
1420
1544
 
1421
1545
  writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
1422
- const includesRecorded = provisionIncludes({ root, rootReal, wtRoot: targetPath, includes: flags.include, git, fs, report, copied });
1546
+ const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, git, fs, report, copied });
1423
1547
  const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, git, fs, report });
1424
1548
  const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
1425
1549
 
@@ -1429,7 +1553,15 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1429
1553
  wtRoot: targetPath,
1430
1554
  slug,
1431
1555
  branch,
1432
- fields: { slug, branch, includes: includesRecorded, nodeModules: nodeModulesMode, vscode: vscodeMode },
1556
+ fields: {
1557
+ slug,
1558
+ branch,
1559
+ includes: includesRecorded,
1560
+ nodeModules: nodeModulesMode,
1561
+ vscode: vscodeMode,
1562
+ install: resolveInstallPosture({ root, wtRoot: targetPath, fs }),
1563
+ ...orientationFields({ root, slug }),
1564
+ },
1433
1565
  fs,
1434
1566
  report,
1435
1567
  });