@sabaiway/agent-workflow-kit 3.10.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 +33 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +6 -1
- package/tools/worktrees.mjs +260 -19
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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
|
+
|
|
7
40
|
## 3.10.0 — install advice reads the worktree checkout (AD-071)
|
|
8
41
|
|
|
9
42
|
The install advice picked its package manager from MAIN's `package.json` and lockfiles while the
|
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,7 +109,7 @@ 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: 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,
|
|
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.
|
|
109
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.
|
|
110
115
|
|
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
|
|
@@ -1809,13 +2026,22 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1809
2026
|
}
|
|
1810
2027
|
const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
|
|
1811
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 });
|
|
1812
2030
|
assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
|
|
1813
2031
|
assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
|
|
1814
2032
|
runWritabilityProbe();
|
|
1815
2033
|
report.push(`resuming provision at ${targetReal} (branch ${branch})`);
|
|
1816
2034
|
} else {
|
|
1817
2035
|
runWritabilityProbe();
|
|
1818
|
-
|
|
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);
|
|
1819
2045
|
if (add.status !== 0) {
|
|
1820
2046
|
throw stop(
|
|
1821
2047
|
[
|
|
@@ -1830,9 +2056,12 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1830
2056
|
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1831
2057
|
// hand back the exact finish command, never just the local cause
|
|
1832
2058
|
try {
|
|
2059
|
+
if (!flags.resume) {
|
|
2060
|
+
assertPlansChainCleanPostAdd({ git, wtRoot: targetReal, branch, rels: plansChainRels({ slug, seedName: seed.name }) });
|
|
2061
|
+
}
|
|
1833
2062
|
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
|
|
1834
2063
|
} catch (err) {
|
|
1835
|
-
if (!flags.resume && err?.message) {
|
|
2064
|
+
if (!flags.resume && err?.message && !err.suppressKeptNote) {
|
|
1836
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).`;
|
|
1837
2066
|
}
|
|
1838
2067
|
throw err;
|
|
@@ -1859,23 +2088,6 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1859
2088
|
|
|
1860
2089
|
rebasePins({ root, wtRoot: targetPath, git, fs, report });
|
|
1861
2090
|
|
|
1862
|
-
writeHandoffRecord({
|
|
1863
|
-
wtRoot: targetPath,
|
|
1864
|
-
slug,
|
|
1865
|
-
branch,
|
|
1866
|
-
fields: {
|
|
1867
|
-
slug,
|
|
1868
|
-
branch,
|
|
1869
|
-
includes: includesRecorded,
|
|
1870
|
-
nodeModules: nodeModulesMode,
|
|
1871
|
-
vscode: vscodeMode,
|
|
1872
|
-
install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
|
|
1873
|
-
...orientationFields({ root, slug }),
|
|
1874
|
-
},
|
|
1875
|
-
fs,
|
|
1876
|
-
report,
|
|
1877
|
-
});
|
|
1878
|
-
|
|
1879
2091
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
1880
2092
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
1881
2093
|
throw stop(
|
|
@@ -1891,6 +2103,35 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1891
2103
|
);
|
|
1892
2104
|
}
|
|
1893
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
|
+
|
|
1894
2135
|
const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
|
|
1895
2136
|
for (const line of report) log(line);
|
|
1896
2137
|
log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
|