@sabaiway/agent-workflow-kit 3.4.0 → 3.6.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,68 @@ 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.6.0 — the dependency-free install posture: a proof, never a default (AD-067)
8
+
9
+ On a provably dependency-free project the provision record and the default-lane report no longer
10
+ print a generic `npm install` hint: both state
11
+ `no install needed — the project declares no dependencies` (recorded node_modules mode
12
+ `no-dependencies`), and a `doc-parity` binding pins the posture string to the mode doc.
13
+
14
+ The verdict is a PROOF granted only on evidence the tool actually read — the WORKTREE'S OWN LIVE
15
+ checkout: what an install run there would actually read, exactly HEAD at provision time and the
16
+ satellite's own committed state on `--resume`, never MAIN's mutable working tree (which can
17
+ diverge from what the satellite actually holds; real-git tests pin both refresh directions).
18
+ Everything the tool cannot vouch for leaves the posture UNKNOWN with the honest advice kept — the
19
+ fail-safe direction, because a false "nothing to install" is worse than a redundant hint:
20
+
21
+ - a `workspaces` field of ANY shape, outright — a workspace install materializes member links and
22
+ `.bin` shims even with zero dependencies, so a workspace tree is never provably install-free;
23
+ - an external workspace manifest beside the root (`pnpm-workspace.yaml`/`.yml`, `lerna.json`);
24
+ - a malformed manifest, dependency field, or `scripts` shape (three-valued verdicts — malformed is
25
+ `unknown`, never "none"; a lifecycle key with a non-string value fails closed);
26
+ - an install-lifecycle script — dependency-free is NOT install-free: the CLOSED, test-pinned set
27
+ is the npm lifecycle (including the deprecated `prepublish`) plus `pnpm:devPreinstall`; a
28
+ native-addon manifest (`binding.gyp`) is a mandatory install too.
29
+
30
+ The posture COMPOSES with the shipped record contract, and LIVE STATE WINS: a node already at the
31
+ worktree — a directory, or a symlink left by an earlier provision, even dangling — records
32
+ `present` before any MAIN-state early return, the AD-065 symlink unlink-first arm is untouched for
33
+ every non-proven project, and `--install` remains an explicit request that is always answered with
34
+ the isolated-install command while the record still states the posture.
35
+
36
+ Third safe slice of the deferred parallel-track work (AD-063); node_modules ownership and
37
+ resume-verify semantics stay separate redesigns.
38
+
39
+ ## 3.5.0 — the provision record orients a fresh satellite session (AD-065)
40
+
41
+ The worktrees provision record was an identity stub; a fresh satellite session could not derive
42
+ three facts from its own checkout. The record now carries all three, and the mode doc is pinned to
43
+ the live constants:
44
+
45
+ - **`shared-queue`** — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the verbatim
46
+ rule: the series index is SHARED, read it at that path, never copy it (a machine-local copy
47
+ silently diverges); findings ride the handoff record and main appends them to the index. The rule
48
+ ships only WITH the path — a record from an earlier kit carries neither. `--include` refuses to
49
+ copy the index (or any directory containing it) into the worktree, at preflight AND re-asserted
50
+ at the point of copy on the canonical pair resolved before `git worktree add`.
51
+ - **`landing`** — landing runs FROM MAIN, never from the worktree, with the runnable
52
+ `… land <slug> --prepare` command already `cd`-ing back to main.
53
+ - **`install`** — the resolved install posture: the runnable isolated-install command, or the
54
+ honest by-hand advice when the package manager is ambiguous.
55
+
56
+ The record is line-oriented and parsed back for IDENTITY, so it now REFUSES any value it cannot
57
+ round-trip: control bytes (an injected newline forges a field line or an `## …` heading that
58
+ truncates the section) and U+2028/U+2029 (which write fine and are then silently DROPPED on read —
59
+ a lost field with no error) are a typed STOP, never sanitized. Every value the record will carry is
60
+ validated BEFORE any git mutation — a refusal at compose time would strand a created worktree with
61
+ no handoff, which neither `--resume` nor `cleanup --abandon` can recover. Optional fields are
62
+ omitted when absent, never rendered as `null`, so a record written by an earlier kit survives a
63
+ refresh. Two new `doc-parity` bindings (`queue-shared-rule`, `landing-from-main`) pin
64
+ `references/modes/worktrees.md` to the emitted strings.
65
+
66
+ Second safe slice extracted from the deferred parallel-track work (AD-063) — no
67
+ node_modules-ownership coupling; the provable dependency-free install posture is the next slice.
68
+
7
69
  ## 3.4.0 — review-state names a latent arm on a clean-tree PASS (AD-064)
