@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 +62 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +19 -2
- package/tools/doc-parity.mjs +12 -2
- package/tools/worktrees.mjs +277 -38
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.
|
|
6
|
+
version: '3.6.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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
|
|
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
|
|
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.
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -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,
|
|
146
|
-
|
|
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
|
|
package/tools/worktrees.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
1423
|
-
|
|
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: {
|
|
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
|
});
|