@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 +30 -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 +9 -1
- package/tools/worktrees.mjs +163 -31
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.
|
|
6
|
+
version: '3.5.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.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
|
|
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 } 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,
|
|
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.
|
package/tools/worktrees.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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: {
|
|
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
|
});
|