8
70
 
9
71
  `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.6.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.6.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.6.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. 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
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, NO_DEPENDENCIES_POSTURE } 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,13 @@ 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, the landing-from-main fact, and the no-dependencies install posture were
103
+ // prose-only bars a doc could silently drop, so all are pinned to the live strings the record
104
+ // actually carries.
105
+ valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
106
+ valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
107
+ valueBinding('no-dependencies-posture', NO_DEPENDENCIES_POSTURE, NO_DEPENDENCIES_POSTURE, [WORKTREES_DOC]),
99
108
  ].map((b) => Object.freeze(b)));
100
109
 
101
110
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -142,8 +151,9 @@ Usage:
142
151
  A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
143
152
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
144
153
  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
146
- references/modes/*.md contract must carry, and
154
+ degrade token, the review-state clean-tree latent-arm notice, and the worktrees provision-record
155
+ orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture) — to
156
+ the exact token its references/modes/*.md contract must carry, and
147
157
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
148
158
  or an absent token FAILS CLOSED.
149
159
 
@@ -927,16 +927,62 @@ 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
+ export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declares no dependencies';
937
+ // The recorded node_modules mode for that same verdict: provision neither advised nor created a
938
+ // node_modules for this worktree.
939
+ export const NODE_MODULES_NONE = 'no-dependencies';
940
+
941
+ // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
942
+ // is refused rather than written: a newline spills a second line the parser reads as a real field
943
+ // (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
944
+ // the whole section). Values reach here from the repo ROOT path and from --include, both of which
945
+ // may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
946
+ // record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
947
+ // not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
948
+ // a lost field with no error, which is the one outcome this codebase never allows.
949
+ // Fail closed: refuse to write, never sanitize silently.
950
+ const RECORD_CONTROL_BYTE = /[\u0000-\u001F\u007F\u2028\u2029]/;
951
+ const recordValue = (name, value) => {
952
+ const text = String(value);
953
+ if (RECORD_CONTROL_BYTE.test(text)) {
954
+ 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`);
955
+ }
956
+ // The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
957
+ // — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
958
+ // back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
959
+ if (text !== text.trim()) {
960
+ 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)}`);
961
+ }
962
+ return text;
963
+ };
964
+
965
+ // An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
966
+ // earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
967
+ // that kit never wrote must survive the round-trip as absence, not as a literal null string.
968
+ const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
969
+
970
+ const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null }) => [
931
971
  '## Provision record',
932
972
  '',
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}`]),
973
+ `- slug: ${recordValue('slug', slug)}`,
974
+ `- branch: ${recordValue('branch', branch)}`,
975
+ ...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
976
+ `- node_modules: ${recordValue('node_modules', nodeModules)}`,
977
+ `- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
978
+ ...optionalField('install', install),
979
+ ...optionalField('shared-queue', sharedQueue),
980
+ ...optionalField('landing', landing),
981
+ ...optionalField('prepared-tree', prepared),
939
982
  '',
983
+ // The rule says "at the absolute path above", so it ships only WITH that path: a record from an
984
+ // earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
985
+ ...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
940
986
  ].join('\n');
941
987
 
942
988
  export const composeHandoffStub = (fields) => [
@@ -965,10 +1011,11 @@ const locateProvisionRecordSection = (text) => {
965
1011
  export const parseProvisionRecord = (text) => {
966
1012
  const section = locateProvisionRecordSection(text);
967
1013
  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 };
1014
+ const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null };
969
1015
  const single = {
970
1016
  slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
971
1017
  'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
1018
+ install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
972
1019
  };
973
1020
  const seen = new Set();
974
1021
  for (const line of scan) {
@@ -988,11 +1035,67 @@ export const parseProvisionRecord = (text) => {
988
1035
  return record;
989
1036
  };
990
1037
 
991
- const pendingHandoffFields = ({ slug, branch }) =>
992
- ({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending' });
1038
+ // Derived from MAIN's root, so the satellite reads an absolute path and a command that already
1039
+ // cd-s back to main neither is derivable from inside the worktree.
1040
+ const orientationFields = ({ root, slug }) => ({
1041
+ sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
1042
+ landing: `${LANDING_FROM_MAIN} — ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
1043
+ });
1044
+
1045
+ // Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
1046
+ // the repo ROOT, so validating them here validates the root itself.
1047
+ const assertRecordValuesComposable = ({ root, slug, branch }) => {
1048
+ recordValue('slug', slug);
1049
+ recordValue('branch', branch);
1050
+ const orientation = orientationFields({ root, slug });
1051
+ recordValue('shared-queue', orientation.sharedQueue);
1052
+ recordValue('landing', orientation.landing);
1053
+ };
1054
+
1055
+ // An `- include:` value also round-trips through the literal `(none)` empty-list sentinel, so that
1056
+ // exact text reads back as NO value at all and cleanup would not recognize the copied path.
1057
+ // (Edge whitespace is refused inside recordValue — the same trim-on-read hazard for every field.)
1058
+ const assertIncludeRoundTrips = (rel) => {
1059
+ recordValue('include', rel);
1060
+ // No empty-rel arm: an include resolving TO the repo root is already refused by the containment
1061
+ // check above (`isInside` excludes the root itself), so a guard here would be unreachable.
1062
+ if (rel === '(none)') {
1063
+ throw stop('--include resolves to the literal "(none)", which the provision record uses as its empty-list sentinel — rename the path before provisioning');
1064
+ }
1065
+ };
1066
+
1067
+ // The shared series index must never reach a satellite — a machine-local copy silently diverges
1068
+ // from what main and every other worktree are writing, which is the whole point of the
1069
+ // read-only-at-the-absolute-path contract. `--include` is the one lane that could smuggle it in,
1070
+ // by naming the file OR any directory that contains it. The compare runs in BOTH spaces: `incReal`
1071
+ // is canonical, so a queue.md (or docs/plans) that is itself a symlink canonicalizes AWAY from the
1072
+ // lexical queue path and would walk straight through a lexical-only compare while copying the very
1073
+ // content the rule fences. Fail closed: ONLY an ABSENT queue path (ENOENT — nothing exists there
1074
+ // to smuggle) falls back to the lexical compare alone; any other realpath failure (EACCES/EIO)
1075
+ // means the canonical identity cannot be established, and a silent fallback would quietly disable
1076
+ // the guard it exists to enforce.
1077
+ const assertIncludeNeverCopiesTheQueue = ({ rootReal, incReal, inc, fs }) => {
1078
+ const queueLexical = join(rootReal, PLANS_REL, QUEUE_BASENAME);
1079
+ const queuePaths = [queueLexical];
1080
+ try {
1081
+ queuePaths.push(fs.realpath(queueLexical));
1082
+ } catch (err) {
1083
+ if (err?.code !== 'ENOENT') {
1084
+ 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`);
1085
+ }
1086
+ }
1087
+ for (const queuePath of queuePaths) {
1088
+ if (incReal === queuePath || isInside(incReal, queuePath)) {
1089
+ 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.`);
1090
+ }
1091
+ }
1092
+ };
1093
+
1094
+ const pendingHandoffFields = ({ root, slug, branch }) =>
1095
+ ({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
993
1096
 
994
1097
  // The stub is written only when ABSENT; the final record surgically replaces the tool section.
995
- const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
1098
+ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) => {
996
1099
  const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
997
1100
  const cur = readFileNoFollow(fs, dst);
998
1101
  if (cur.bytes) {
@@ -1004,7 +1107,7 @@ const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
1004
1107
  }
1005
1108
  guardDst(fs, wtRoot, dirname(dst));
1006
1109
  fs.mkdir(dirname(dst));
1007
- writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ slug, branch })), fs, { stop: (m) => stop(m) });
1110
+ writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
1008
1111
  };
