@sabaiway/agent-workflow-kit 3.9.0 → 3.11.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 +58 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +7 -1
- package/tools/worktrees.mjs +273 -29
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,64 @@ 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.11.0 — the record attests only a verified provision; tracked plans-chain paths refuse (AD-072)
|
|
8
|
+
|
|
9
|
+
Two provision honesty fixes from the converged resume-verify design (its slice R1; the
|
|
10
|
+
tolerance flip is the next slice):
|
|
11
|
+
|
|
12
|
+
- **The provision record is refreshed LAST — after the in-flight-plan check and the
|
|
13
|
+
post-provision verify, in BOTH lanes.** The record now attests only a VERIFIED provision: a
|
|
14
|
+
first provision that fails after the stub write leaves the STUB (identity still binds from
|
|
15
|
+
it — `--resume` completes as before; a pre-write refusal leaves no handoff at all), and a
|
|
16
|
+
failed resume leaves the PRIOR record bytes byte-exact. Previously a failed
|
|
17
|
+
run left a freshly refreshed record (the 3.6.0 stated residual — now eliminated). A refresh
|
|
18
|
+
failure after a clean verify keeps the worktree and names the exact re-run command in both
|
|
19
|
+
lanes, preserving the original failure cause.
|
|
20
|
+
- **A TRACKED plans-chain path — the handoff or the seeded plan — refuses fail-closed in both
|
|
21
|
+
lanes.** Its drift is undeliverable (`land` categorically refuses docs/plans, and satellite
|
|
22
|
+
commits are not a lane), so tolerating it would strand the worktree. A fresh provision proves
|
|
23
|
+
both paths untracked at ONE captured commit — the same OID the branch is cut from — and
|
|
24
|
+
re-probes the new worktree's index and branch HEAD before the first write, catching
|
|
25
|
+
post-checkout hooks that force-add or even COMMIT them (a committed add leaves the tree
|
|
26
|
+
clean, invisible to the clean-tree verify). `--resume` probes the branch HEAD tree
|
|
27
|
+
unconditionally plus the live index before reading the handoff identity.
|
|
28
|
+
- **Recovery is surgical and honest.** An index-only entry names a pathspec-LITERAL
|
|
29
|
+
`git rm --cached` (a magic-shaped seed name never touches a pattern-matching neighbor) plus
|
|
30
|
+
the ignore-rule restoration when the worktree's live rules lost it; a branch-HEAD-tracked
|
|
31
|
+
file names salvage FIRST, then consented `cleanup --abandon` ONLY where the record identity
|
|
32
|
+
binds (a malformed record routes to the shipped record recovery — abandon is never promised);
|
|
33
|
+
the post-add hook STOP names inspect/salvage, then plain-git removal (never `cleanup
|
|
34
|
+
--abandon` — no handoff identity exists yet); an irregular entry (tree / symlink / gitlink)
|
|
35
|
+
fails closed with NO recovery command. Recovery never routes through the landing lane.
|
|
36
|
+
- **The mode doc states both contracts verbatim** (the corrected verify-then-refresh order and
|
|
37
|
+
the plans-chain refusal), each pinned by a named test. Resume tolerance is UNCHANGED this
|
|
38
|
+
release: the blanket clean-tree verify stays, pinned byte-exact.
|
|
39
|
+
|
|
40
|
+
## 3.10.0 — install advice reads the worktree checkout (AD-071)
|
|
41
|
+
|
|
42
|
+
The install advice picked its package manager from MAIN's `package.json` and lockfiles while the
|
|
43
|
+
printed command targets the SATELLITE (`cd <worktree> && <manager> install`) — so a dirty MAIN
|
|
44
|
+
lockfile could push the advice into ambiguity or the wrong manager, and a MAIN advanced past the
|
|
45
|
+
satellite's base steered a satellite it no longer describes. The evidence and the target are now
|
|
46
|
+
the SAME tree:
|
|
47
|
+
|
|
48
|
+
- **Manager evidence reads the worktree's own live checkout** — the `packageManager` field and
|
|
49
|
+
the lockfile scan, the same live lane the dependency-free proof (3.6.0) and the node_modules
|
|
50
|
+
symlink probe already use. MAIN's mutable working tree never steers manager selection; MAIN
|
|
51
|
+
state legitimately steers only the symlink lane (the shared-cache link and its unlink-first
|
|
52
|
+
posture), unchanged.
|
|
53
|
+
- **The contract ships in the worktrees mode doc as one pinned sentence** (a named test locks
|
|
54
|
+
the exact wording): all manifest/lockfile install evidence is read from the worktree's own
|
|
55
|
+
live files at the moment the posture is resolved — deliberately NOT «exactly HEAD», because a
|
|
56
|
+
`post-checkout` hook can shape the checkout before the posture is resolved.
|
|
57
|
+
- **No resume-tolerance smuggling.** The shipped clean-tree `--resume` STOP is pinned byte-exact
|
|
58
|
+
by a real-git test; dirty-resume semantics remain a queued redesign. A satellite behind an
|
|
59
|
+
advanced MAIN now gets advice about ITSELF — pinned by a real-git test where MAIN's lockfile
|
|
60
|
+
advance does not re-steer the satellite's refreshed record.
|
|
61
|
+
- **Honest residual:** a manager signal living only in MAIN's working tree (e.g. an ignored,
|
|
62
|
+
uncommitted lockfile) no longer steers the advice — the checkout is the truth even when that
|
|
63
|
+
yields the npm default.
|
|
64
|
+
|
|
7
65
|
## 3.9.0 — the --include copy door proves what it copies (AD-070)
|
|
8
66
|
|
|
9
67
|
Nothing bound the node the provision copy walk read to the node `--include` preflight approved:
|
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.11.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.11.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -33,6 +33,11 @@ own verbatim error through the existing Git-error surface.
|
|
|
33
33
|
is read from exactly one REQUIRED `## Provision record` section (a decoy field elsewhere cannot
|
|
34
34
|
hijack identity); a missing or repeated section, or a duplicated single-valued field, is a typed
|
|
35
35
|
STOP, never last-wins.
|
|
36
|
+
A TRACKED plans-chain path — the handoff or the seeded plan — refuses fail-closed in BOTH lanes (its drift is undeliverable at land, a manufactured dead end): a fresh provision proves both paths untracked at ONE captured commit before `git worktree add` and re-probes the new worktree's index and branch HEAD before any provision write; `--resume` probes the branch HEAD tree and the live index before reading the handoff identity; recovery is surgical (an index-only entry names a literal index removal — plus the ignore restoration where the rule was lost; a HEAD-tracked regular file names salvage first, then consented abandon only where identity binds) and never routes through land.
|
|
37
|
+
The provision record is refreshed LAST, after the post-provision verify, in both lanes — the
|
|
38
|
+
record attests only a verified provision, and a failed run leaves the prior record bytes: on a
|
|
39
|
+
first provision that failed after the stub write, the stub; a refusal BEFORE any provision
|
|
40
|
+
write (the tracked-plans-chain and probe-error STOPs) leaves no handoff at all.
|
|
36
41
|
`--include` sources are identity-bound: preflight records each include root's identity (device,
|
|
37
42
|
inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
|
|
38
43
|
regular file nor a directory — or whose identity probe fails — is refused before any mutation.
|
|
@@ -104,8 +109,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
|
|
|
104
109
|
isolated-install command when the package manager is unambiguous, the honest install-by-hand
|
|
105
110
|
advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
|
|
106
111
|
unlink-first form, because a plain install through the symlink writes into MAIN and is never
|
|
107
|
-
presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read:
|
|
112
|
+
presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read: the checkout's LIVE files at the moment the posture is resolved; on `--resume` a dirty tree is then refused by the clean-tree verify, before the record refresh — a failed resume leaves the prior record bytes) 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
|
|
108
113
|
isolated-install command.
|
|
114
|
+
All manifest/lockfile install evidence — the dependency-free proof AND the package-manager selection (the `packageManager` field, lockfiles) — is read from the worktree's own LIVE files at the moment the posture is resolved (on `--resume` too, where a dirty tree is then refused by the clean-tree verify); MAIN's mutable working tree never steers manager selection.
|
|
109
115
|
|
|
110
116
|
**Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
|
|
111
117
|
commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
|
package/tools/worktrees.mjs
CHANGED
|
@@ -1059,6 +1059,223 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
|
1059
1059
|
);
|
|
1060
1060
|
};
|
|
1061
1061
|
|
|
1062
|
+
// ── D10: the tracked plans-chain refusal (slice R1 of the resume-verify design) ────────
|
|
1063
|
+
// A tracked handoff or seeded plan is a manufactured dead end: the landing lane
|
|
1064
|
+
// categorically refuses docs/plans (staged AND unstaged) and satellite commits are not a
|
|
1065
|
+
// lane, so drift on such a path can never be delivered. Both lanes refuse fail-closed
|
|
1066
|
+
// BEFORE any provision write. Probes are LITERAL by construction (D11): git lists the
|
|
1067
|
+
// docs/plans CONTAINER (that prefix carries no magic bytes) and each leaf is byte-compared
|
|
1068
|
+
// in code, so pathspec magic in a leaf name is inert.
|
|
1069
|
+
|
|
1070
|
+
const PLANS_CHAIN_KIND = Object.freeze({
|
|
1071
|
+
100644: 'regular', 100755: 'regular', 120000: 'symlink', 160000: 'gitlink', '040000': 'tree',
|
|
1072
|
+
});
|
|
1073
|
+
const plansChainKind = (mode) => PLANS_CHAIN_KIND[mode] ?? `mode-${mode}`;
|
|
1074
|
+
|
|
1075
|
+
// `ls-tree -z`/`ls-files -z` lines: "<meta…>\t<path>" NUL-terminated → Map path → [entry].
|
|
1076
|
+
// A MULTIMAP on purpose: an unmerged index carries several stages of one path, and a
|
|
1077
|
+
// last-wins single value could hide an irregular stage behind a regular one. Each lister
|
|
1078
|
+
// maps its own meta shape (`ls-tree`: mode type oid · `ls-files --stage`: mode oid stage —
|
|
1079
|
+
// the STAGE is kept: a single stage-1/2/3 entry is unmerged even alone).
|
|
1080
|
+
const parseZPathEntries = (stdout, toEntry) => {
|
|
1081
|
+
const entries = new Map();
|
|
1082
|
+
for (const field of stdout.split('\0')) {
|
|
1083
|
+
const tab = field.indexOf('\t');
|
|
1084
|
+
if (tab === -1) continue;
|
|
1085
|
+
const rel = field.slice(tab + 1);
|
|
1086
|
+
if (!entries.has(rel)) entries.set(rel, []);
|
|
1087
|
+
entries.get(rel).push(toEntry(field.slice(0, tab).split(' ')));
|
|
1088
|
+
}
|
|
1089
|
+
return entries;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// docs/plans entries of a tree-ish (an absent dir lists empty, exit 0).
|
|
1093
|
+
const listPlansTreeEntries = ({ git, cwd, treeish }) => {
|
|
1094
|
+
const r = git(['ls-tree', '-z', treeish, `${PLANS_REL}/`], cwd);
|
|
1095
|
+
if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-tree failed' };
|
|
1096
|
+
return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: '0' })) };
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const listPlansIndexEntries = ({ git, cwd }) => {
|
|
1100
|
+
const r = git(['ls-files', '--cached', '--stage', '-z', '--', PLANS_REL], cwd);
|
|
1101
|
+
if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-files failed' };
|
|
1102
|
+
return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: meta[2] ?? '0' })) };
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const plansChainRels = ({ slug, seedName }) =>
|
|
1106
|
+
[...new Set([`${PLANS_REL}/${handoffBasename(slug)}`, `${PLANS_REL}/${seedName}`])];
|
|
1107
|
+
|
|
1108
|
+
// The finding kind is decided over EVERY entry of the path from BOTH sources; an unmerged
|
|
1109
|
+
// index state — several stages OR a single non-zero stage — is irregular outright, never a
|
|
1110
|
+
// surgical-recovery candidate.
|
|
1111
|
+
const classifyPlansChainFinding = ({ headEntries = [], indexEntries = [] }) => {
|
|
1112
|
+
const kinds = [...headEntries, ...indexEntries].map(({ mode }) => plansChainKind(mode));
|
|
1113
|
+
const unmerged = indexEntries.length > 1 || indexEntries.some(({ stage }) => stage !== '0');
|
|
1114
|
+
const irregular = unmerged || kinds.some((k) => k !== 'regular');
|
|
1115
|
+
return { irregular, kind: unmerged ? 'unmerged-index' : (kinds.find((k) => k !== 'regular') ?? 'regular') };
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
const literalIndexRemoval = (rel) => `git rm --cached -- ${shellQuoteArg(`:(literal)${rel}`)}`;
|
|
1119
|
+
|
|
1120
|
+
// FRESH, pre-mutation: the tree proof reads EXACTLY the captured commit — the same OID the
|
|
1121
|
+
// branch is cut from — so probe and mutation bind to one object.
|
|
1122
|
+
const assertPlansChainCleanAtCapturedOid = ({ git, root, oid, rels }) => {
|
|
1123
|
+
const tree = listPlansTreeEntries({ git, cwd: root, treeish: oid });
|
|
1124
|
+
if (tree.error) {
|
|
1125
|
+
throw stop(`provision: the plans-chain tree probe failed at the captured commit ${oid}: ${tree.error}`);
|
|
1126
|
+
}
|
|
1127
|
+
for (const rel of rels) {
|
|
1128
|
+
const headEntries = tree.entries.get(rel);
|
|
1129
|
+
if (headEntries === undefined) continue;
|
|
1130
|
+
const { irregular, kind } = classifyPlansChainFinding({ headEntries });
|
|
1131
|
+
if (irregular) {
|
|
1132
|
+
throw stop(`provision refuses: ${rel} is tracked as a ${kind} entry in the captured commit ${oid} — fail-closed; no worktree was created. ${PLANS_REL} must stay git-ignored and untracked.`);
|
|
1133
|
+
}
|
|
1134
|
+
// The reachable poisoned state usually has the removal ALREADY STAGED at main (with the
|
|
1135
|
+
// path still index-tracked the docs/plans entry gate refuses first) — the advice must
|
|
1136
|
+
// match the live index, else the printed command fails «pathspec did not match».
|
|
1137
|
+
const index = listPlansIndexEntries({ git, cwd: root });
|
|
1138
|
+
if (index.error) {
|
|
1139
|
+
throw stop(`provision: the plans-chain index probe failed at main: ${index.error}`);
|
|
1140
|
+
}
|
|
1141
|
+
const stillIndexed = index.entries.has(rel);
|
|
1142
|
+
throw stop([
|
|
1143
|
+
`provision refuses: ${rel} is tracked in the captured commit ${oid} — a tracked plans-chain path is undeliverable and would strand the worktree. No worktree was created.`,
|
|
1144
|
+
...(stillIndexed
|
|
1145
|
+
? ['Recovery (surgical, at main): untrack it and commit the removal —', ` ${literalIndexRemoval(rel)}`, 'then commit and re-run provision.']
|
|
1146
|
+
: ['Recovery (surgical, at main): the removal is already staged — commit it, then re-run provision.']),
|
|
1147
|
+
`${PLANS_REL} must stay git-ignored and untracked.`,
|
|
1148
|
+
].join('\n'));
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
// FRESH, post-add, pre-write: a post-checkout hook may force-add or even COMMIT a
|
|
1153
|
+
// plans-chain path on the new branch (a committed add leaves the tree clean, invisible to
|
|
1154
|
+
// the blanket verify) — re-probe the live index AND the branch HEAD before the first write.
|
|
1155
|
+
const assertPlansChainCleanPostAdd = ({ git, wtRoot, branch, rels }) => {
|
|
1156
|
+
const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
|
|
1157
|
+
if (head.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${head.error}`);
|
|
1158
|
+
const index = listPlansIndexEntries({ git, cwd: wtRoot });
|
|
1159
|
+
if (index.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${index.error}`);
|
|
1160
|
+
const offending = rels
|
|
1161
|
+
.filter((rel) => head.entries.has(rel) || index.entries.has(rel))
|
|
1162
|
+
.map((rel) => ({ rel, ...classifyPlansChainFinding({ headEntries: head.entries.get(rel), indexEntries: index.entries.get(rel) }) }));
|
|
1163
|
+
if (offending.length === 0) return;
|
|
1164
|
+
// The kind matrix holds in every lane: ANY irregular entry → fail-closed, no recovery
|
|
1165
|
+
// command at all. Both STOPs suppress the generic kept-worktree NOTE — its --resume advice
|
|
1166
|
+
// would steer away from (or contradict) the honest lane here.
|
|
1167
|
+
if (offending.some(({ irregular }) => irregular)) {
|
|
1168
|
+
throw stop([
|
|
1169
|
+
'provision refuses: the new worktree already tracks irregular plans-chain entr(y/ies) before any provision write — fail-closed:',
|
|
1170
|
+
...offending.map(({ rel, kind }) => ` ${rel} (${kind})`),
|
|
1171
|
+
'Nothing was written into the worktree.',
|
|
1172
|
+
].join('\n'), { suppressKeptNote: true });
|
|
1173
|
+
}
|
|
1174
|
+
throw stop([
|
|
1175
|
+
'provision refuses: the new worktree already tracks plans-chain path(s) before any provision write — a post-checkout hook force-added or committed:',
|
|
1176
|
+
...offending.map(({ rel }) => ` ${rel}`),
|
|
1177
|
+
'A tracked plans-chain path is undeliverable and the worktree cannot converge; nothing was written into it.',
|
|
1178
|
+
'Recovery, in order:',
|
|
1179
|
+
' 1. inspect the worktree and copy OUT any hook-created content you value',
|
|
1180
|
+
' 2. fix or remove the offending post-checkout hook',
|
|
1181
|
+
' 3. with consent, remove the kept worktree from MAIN:',
|
|
1182
|
+
` git worktree remove --force ${shellQuoteArg(wtRoot)}`,
|
|
1183
|
+
` git branch -D ${shellQuoteArg(branch)}`,
|
|
1184
|
+
' 4. re-run provision',
|
|
1185
|
+
].join('\n'), { suppressKeptNote: true });
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
// RESUME: the branch HEAD tree is probed UNCONDITIONALLY plus the live index, BEFORE the
|
|
1189
|
+
// handoff-identity reads — the probe result is collected first; the identity parse steers
|
|
1190
|
+
// only the recovery wording (a malformed record never cancels a tracked classification).
|
|
1191
|
+
const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels, fs }) => {
|
|
1192
|
+
const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
|
|
1193
|
+
if (head.error) {
|
|
1194
|
+
throw stop(`--resume: the plans-chain probe failed (branch HEAD tree): ${head.error} — the prior record is untouched`);
|
|
1195
|
+
}
|
|
1196
|
+
const index = listPlansIndexEntries({ git, cwd: wtRoot });
|
|
1197
|
+
if (index.error) {
|
|
1198
|
+
throw stop(`--resume: the plans-chain probe failed (live index): ${index.error} — the prior record is untouched`);
|
|
1199
|
+
}
|
|
1200
|
+
const headTracked = [];
|
|
1201
|
+
const indexOnly = [];
|
|
1202
|
+
for (const rel of rels) {
|
|
1203
|
+
const headEntries = head.entries.get(rel);
|
|
1204
|
+
const indexEntries = index.entries.get(rel);
|
|
1205
|
+
if (headEntries !== undefined) headTracked.push({ rel, ...classifyPlansChainFinding({ headEntries, indexEntries }) });
|
|
1206
|
+
else if (indexEntries !== undefined) indexOnly.push({ rel, ...classifyPlansChainFinding({ indexEntries }) });
|
|
1207
|
+
}
|
|
1208
|
+
if (headTracked.length === 0 && indexOnly.length === 0) return;
|
|
1209
|
+
|
|
1210
|
+
// Best-effort identity read, for RECOVERY WORDING only: destructive removal is offered
|
|
1211
|
+
// ONLY where the record identity provably binds this worktree. The failure REASON is kept
|
|
1212
|
+
// and printed INLINE — advising a re-run would loop (this probe fires before the shipped
|
|
1213
|
+
// identity STOP can ever be reached).
|
|
1214
|
+
const identity = (() => {
|
|
1215
|
+
try {
|
|
1216
|
+
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
|
|
1217
|
+
if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
|
|
1218
|
+
const record = parseProvisionRecord(String(rf.bytes));
|
|
1219
|
+
if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}` };
|
|
1220
|
+
if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}` };
|
|
1221
|
+
return { binds: true, reason: null };
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
return { binds: false, reason: errorText(err) };
|
|
1224
|
+
}
|
|
1225
|
+
})();
|
|
1226
|
+
|
|
1227
|
+
// The kind matrix governs the WHOLE set: any irregular finding (an irregular mode in
|
|
1228
|
+
// EITHER source, or a multistage/unmerged index entry) withholds EVERY recovery command —
|
|
1229
|
+
// destructive or surgical advice for a regular sibling would also cover the irregular
|
|
1230
|
+
// content the contract fails closed on.
|
|
1231
|
+
if ([...headTracked, ...indexOnly].some(({ irregular }) => irregular)) {
|
|
1232
|
+
throw stop([
|
|
1233
|
+
'--resume refuses: irregular plans-chain entr(y/ies) are tracked in this worktree — fail-closed, recovery commands withheld for the whole set:',
|
|
1234
|
+
...headTracked.map(({ rel, kind }) => ` ${rel} (${kind}, branch HEAD)`),
|
|
1235
|
+
...indexOnly.map(({ rel, kind }) => ` ${rel} (${kind}, index only)`),
|
|
1236
|
+
'The prior provision record is untouched.',
|
|
1237
|
+
].join('\n'));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// A poisoned base dominates the scenario: consented abandon disposes the WHOLE worktree,
|
|
1241
|
+
// so per-path index-only surgical commands beside it would be contradictory noise — the
|
|
1242
|
+
// index-only paths are LISTED as findings and the one salvage-then-abandon lane covers all.
|
|
1243
|
+
if (headTracked.length > 0) {
|
|
1244
|
+
throw stop([
|
|
1245
|
+
...headTracked.map(({ rel }) =>
|
|
1246
|
+
`--resume refuses: ${rel} is tracked in this worktree's branch HEAD — the legacy poisoned-base pathology; its drift is undeliverable, so this worktree cannot converge.`),
|
|
1247
|
+
...indexOnly.map(({ rel }) =>
|
|
1248
|
+
`--resume refuses: ${rel} is staged in this worktree's index — undeliverable beside the poisoned base above.`),
|
|
1249
|
+
'Recovery, in order:',
|
|
1250
|
+
' 1. salvage FIRST: copy the live files (and anything else you value) OUT of the worktree — plain file copies, no identity needed',
|
|
1251
|
+
identity.binds
|
|
1252
|
+
? ` 2. then, with consent: ${composeOwnToolPrefix(root)} ${composeCleanupCommand({ slug, branch, abandon: true })} (DESTROYS unlanded work)`
|
|
1253
|
+
: ` 2. the handoff record does not bind: ${identity.reason} — a consented destructive removal is NOT offered; repair the record before any identity-bound lane can run`,
|
|
1254
|
+
].join('\n'));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const lines = [];
|
|
1258
|
+
for (const { rel } of indexOnly) {
|
|
1259
|
+
// The rule probe reads the EXACT offending path, index-independently: `--no-index` skips
|
|
1260
|
+
// the index consultation that makes a plain check-ignore lie about tracked paths
|
|
1261
|
+
// (live-probed), and a fabricated sibling name could disagree with the real rule set.
|
|
1262
|
+
const rule = git(['check-ignore', '--no-index', '--', rel], wtRoot);
|
|
1263
|
+
if (rule.status !== 0 && rule.status !== 1) {
|
|
1264
|
+
throw stop(`--resume: the ignore-rule probe failed for ${rel}: ${(rule.stderr || rule.stdout).trim()} — the prior record is untouched`);
|
|
1265
|
+
}
|
|
1266
|
+
const ignoreRuleGone = rule.status === 1;
|
|
1267
|
+
lines.push(
|
|
1268
|
+
`--resume refuses: ${rel} is staged in this worktree's index (absent from the branch HEAD) — a session force-add whose staged entry can never be delivered.`,
|
|
1269
|
+
'Recovery (surgical, in the worktree):',
|
|
1270
|
+
` 1. ${literalIndexRemoval(rel)}`,
|
|
1271
|
+
` (if git refuses — the staged content differs from the live file — salvage the staged blob first: git show ${shellQuoteArg(`:${rel}`)} > <a file OUTSIDE this worktree>, then repeat with -f; the live file stays untouched)`,
|
|
1272
|
+
...(ignoreRuleGone ? [` 2. restore the git-ignore rule covering ${rel} (provision requires ${PLANS_REL} ignored)`] : []),
|
|
1273
|
+
' then re-run --resume.',
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
throw stop(lines.join('\n'));
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1062
1279
|
// ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
|
|
1063
1280
|
|
|
1064
1281
|
// The orientation facts a fresh satellite session cannot derive from its own checkout. They are
|
|
@@ -1374,8 +1591,10 @@ const LOCKFILE_MANAGERS = Object.freeze([
|
|
|
1374
1591
|
const NEUTRAL_INSTALL_ADVICE =
|
|
1375
1592
|
'install command not printed — package manager is ambiguous or unknown; install dependencies in the worktree by hand';
|
|
1376
1593
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1594
|
+
// Advice evidence = the WORKTREE'S OWN LIVE checkout (manifest AND lockfiles): the printed
|
|
1595
|
+
// command runs in the satellite, so what main's working tree happens to hold must not steer it.
|
|
1596
|
+
const resolveInstallAdvice = ({ wtRoot, fs }) => {
|
|
1597
|
+
const pkg = readFileNoFollow(fs, join(wtRoot, 'package.json'));
|
|
1379
1598
|
let manager = null;
|
|
1380
1599
|
let inspectLocks = false;
|
|
1381
1600
|
if (pkg.absent) {
|
|
@@ -1406,7 +1625,7 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
|
|
|
1406
1625
|
for (const [name, candidate] of LOCKFILE_MANAGERS) {
|
|
1407
1626
|
let st;
|
|
1408
1627
|
try {
|
|
1409
|
-
st = fs.lstat(join(
|
|
1628
|
+
st = fs.lstat(join(wtRoot, name));
|
|
1410
1629
|
} catch (err) {
|
|
1411
1630
|
if (err?.code === 'ENOENT') continue;
|
|
1412
1631
|
return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
|
|
@@ -1481,9 +1700,10 @@ const declaresNativeBuild = (fs, dir) => lstatNoFollow(fs.lstat, join(dir, 'bind
|
|
|
1481
1700
|
|
|
1482
1701
|
// PROVABLY dependency-free, or nothing — read from the WORKTREE'S OWN LIVE CHECKOUT, never from
|
|
1483
1702
|
// MAIN's mutable working tree: the evidence is what an install run in THIS worktree would actually
|
|
1484
|
-
// read
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1703
|
+
// read, taken at the moment the posture is resolved (a post-checkout hook may already have shaped
|
|
1704
|
+
// the checkout; on --resume it follows the session's own edits, in both directions — gained
|
|
1705
|
+
// dependencies revoke the proof, shed ones grant it — the same live lane
|
|
1706
|
+
// as the node_modules symlink probe). A dirty main manifest must neither grant nor revoke a verdict
|
|
1487
1707
|
// about content it does not describe. A `workspaces` field of ANY shape
|
|
1488
1708
|
// is UNKNOWN outright — a workspace install materializes member links and `.bin` shims even with
|
|
1489
1709
|
// zero dependencies, so a workspace tree is never provably install-free. Everything else the tool
|
|
@@ -1512,20 +1732,20 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
|
|
|
1512
1732
|
// an earlier provision left — an install through it writes into MAIN, and the posture must never
|
|
1513
1733
|
// hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
|
|
1514
1734
|
// "nothing to install" must not ride an install instruction.
|
|
1515
|
-
const resolveInstallPosture = ({
|
|
1735
|
+
const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
|
|
1516
1736
|
const nmPath = join(wtRoot, 'node_modules');
|
|
1517
1737
|
const nm = lstatNoFollow(fs.lstat, nmPath);
|
|
1518
1738
|
if (nm !== null && nm.isSymbolicLink()) {
|
|
1519
|
-
const advice = resolveInstallAdvice({
|
|
1739
|
+
const advice = resolveInstallAdvice({ wtRoot, fs });
|
|
1520
1740
|
const separator = advice.command === null ? ' — ' : ' && ';
|
|
1521
1741
|
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}`;
|
|
1522
1742
|
}
|
|
1523
1743
|
if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
|
|
1524
|
-
return resolveInstallAdvice({
|
|
1744
|
+
return resolveInstallAdvice({ wtRoot, fs }).instruction;
|
|
1525
1745
|
};
|
|
1526
1746
|
|
|
1527
1747
|
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
|
|
1528
|
-
const install = resolveInstallAdvice({
|
|
1748
|
+
const install = resolveInstallAdvice({ wtRoot, fs });
|
|
1529
1749
|
if (installFlag) {
|
|
1530
1750
|
const dst = join(wtRoot, 'node_modules');
|
|
1531
1751
|
const existing = lstatNoFollow(fs.lstat, dst);
|
|
@@ -1806,13 +2026,22 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1806
2026
|
}
|
|
1807
2027
|
const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
|
|
1808
2028
|
if (wtCommon !== commonDir) throw stop(`--resume identity mismatch: ${targetReal} does not share this repo's git dir`);
|
|
2029
|
+
assertPlansChainCleanOnResume({ git, root, wtRoot: targetReal, slug, branch, rels: plansChainRels({ slug, seedName: seed.name }), fs });
|
|
1809
2030
|
assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
|
|
1810
2031
|
assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
|
|
1811
2032
|
runWritabilityProbe();
|
|
1812
2033
|
report.push(`resuming provision at ${targetReal} (branch ${branch})`);
|
|
1813
2034
|
} else {
|
|
1814
2035
|
runWritabilityProbe();
|
|
1815
|
-
|
|
2036
|
+
// ONE captured commit binds the plans-chain tree proof AND the branch cut: a clean
|
|
2037
|
+
// captured OID with a subsequently-moved HEAD still cuts from the captured OID.
|
|
2038
|
+
const capture = git(['rev-parse', 'HEAD'], root);
|
|
2039
|
+
if (capture.status !== 0) {
|
|
2040
|
+
throw stop(`provision: cannot capture the base commit (git rev-parse HEAD failed in the main repo): ${(capture.stderr || capture.stdout).trim()} — the plans-chain proof and the branch cut must bind to one commit`);
|
|
2041
|
+
}
|
|
2042
|
+
const capturedOid = capture.stdout.replace(/\r?\n$/, '');
|
|
2043
|
+
assertPlansChainCleanAtCapturedOid({ git, root, oid: capturedOid, rels: plansChainRels({ slug, seedName: seed.name }) });
|
|
2044
|
+
const add = git(['worktree', 'add', '-b', branch, targetReal, capturedOid], root);
|
|
1816
2045
|
if (add.status !== 0) {
|
|
1817
2046
|
throw stop(
|
|
1818
2047
|
[
|
|
@@ -1827,9 +2056,12 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1827
2056
|
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1828
2057
|
// hand back the exact finish command, never just the local cause
|
|
1829
2058
|
try {
|
|
2059
|
+
if (!flags.resume) {
|
|
2060
|
+
assertPlansChainCleanPostAdd({ git, wtRoot: targetReal, branch, rels: plansChainRels({ slug, seedName: seed.name }) });
|
|
2061
|
+
}
|
|
1830
2062
|
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
|
|
1831
2063
|
} catch (err) {
|
|
1832
|
-
if (!flags.resume && err?.message) {
|
|
2064
|
+
if (!flags.resume && err?.message && !err.suppressKeptNote) {
|
|
1833
2065
|
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).`;
|
|
1834
2066
|
}
|
|
1835
2067
|
throw err;
|
|
@@ -1856,23 +2088,6 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1856
2088
|
|
|
1857
2089
|
rebasePins({ root, wtRoot: targetPath, git, fs, report });
|
|
1858
2090
|
|
|
1859
|
-
writeHandoffRecord({
|
|
1860
|
-
wtRoot: targetPath,
|
|
1861
|
-
slug,
|
|
1862
|
-
branch,
|
|
1863
|
-
fields: {
|
|
1864
|
-
slug,
|
|
1865
|
-
branch,
|
|
1866
|
-
includes: includesRecorded,
|
|
1867
|
-
nodeModules: nodeModulesMode,
|
|
1868
|
-
vscode: vscodeMode,
|
|
1869
|
-
install: resolveInstallPosture({ root, wtRoot: targetPath, dependencyFree, fs }),
|
|
1870
|
-
...orientationFields({ root, slug }),
|
|
1871
|
-
},
|
|
1872
|
-
fs,
|
|
1873
|
-
report,
|
|
1874
|
-
});
|
|
1875
|
-
|
|
1876
2091
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
1877
2092
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
1878
2093
|
throw stop(
|
|
@@ -1888,6 +2103,35 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1888
2103
|
);
|
|
1889
2104
|
}
|
|
1890
2105
|
|
|
2106
|
+
// The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
|
|
2107
|
+
// the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
|
|
2108
|
+
// (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
|
|
2109
|
+
// fire, so a refresh failure here wraps its own: the verify PASSED and the exact re-run
|
|
2110
|
+
// command is the honest recovery, with the original cause preserved.
|
|
2111
|
+
try {
|
|
2112
|
+
writeHandoffRecord({
|
|
2113
|
+
wtRoot: targetPath,
|
|
2114
|
+
slug,
|
|
2115
|
+
branch,
|
|
2116
|
+
fields: {
|
|
2117
|
+
slug,
|
|
2118
|
+
branch,
|
|
2119
|
+
includes: includesRecorded,
|
|
2120
|
+
nodeModules: nodeModulesMode,
|
|
2121
|
+
vscode: vscodeMode,
|
|
2122
|
+
install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
|
|
2123
|
+
...orientationFields({ root, slug }),
|
|
2124
|
+
},
|
|
2125
|
+
fs,
|
|
2126
|
+
report,
|
|
2127
|
+
});
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (flags.resume && err?.message) {
|
|
2130
|
+
err.message += `\nNOTE: the worktree at ${targetPath} (branch ${branch}) is KEPT — the provision verify PASSED and only the record refresh failed; finish with: ${composeProvisionArgv({ root, slug, flags })} (the prior record bytes are untouched).`;
|
|
2131
|
+
}
|
|
2132
|
+
throw err;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1891
2135
|
const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
|
|
1892
2136
|
for (const line of report) log(line);
|
|
1893
2137
|
log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
|