1009
1112
 
1010
1113
  const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
@@ -1070,26 +1173,25 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1070
1173
  report.push(` seeded plan: ${PLANS_REL}/${name}`);
1071
1174
  };
1072
1175
 
1073
- const provisionIncludes = ({ root, rootReal, wtRoot, includes, git, fs, report, copied }) => {
1176
+ // The include set is resolved and identity-checked in runProvision BEFORE `git worktree add` —
1177
+ // against the queue-copy guard and the round-trip guard, and arrives here as {rel, real} pairs.
1178
+ // It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
1179
+ // realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
1180
+ // redirect an include at the shared series index between the check and the copy.
1181
+ const provisionIncludes = ({ rootReal, wtRoot, includeSources, git, fs, report, copied }) => {
1074
1182
  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;
1183
+ for (const { rel, real } of includeSources) {
1184
+ // Defence in depth: re-assert the queue-copy prohibition on the canonical path at the POINT OF
1185
+ // 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
+ const probeRel = fs.lstat(real).isDirectory() ? `${rel}/` : rel;
1086
1188
  if (!checkIgnored(git, probeRel, wtRoot)) {
1087
1189
  throw stop(
1088
1190
  `--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
1089
1191
  'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
1090
1192
  );
1091
1193
  }
1092
- copyNode({ srcAbs: srcReal, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
1194
+ copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
1093
1195
  recorded.push(rel);
1094
1196
  }
1095
1197
  return recorded;
@@ -1152,7 +1254,110 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1152
1254
  return { command, instruction: command };
1153
1255
  };
1154
1256
 
1155
- const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, report }) => {
1257
+ // Exported and frozen so the closed sets are test-pinned in BOTH directions (a member can neither
1258
+ // be dropped nor smuggled in without failing the pin).
1259
+ export const DEPENDENCY_FIELDS = Object.freeze(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']);
1260
+ // Workspace declarations this tool cannot read. Their presence makes the dependency inventory
1261
+ // unknowable from package.json alone, so it is never proof.
1262
+ export const EXTERNAL_WORKSPACE_MANIFESTS = Object.freeze(['pnpm-workspace.yaml', 'pnpm-workspace.yml', 'lerna.json']);
1263
+
1264
+ const readPackageJson = (fs, path) => {
1265
+ const file = readFileNoFollow(fs, path);
1266
+ if (!file.bytes) return null;
1267
+ try {
1268
+ const parsed = JSON.parse(String(file.bytes));
1269
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
1270
+ } catch {
1271
+ return null;
1272
+ }
1273
+ };
1274
+
1275
+ // A dependency field is read THREE ways, never two: 'has' (a non-empty plain object), 'none' (the
1276
+ // field is absent, or an empty plain object), and 'unknown' for every other shape — a string, an
1277
+ // array, null. A malformed manifest is evidence of NOTHING, so it must never read as "none".
1278
+ const dependencyFieldsVerdict = (pkg) => {
1279
+ let verdict = 'none';
1280
+ for (const field of DEPENDENCY_FIELDS) {
1281
+ if (!Object.hasOwn(pkg, field)) continue;
1282
+ const value = pkg[field];
1283
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return 'unknown';
1284
+ if (Object.keys(value).length > 0) verdict = 'has';
1285
+ }
1286
+ return verdict;
1287
+ };
1288
+
1289
+ // A project with NO declared dependencies can still REQUIRE an install: a package-manager install
1290
+ // runs the WHOLE install-lifecycle set — the npm set (including the deprecated `prepublish`, which
1291
+ // historically fired on install) plus pnpm's `pnpm:devPreinstall` — so a manifest declaring any of
1292
+ // them, ROOT or member, needs an install even with no dependencies. dependency-free is NOT
1293
+ // install-free. The list is CLOSED and stated: a hook from a manager this tool has never heard of is
1294
+ // the honest residual of a closed-world check, the same shape as EXTERNAL_WORKSPACE_MANIFESTS.
1295
+ export const INSTALL_LIFECYCLE_SCRIPTS = Object.freeze(['preinstall', 'install', 'postinstall', 'prepare', 'preprepare', 'postprepare', 'prepublish', 'pnpm:devPreinstall']);
1296
+ // Fail-closed like the dependency check: a `scripts` field of the wrong SHAPE, or a lifecycle key
1297
+ // present with a NON-STRING value, is 'unknown' (never "no hook"). An empty-string value is a no-op,
1298
+ // not a trigger; 'has' only when a lifecycle key holds a non-empty command string.
1299
+ const installHookVerdict = (pkg) => {
1300
+ if (!Object.hasOwn(pkg, 'scripts')) return 'none';
1301
+ const scripts = pkg.scripts;
1302
+ if (scripts === null || typeof scripts !== 'object' || Array.isArray(scripts)) return 'unknown';
1303
+ let verdict = 'none';
1304
+ for (const name of INSTALL_LIFECYCLE_SCRIPTS) {
1305
+ if (!Object.hasOwn(scripts, name)) continue;
1306
+ if (typeof scripts[name] !== 'string') return 'unknown';
1307
+ if (scripts[name].length > 0) verdict = 'has';
1308
+ }
1309
+ return verdict;
1310
+ };
1311
+ // A native-addon manifest triggers an implicit `node-gyp rebuild` on install even with an empty
1312
+ // scripts block, so its presence — root or member — is a mandatory install, never proof of none.
1313
+ const declaresNativeBuild = (fs, dir) => lstatNoFollow(fs.lstat, join(dir, 'binding.gyp')) !== null;
1314
+
1315
+ // PROVABLY dependency-free, or nothing — read from the WORKTREE'S OWN LIVE CHECKOUT, never from
1316
+ // MAIN's mutable working tree: the evidence is what an install run in THIS worktree would actually
1317
+ // read. At provision time that is exactly HEAD; on --resume it follows the session's own edits, in
1318
+ // both directions (gained dependencies revoke the proof, shed ones grant it) — the same live lane
1319
+ // as the node_modules symlink probe. A dirty main manifest must neither grant nor revoke a verdict
1320
+ // about content it does not describe. A `workspaces` field of ANY shape
1321
+ // is UNKNOWN outright — a workspace install materializes member links and `.bin` shims even with
1322
+ // zero dependencies, so a workspace tree is never provably install-free. Everything else the tool
1323
+ // cannot enumerate — an absent/unparseable package.json, a malformed dependency or scripts field,
1324
+ // an install-lifecycle script, a native-addon manifest (binding.gyp), an external workspace
1325
+ // manifest — leaves the posture UNKNOWN, and unknown keeps the existing install advice: a false
1326
+ // "nothing to install" is worse than a redundant hint.
1327
+ const declaresNoDependencies = ({ wtRoot, fs }) => {
1328
+ const pkg = readPackageJson(fs, join(wtRoot, 'package.json'));
1329
+ if (pkg === null || dependencyFieldsVerdict(pkg) !== 'none' || installHookVerdict(pkg) !== 'none') return false;
1330
+ if (Object.hasOwn(pkg, 'workspaces')) return false;
1331
+ if (declaresNativeBuild(fs, wtRoot)) return false;
1332
+ // A workspace set can be declared OUTSIDE package.json too. The list is CLOSED and stated: an
1333
+ // exotic third manifest this tool has never heard of would still read as proof, which is the
1334
+ // honest residual of a closed-world check (the same shape as INSTALL_LIFECYCLE_SCRIPTS).
1335
+ for (const manifest of EXTERNAL_WORKSPACE_MANIFESTS) {
1336
+ if (lstatNoFollow(fs.lstat, join(wtRoot, manifest)) !== null) return false;
1337
+ }
1338
+ return true;
1339
+ };
1340
+
1341
+ // What the RECORD states about installing — the resolved posture for THIS worktree (the runnable
1342
+ // isolated-install command, or the honest by-hand advice), never a lane-dependent hint. Probed on
1343
+ // the LIVE worktree after the node_modules step, and LIVE STATE WINS: a symlinked node_modules
1344
+ // records the unlink-first form even on a dependency-free checkout (`--resume` can meet a symlink
1345
+ // an earlier provision left — an install through it writes into MAIN, and the posture must never
1346
+ // hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
1347
+ // "nothing to install" must not ride an install instruction.
1348
+ const resolveInstallPosture = ({ root, wtRoot, dependencyFree, fs }) => {
1349
+ const nmPath = join(wtRoot, 'node_modules');
1350
+ const nm = lstatNoFollow(fs.lstat, nmPath);
1351
+ if (nm !== null && nm.isSymbolicLink()) {
1352
+ const advice = resolveInstallAdvice({ root, wtRoot, fs });
1353
+ const separator = advice.command === null ? ' — ' : ' && ';
1354
+ 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}`;
1355
+ }
1356
+ if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
1357
+ return resolveInstallAdvice({ root, wtRoot, fs }).instruction;
1358
+ };
1359
+
1360
+ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
1156
1361
  const install = resolveInstallAdvice({ root, wtRoot, fs });
1157
1362
  if (installFlag) {
1158
1363
  const dst = join(wtRoot, 'node_modules');
@@ -1168,6 +1373,22 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, re
1168
1373
  : ` node_modules: install it yourself (zero spawn): ${install.instruction}`);
1169
1374
  return 'install-printed';
1170
1375
  }
1376
+ // LIVE STATE WINS the whole default lane: a node already at the worktree — a directory, or a
1377
+ // symlink an earlier provision left, even dangling — is what the record states; reporting
1378
+ // MAIN's state (`absent`) beside an existing node would contradict record.install.
1379
+ const dst = join(wtRoot, 'node_modules');
1380
+ if (lstatNoFollow(fs.lstat, dst) !== null) {
1381
+ report.push(' node_modules: already present in the worktree');
1382
+ return 'present';
1383
+ }
1384
+ // Only then may a PROVEN dependency-free checkout short-circuit the rest. Composing the posture
1385
+ // into one of the arms below instead produced a self-contradicting line ("after your own
1386
+ // install there, re-run --resume, or: no install needed"). `--install` is untouched above —
1387
+ // that request was explicit.
1388
+ if (dependencyFree) {
1389
+ report.push(` node_modules: ${NO_DEPENDENCIES_POSTURE}`);
1390
+ return NODE_MODULES_NONE;
1391
+ }
1171
1392
  const mainNm = join(root, 'node_modules');
1172
1393
  const node = classifyNodeNoFollow(mainNm, fs);
1173
1394
  if (node.kind === 'absent') {
@@ -1201,11 +1422,6 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, re
1201
1422
  report.push(` node_modules: main's resolves outside the repo — not symlinked; ${install.instruction}`);
1202
1423
  return 'outside-repo';
1203
1424
  }
1204
- const dst = join(wtRoot, 'node_modules');
1205
- if (lstatNoFollow(fs.lstat, dst) !== null) {
1206
- report.push(' node_modules: already present in the worktree');
1207
- return 'present';
1208
- }
1209
1425
  guardDst(fs, wtRoot, dst);
1210
1426
  try {
1211
1427
  fs.symlink(mainNm, dst);
@@ -1323,6 +1539,11 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1323
1539
  const branch = flags.branch ?? `${DEFAULT_BRANCH_PREFIX}${slug}`;
1324
1540
  if (flags.plan == null) throw usageStop('provision requires --plan <path> (the ONE feature plan the worktree starts with)');
1325
1541
  const { root, commonDir } = resolveRoots(cwd, git);
1542
+ // Composing the record is the LAST step of provision, so a refusal there would leave a created
1543
+ // worktree with no handoff — which neither --resume nor `cleanup --abandon` can recover, because
1544
+ // both bind on the handoff identity. Every value the record will carry is therefore checked HERE,
1545
+ // before the first fs read and long before `git worktree add`.
1546
+ assertRecordValuesComposable({ root, slug, branch });
1326
1547
  const rootReal = fs.realpath(root);
1327
1548
  const config = loadWorktreesConfig(root, deps);
1328
1549
  const targetAbs = resolveTargetDir({ root, slug, dirFlag: flags.dir ?? null, parentDir: config.parentDir });
@@ -1356,9 +1577,16 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1356
1577
  throw stop(`--include: not found: ${inc}`);
1357
1578
  }
1358
1579
  if (!isInside(rootReal, incReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
1359
- includeSources.push({ rel: relative(rootReal, incReal), real: incReal });
1580
+ assertIncludeNeverCopiesTheQueue({ rootReal, incReal, inc, fs });
1581
+ const rel = relative(rootReal, incReal);
1582
+ assertIncludeRoundTrips(rel);
1583
+ includeSources.push({ rel, real: incReal });
1360
1584
  }
1361
1585
  assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
1586
+ // The TARGET path reaches the record too — the `install` field embeds the worktree dir — and it
1587
+ // is only known here, after --dir/parentDir resolution. Validating it now keeps the whole record
1588
+ // composable BEFORE `git worktree add`, which is the point of every check above.
1589
+ recordValue('target-dir', targetReal);
1362
1590
 
1363
1591
  const probeDir = resolveProbeDir(dirname(targetReal), deps);
1364
1592
  // the probe itself is a create+delete write — on resume it runs only AFTER every identity check
@@ -1399,7 +1627,7 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1399
1627
  // any failure past this point leaves a real created worktree — the error must say so and
1400
1628
  // hand back the exact finish command, never just the local cause
1401
1629
  try {
1402
- return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, git, deps, fs, report, log });
1630
+ return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, git, deps, fs, report, log });
1403
1631
  } catch (err) {
1404
1632
  if (!flags.resume && err?.message) {
1405
1633
  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 +1636,8 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1408
1636
  }
1409
1637
  };
1410
1638
 
1411
- const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, git, deps, fs, report, log }) => {
1412
- writeHandoffStubIfAbsent({ wtRoot: targetPath, slug, branch, fs, report });
1639
+ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, git, deps, fs, report, log }) => {
1640
+ writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
1413
1641
 
1414
1642
  const copied = new Set();
1415
1643
  report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
@@ -1419,8 +1647,11 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1419
1647
  }
1420
1648
 
1421
1649
  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 });
1423
- const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, git, fs, report });
1650
+ const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, git, fs, report, copied });
1651
+ // Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
1652
+ // lane and the record must state the SAME verdict.
1653
+ const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
1654
+ const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report });
1424
1655
  const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
1425
1656
 
1426
1657
  rebasePins({ root, wtRoot: targetPath, git, fs, report });
@@ -1429,7 +1660,15 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1429
1660
  wtRoot: targetPath,
1430
1661
  slug,
1431
1662
  branch,
1432
- fields: { slug, branch, includes: includesRecorded, nodeModules: nodeModulesMode, vscode: vscodeMode },
1663
+ fields: {
1664
+ slug,
1665
+ branch,
1666
+ includes: includesRecorded,
1667
+ nodeModules: nodeModulesMode,
1668
+ vscode: vscodeMode,
1669
+ install: resolveInstallPosture({ root, wtRoot: targetPath, dependencyFree, fs }),
1670
+ ...orientationFields({ root, slug }),
1671
+ },
1433
1672
  fs,
1434
1673
  report,
1435
1674
  });