@sabaiway/agent-workflow-kit 3.10.0 → 3.12.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 +77 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/worktrees.md +41 -4
- package/tools/doc-parity.mjs +7 -2
- package/tools/worktrees.mjs +462 -45
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ 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.12.0 — `--resume` tolerates the session's work: the verify proves per placed path (AD-073)
|
|
8
|
+
|
|
9
|
+
`provision --resume` no longer refuses a satellite you have worked in. The closing slice of the
|
|
10
|
+
resume-verify design: the post-provision verify stopped asking "is the whole tree clean?" and now
|
|
11
|
+
asks "is every path THIS run placed or kept in a git lane provision can prove?" — so your
|
|
12
|
+
uncommitted edits, untracked scratch and hook-created files are out of scope BY CONSTRUCTION.
|
|
13
|
+
|
|
14
|
+
- **What `--resume` now tolerates:** uncommitted tracked edits (including a dirty `package.json`,
|
|
15
|
+
whose live state steers the refreshed install posture in both directions), untracked scratch at
|
|
16
|
+
any depth, renamed tracked files, hook-created content, and every ignored file. A worktree wedged
|
|
17
|
+
by a `post-checkout` hook that dirtied its fresh checkout now completes on `--resume` — previously
|
|
18
|
+
both lanes refused at the same verify and the only way out was deleting the hook's files by hand.
|
|
19
|
+
- **What still refuses, fail-closed:** a path provision itself placed or kept whose lane is
|
|
20
|
+
UNTRACKED, and any lane probe that errors. The STOP names the exact leaf — never a directory, never
|
|
21
|
+
a session path — and carries the convergent fix first: restore the ignore rule (the only
|
|
22
|
+
convergent fix for copy-set leaves, the `node_modules` link and `.vscode/settings.json`, since the
|
|
23
|
+
next resume simply re-places a removed node). A droppable `--include` gets ONE instruction for its
|
|
24
|
+
whole destination ROOT — dropping the flag orphans every copy under it — namely: move the root OUT
|
|
25
|
+
of the worktree and drop `--include <root>` together. No removal command is ever derived (the tool
|
|
26
|
+
cannot see what else you put inside that directory), and leaving it in place is not offered either
|
|
27
|
+
(an orphan is what blocks landing). A node this attempt did not create is never advised away, and
|
|
28
|
+
an unprovable probe carries no recovery command at all.
|
|
29
|
+
- **The proof set is a closed, frozen registry** (`PLACEMENT_REGISTRY`): the handoff stub, the seeded
|
|
30
|
+
plan, copy-set leaves, include leaves, the `node_modules` link, `.vscode/settings.json`,
|
|
31
|
+
pin-rebase targets, and the record refresh. It is leaf-only and kind-gated — a real `node_modules`
|
|
32
|
+
directory where the tool places a symlink is YOUR content, never probed and never touched — and it
|
|
33
|
+
freezes at the verify, so the record refresh can only write at the path the stub already
|
|
34
|
+
journaled. The kind gate applies to KEPT nodes only: anything the run itself created stays proven.
|
|
35
|
+
- **The lane probes are literal.** Live-probed on git 2.43: `check-ignore` refuses pathspec magic and
|
|
36
|
+
otherwise answers for a name that glob-matches a TRACKED sibling, so a file literally named
|
|
37
|
+
`feature-[a].md` would read "not ignored" merely because `feature-a.md` is tracked. Tracked-ness is
|
|
38
|
+
decided first by an explicit literal pathspec whose output must match the path BYTE-EXACTLY (a
|
|
39
|
+
pathspec naming a directory lists its descendants, which prove nothing about the path itself), and
|
|
40
|
+
the ignore probe runs `--no-index`. A `.vscode/settings.json` an earlier run placed is proven even
|
|
41
|
+
when the current run's door skips writing it, so a lost ignore rule can no longer hide behind a
|
|
42
|
+
successful resume.
|
|
43
|
+
- **The FIRST provision stays deliberately strict** — any dirt still refuses — and its untracked
|
|
44
|
+
visibility is now explicit, so a repo's `status.showUntrackedFiles=no` can no longer turn that
|
|
45
|
+
check into a silent no-op. Default behavior is unchanged.
|
|
46
|
+
- **The contract ships as a live constant** (`RESUME_VERIFY_RULE`) printed on every resume-verify
|
|
47
|
+
STOP and pinned into the mode doc by the doc-parity gate. The record's fields are documented for
|
|
48
|
+
what they are: `slug`, `branch` and the seeded plan name authorize a resume; `include`,
|
|
49
|
+
`node_modules` and `vscode-settings` are recorded facts that never do.
|
|
50
|
+
|
|
51
|
+
## 3.11.0 — the record attests only a verified provision; tracked plans-chain paths refuse (AD-072)
|
|
52
|
+
|
|
53
|
+
Two provision honesty fixes from the converged resume-verify design (its slice R1; the
|
|
54
|
+
tolerance flip is the next slice):
|
|
55
|
+
|
|
56
|
+
- **The provision record is refreshed LAST — after the in-flight-plan check and the
|
|
57
|
+
post-provision verify, in BOTH lanes.** The record now attests only a VERIFIED provision: a
|
|
58
|
+
first provision that fails after the stub write leaves the STUB (identity still binds from
|
|
59
|
+
it — `--resume` completes as before; a pre-write refusal leaves no handoff at all), and a
|
|
60
|
+
failed resume leaves the PRIOR record bytes byte-exact. Previously a failed
|
|
61
|
+
run left a freshly refreshed record (the 3.6.0 stated residual — now eliminated). A refresh
|
|
62
|
+
failure after a clean verify keeps the worktree and names the exact re-run command in both
|
|
63
|
+
lanes, preserving the original failure cause.
|
|
64
|
+
- **A TRACKED plans-chain path — the handoff or the seeded plan — refuses fail-closed in both
|
|
65
|
+
lanes.** Its drift is undeliverable (`land` categorically refuses docs/plans, and satellite
|
|
66
|
+
commits are not a lane), so tolerating it would strand the worktree. A fresh provision proves
|
|
67
|
+
both paths untracked at ONE captured commit — the same OID the branch is cut from — and
|
|
68
|
+
re-probes the new worktree's index and branch HEAD before the first write, catching
|
|
69
|
+
post-checkout hooks that force-add or even COMMIT them (a committed add leaves the tree
|
|
70
|
+
clean, invisible to the clean-tree verify). `--resume` probes the branch HEAD tree
|
|
71
|
+
unconditionally plus the live index before reading the handoff identity.
|
|
72
|
+
- **Recovery is surgical and honest.** An index-only entry names a pathspec-LITERAL
|
|
73
|
+
`git rm --cached` (a magic-shaped seed name never touches a pattern-matching neighbor) plus
|
|
74
|
+
the ignore-rule restoration when the worktree's live rules lost it; a branch-HEAD-tracked
|
|
75
|
+
file names salvage FIRST, then consented `cleanup --abandon` ONLY where the record identity
|
|
76
|
+
binds (a malformed record routes to the shipped record recovery — abandon is never promised);
|
|
77
|
+
the post-add hook STOP names inspect/salvage, then plain-git removal (never `cleanup
|
|
78
|
+
--abandon` — no handoff identity exists yet); an irregular entry (tree / symlink / gitlink)
|
|
79
|
+
fails closed with NO recovery command. Recovery never routes through the landing lane.
|
|
80
|
+
- **The mode doc states both contracts verbatim** (the corrected verify-then-refresh order and
|
|
81
|
+
the plans-chain refusal), each pinned by a named test. Resume tolerance is UNCHANGED this
|
|
82
|
+
release: the blanket clean-tree verify stays, pinned byte-exact.
|
|
83
|
+
|
|
7
84
|
## 3.10.0 — install advice reads the worktree checkout (AD-071)
|
|
8
85
|
|
|
9
86
|
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.12.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.12.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,41 @@ 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.
|
|
41
|
+
The post-provision verify is lane-specific:
|
|
42
|
+
the resume verify proves only what THIS run placed or kept: every journaled leaf must be tracked or ignored in the worktree, an untracked owned leaf or any probe error stops the run naming the exact leaf, and every other path — the session's own work — is never probed and never a stop cause; a first provision keeps the blanket clean-tree verify.
|
|
43
|
+
The proof list is THIS run's live placement journal — the closed-world placement registry (the
|
|
44
|
+
handoff stub, the seeded plan, copy-set leaves, include leaves, the `node_modules` link,
|
|
45
|
+
`.vscode/settings.json`, the pin-rebase targets, and the record refresh at its already-journaled
|
|
46
|
+
path), leaf-only (files and symlinks; directories are containers, never proof obligations) and
|
|
47
|
+
kind-gated: membership requires the live node's kind to match what that lane's SOURCE places, so
|
|
48
|
+
a directory, a special node, or a kind-mismatched link at an owned path is session content the
|
|
49
|
+
verifier never probes. The journal FREEZES at the verify. Lane probes are LITERAL — pathspec
|
|
50
|
+
magic in a placed name is inert — and tracked-priority (tracked > ignored > untracked). An
|
|
51
|
+
untracked owned leaf STOPs with the convergent fix first: restore the ignore rule (the only
|
|
52
|
+
convergent fix for a mandatory copy-set leaf, the `node_modules` link, and
|
|
53
|
+
`.vscode/settings.json` — each is placed only where a check-ignore gate proved the destination
|
|
54
|
+
ignored, and removing the node is non-convergent because the next resume re-places it). A
|
|
55
|
+
droppable `--include` instead gets ONE grouped instruction on its destination ROOT — dropping the
|
|
56
|
+
flag orphans every copy under that root, since cleanup derives ownership from the recorded
|
|
57
|
+
includes — namely: salvage or relocate the whole root OUT of the worktree AND drop
|
|
58
|
+
`--include <root>` in the same run, either alone recurs. No removal is ever derived there: the
|
|
59
|
+
journal cannot see session content or kind-excluded nodes inside that root, so no `rm` over it
|
|
60
|
+
could be proven safe; and leaving it in place is not offered either, because an orphaned
|
|
61
|
+
destination is exactly what stops land. A kept node is never advised away, an excluded dir
|
|
62
|
+
(`docs/plans`, `docs/ai`) never gets a tracking arm, and a probe error carries NO recovery
|
|
63
|
+
command. The tracked lane is proven by a byte-exact path match, never by a non-empty listing — a
|
|
64
|
+
pathspec naming a directory lists its descendants, which prove nothing about the path itself. Membership itself is kind-gated only for a KEPT node: a
|
|
65
|
+
node THIS attempt created is owned by construction and stays in the proof set whatever its live
|
|
66
|
+
kind became. So a `--resume` in a worked-in satellite completes
|
|
67
|
+
with the session's uncommitted tracked edits, untracked scratch, and hook-created files
|
|
68
|
+
untouched — while the FIRST provision stays deliberately stricter, refusing any dirt at the
|
|
69
|
+
blanket clean-tree verify (its untracked visibility is explicit, so repo `status` configuration
|
|
70
|
+
cannot blind it).
|
|
36
71
|
`--include` sources are identity-bound: preflight records each include root's identity (device,
|
|
37
72
|
inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
|
|
38
73
|
regular file nor a directory — or whose identity probe fails — is refused before any mutation.
|
|
@@ -93,8 +128,10 @@ own verbatim error through the existing Git-error surface.
|
|
|
93
128
|
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
94
129
|
requires the handoff identity, and is the only path where `--force` may appear.
|
|
95
130
|
|
|
96
|
-
**Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):**
|
|
97
|
-
(`slug`, `branch`,
|
|
131
|
+
**Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):** resume
|
|
132
|
+
IDENTITY (`slug`, `branch`, and the seeded plan name — a mismatch STOPs) · recorded provision FACTS
|
|
133
|
+
that never authorize a resume (`include`, `node_modules`, `vscode-settings`) · and, after a prepare,
|
|
134
|
+
`prepared-tree`, which is a land/cleanup attestation-and-recovery surface, not resume identity —
|
|
98
135
|
PLUS the three facts a fresh satellite session cannot derive from its own checkout:
|
|
99
136
|
|
|
100
137
|
- `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.
|
|
@@ -104,9 +141,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
|
|
|
104
141
|
isolated-install command when the package manager is unambiguous, the honest install-by-hand
|
|
105
142
|
advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
|
|
106
143
|
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`
|
|
144
|
+
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` that includes the session's own uncommitted edits, which the per-owned-path verify tolerates, and 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
145
|
isolated-install command.
|
|
109
|
-
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,
|
|
146
|
+
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, over the session's own live edits); MAIN's mutable working tree never steers manager selection.
|
|
110
147
|
|
|
111
148
|
**Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
|
|
112
149
|
commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from './recommendations.mjs';
|
|
35
35
|
import { SKIPPED_READONLY } from './setup-backends.mjs';
|
|
36
36
|
import { LATENT_ARM_NOTICE } from './review-state.mjs';
|
|
37
|
-
import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE } from './worktrees.mjs';
|
|
37
|
+
import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE, RESUME_VERIFY_RULE } from './worktrees.mjs';
|
|
38
38
|
|
|
39
39
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
40
|
|
|
@@ -112,6 +112,10 @@ export const BINDINGS = Object.freeze([
|
|
|
112
112
|
// a reworded mode doc dropping the preflight-binding × door-time-queue contract fails this pin
|
|
113
113
|
// plus the gate.
|
|
114
114
|
valueBinding('include-identity-rule', INCLUDE_IDENTITY_RULE, INCLUDE_IDENTITY_RULE, [WORKTREES_DOC]),
|
|
115
|
+
// The resume-verify contract (slice R2): the exact live sentence every resume-verify STOP emits —
|
|
116
|
+
// a reworded mode doc dropping the per-owned-path × session-never-probed contract fails this pin
|
|
117
|
+
// plus the gate.
|
|
118
|
+
valueBinding('resume-verify-rule', RESUME_VERIFY_RULE, RESUME_VERIFY_RULE, [WORKTREES_DOC]),
|
|
115
119
|
].map((b) => Object.freeze(b)));
|
|
116
120
|
|
|
117
121
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -160,7 +164,8 @@ table, the status tokens, the trusted-dir allowlist), the recommendations/upgrad
|
|
|
160
164
|
contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
|
|
161
165
|
degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
|
|
162
166
|
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
|
|
163
|
-
worktrees cleanup-ownership rule,
|
|
167
|
+
worktrees cleanup-ownership rule, the worktrees include-identity rule, and the worktrees
|
|
168
|
+
resume-verify rule — to
|
|
164
169
|
the exact token its references/modes/*.md contract must carry, and
|
|
165
170
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
166
171
|
or an absent token FAILS CLOSED.
|
package/tools/worktrees.mjs
CHANGED
|
@@ -785,7 +785,9 @@ const failAfterCopy = ({ cause, dstAbs, wtRoot, fs }) => {
|
|
|
785
785
|
throw stop(`${primary} — partial destination removed; re-run provision`);
|
|
786
786
|
};
|
|
787
787
|
|
|
788
|
-
const
|
|
788
|
+
const NO_JOURNAL = Object.freeze({ record: () => {} });
|
|
789
|
+
|
|
790
|
+
const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null, journal = NO_JOURNAL, surface = 'copy-set-leaf', journalRoot = null }) => {
|
|
789
791
|
if (EXCLUDED_BASENAMES.has(basename(srcAbs))) {
|
|
790
792
|
report.push(` skip (session sidecar): ${rel}`);
|
|
791
793
|
return;
|
|
@@ -809,6 +811,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
|
|
|
809
811
|
// Fresh-provision include lane: an existing destination is aliasing the overlap
|
|
810
812
|
// comparator missed (nothing legitimate pre-populates it) — fail closed, never "kept".
|
|
811
813
|
if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
814
|
+
journal.record({ rel, surface, outcome: 'kept', kind: 'symlink', root: journalRoot });
|
|
812
815
|
report.push(` kept (already present): ${rel}`);
|
|
813
816
|
return;
|
|
814
817
|
}
|
|
@@ -842,6 +845,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
|
|
|
842
845
|
guardDst(fs, wtRoot, dstAbs);
|
|
843
846
|
fs.symlink(target, dstAbs);
|
|
844
847
|
copied.add(rel);
|
|
848
|
+
journal.record({ rel, surface, outcome: 'written', kind: 'symlink', root: journalRoot });
|
|
845
849
|
report.push(` linked: ${rel} -> ${target}`);
|
|
846
850
|
} else if (st.isDirectory()) {
|
|
847
851
|
if (lstatNoFollow(fs.lstat, dstAbs) === null) {
|
|
@@ -854,11 +858,12 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
|
|
|
854
858
|
throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
855
859
|
}
|
|
856
860
|
for (const entry of fs.readdir(srcAbs)) {
|
|
857
|
-
copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied, door });
|
|
861
|
+
copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied, door, journal, surface, journalRoot });
|
|
858
862
|
}
|
|
859
863
|
} else if (st.isFile()) {
|
|
860
864
|
if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
|
|
861
865
|
if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
|
|
866
|
+
journal.record({ rel, surface, outcome: 'kept', root: journalRoot });
|
|
862
867
|
report.push(` kept (already present): ${rel}`);
|
|
863
868
|
return;
|
|
864
869
|
}
|
|
@@ -870,6 +875,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
|
|
|
870
875
|
failAfterCopy({ cause, dstAbs, wtRoot, fs });
|
|
871
876
|
}
|
|
872
877
|
copied.add(rel);
|
|
878
|
+
journal.record({ rel, surface, outcome: 'written', root: journalRoot });
|
|
873
879
|
report.push(` copied: ${rel}`);
|
|
874
880
|
} else {
|
|
875
881
|
throw stop(`refusing to copy a special file (device/FIFO/socket): ${rel}`);
|
|
@@ -1059,6 +1065,223 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
|
1059
1065
|
);
|
|
1060
1066
|
};
|
|
1061
1067
|
|
|
1068
|
+
// ── D10: the tracked plans-chain refusal (slice R1 of the resume-verify design) ────────
|
|
1069
|
+
// A tracked handoff or seeded plan is a manufactured dead end: the landing lane
|
|
1070
|
+
// categorically refuses docs/plans (staged AND unstaged) and satellite commits are not a
|
|
1071
|
+
// lane, so drift on such a path can never be delivered. Both lanes refuse fail-closed
|
|
1072
|
+
// BEFORE any provision write. Probes are LITERAL by construction (D11): git lists the
|
|
1073
|
+
// docs/plans CONTAINER (that prefix carries no magic bytes) and each leaf is byte-compared
|
|
1074
|
+
// in code, so pathspec magic in a leaf name is inert.
|
|
1075
|
+
|
|
1076
|
+
const PLANS_CHAIN_KIND = Object.freeze({
|
|
1077
|
+
100644: 'regular', 100755: 'regular', 120000: 'symlink', 160000: 'gitlink', '040000': 'tree',
|
|
1078
|
+
});
|
|
1079
|
+
const plansChainKind = (mode) => PLANS_CHAIN_KIND[mode] ?? `mode-${mode}`;
|
|
1080
|
+
|
|
1081
|
+
// `ls-tree -z`/`ls-files -z` lines: "<meta…>\t<path>" NUL-terminated → Map path → [entry].
|
|
1082
|
+
// A MULTIMAP on purpose: an unmerged index carries several stages of one path, and a
|
|
1083
|
+
// last-wins single value could hide an irregular stage behind a regular one. Each lister
|
|
1084
|
+
// maps its own meta shape (`ls-tree`: mode type oid · `ls-files --stage`: mode oid stage —
|
|
1085
|
+
// the STAGE is kept: a single stage-1/2/3 entry is unmerged even alone).
|
|
1086
|
+
const parseZPathEntries = (stdout, toEntry) => {
|
|
1087
|
+
const entries = new Map();
|
|
1088
|
+
for (const field of stdout.split('\0')) {
|
|
1089
|
+
const tab = field.indexOf('\t');
|
|
1090
|
+
if (tab === -1) continue;
|
|
1091
|
+
const rel = field.slice(tab + 1);
|
|
1092
|
+
if (!entries.has(rel)) entries.set(rel, []);
|
|
1093
|
+
entries.get(rel).push(toEntry(field.slice(0, tab).split(' ')));
|
|
1094
|
+
}
|
|
1095
|
+
return entries;
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// docs/plans entries of a tree-ish (an absent dir lists empty, exit 0).
|
|
1099
|
+
const listPlansTreeEntries = ({ git, cwd, treeish }) => {
|
|
1100
|
+
const r = git(['ls-tree', '-z', treeish, `${PLANS_REL}/`], cwd);
|
|
1101
|
+
if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-tree failed' };
|
|
1102
|
+
return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: '0' })) };
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const listPlansIndexEntries = ({ git, cwd }) => {
|
|
1106
|
+
const r = git(['ls-files', '--cached', '--stage', '-z', '--', PLANS_REL], cwd);
|
|
1107
|
+
if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-files failed' };
|
|
1108
|
+
return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: meta[2] ?? '0' })) };
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const plansChainRels = ({ slug, seedName }) =>
|
|
1112
|
+
[...new Set([`${PLANS_REL}/${handoffBasename(slug)}`, `${PLANS_REL}/${seedName}`])];
|
|
1113
|
+
|
|
1114
|
+
// The finding kind is decided over EVERY entry of the path from BOTH sources; an unmerged
|
|
1115
|
+
// index state — several stages OR a single non-zero stage — is irregular outright, never a
|
|
1116
|
+
// surgical-recovery candidate.
|
|
1117
|
+
const classifyPlansChainFinding = ({ headEntries = [], indexEntries = [] }) => {
|
|
1118
|
+
const kinds = [...headEntries, ...indexEntries].map(({ mode }) => plansChainKind(mode));
|
|
1119
|
+
const unmerged = indexEntries.length > 1 || indexEntries.some(({ stage }) => stage !== '0');
|
|
1120
|
+
const irregular = unmerged || kinds.some((k) => k !== 'regular');
|
|
1121
|
+
return { irregular, kind: unmerged ? 'unmerged-index' : (kinds.find((k) => k !== 'regular') ?? 'regular') };
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const literalIndexRemoval = (rel) => `git rm --cached -- ${shellQuoteArg(`:(literal)${rel}`)}`;
|
|
1125
|
+
|
|
1126
|
+
// FRESH, pre-mutation: the tree proof reads EXACTLY the captured commit — the same OID the
|
|
1127
|
+
// branch is cut from — so probe and mutation bind to one object.
|
|
1128
|
+
const assertPlansChainCleanAtCapturedOid = ({ git, root, oid, rels }) => {
|
|
1129
|
+
const tree = listPlansTreeEntries({ git, cwd: root, treeish: oid });
|
|
1130
|
+
if (tree.error) {
|
|
1131
|
+
throw stop(`provision: the plans-chain tree probe failed at the captured commit ${oid}: ${tree.error}`);
|
|
1132
|
+
}
|
|
1133
|
+
for (const rel of rels) {
|
|
1134
|
+
const headEntries = tree.entries.get(rel);
|
|
1135
|
+
if (headEntries === undefined) continue;
|
|
1136
|
+
const { irregular, kind } = classifyPlansChainFinding({ headEntries });
|
|
1137
|
+
if (irregular) {
|
|
1138
|
+
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.`);
|
|
1139
|
+
}
|
|
1140
|
+
// The reachable poisoned state usually has the removal ALREADY STAGED at main (with the
|
|
1141
|
+
// path still index-tracked the docs/plans entry gate refuses first) — the advice must
|
|
1142
|
+
// match the live index, else the printed command fails «pathspec did not match».
|
|
1143
|
+
const index = listPlansIndexEntries({ git, cwd: root });
|
|
1144
|
+
if (index.error) {
|
|
1145
|
+
throw stop(`provision: the plans-chain index probe failed at main: ${index.error}`);
|
|
1146
|
+
}
|
|
1147
|
+
const stillIndexed = index.entries.has(rel);
|
|
1148
|
+
throw stop([
|
|
1149
|
+
`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.`,
|
|
1150
|
+
...(stillIndexed
|
|
1151
|
+
? ['Recovery (surgical, at main): untrack it and commit the removal —', ` ${literalIndexRemoval(rel)}`, 'then commit and re-run provision.']
|
|
1152
|
+
: ['Recovery (surgical, at main): the removal is already staged — commit it, then re-run provision.']),
|
|
1153
|
+
`${PLANS_REL} must stay git-ignored and untracked.`,
|
|
1154
|
+
].join('\n'));
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
// FRESH, post-add, pre-write: a post-checkout hook may force-add or even COMMIT a
|
|
1159
|
+
// plans-chain path on the new branch (a committed add leaves the tree clean, invisible to
|
|
1160
|
+
// the blanket verify) — re-probe the live index AND the branch HEAD before the first write.
|
|
1161
|
+
const assertPlansChainCleanPostAdd = ({ git, wtRoot, branch, rels }) => {
|
|
1162
|
+
const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
|
|
1163
|
+
if (head.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${head.error}`);
|
|
1164
|
+
const index = listPlansIndexEntries({ git, cwd: wtRoot });
|
|
1165
|
+
if (index.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${index.error}`);
|
|
1166
|
+
const offending = rels
|
|
1167
|
+
.filter((rel) => head.entries.has(rel) || index.entries.has(rel))
|
|
1168
|
+
.map((rel) => ({ rel, ...classifyPlansChainFinding({ headEntries: head.entries.get(rel), indexEntries: index.entries.get(rel) }) }));
|
|
1169
|
+
if (offending.length === 0) return;
|
|
1170
|
+
// The kind matrix holds in every lane: ANY irregular entry → fail-closed, no recovery
|
|
1171
|
+
// command at all. Both STOPs suppress the generic kept-worktree NOTE — its --resume advice
|
|
1172
|
+
// would steer away from (or contradict) the honest lane here.
|
|
1173
|
+
if (offending.some(({ irregular }) => irregular)) {
|
|
1174
|
+
throw stop([
|
|
1175
|
+
'provision refuses: the new worktree already tracks irregular plans-chain entr(y/ies) before any provision write — fail-closed:',
|
|
1176
|
+
...offending.map(({ rel, kind }) => ` ${rel} (${kind})`),
|
|
1177
|
+
'Nothing was written into the worktree.',
|
|
1178
|
+
].join('\n'), { suppressKeptNote: true });
|
|
1179
|
+
}
|
|
1180
|
+
throw stop([
|
|
1181
|
+
'provision refuses: the new worktree already tracks plans-chain path(s) before any provision write — a post-checkout hook force-added or committed:',
|
|
1182
|
+
...offending.map(({ rel }) => ` ${rel}`),
|
|
1183
|
+
'A tracked plans-chain path is undeliverable and the worktree cannot converge; nothing was written into it.',
|
|
1184
|
+
'Recovery, in order:',
|
|
1185
|
+
' 1. inspect the worktree and copy OUT any hook-created content you value',
|
|
1186
|
+
' 2. fix or remove the offending post-checkout hook',
|
|
1187
|
+
' 3. with consent, remove the kept worktree from MAIN:',
|
|
1188
|
+
` git worktree remove --force ${shellQuoteArg(wtRoot)}`,
|
|
1189
|
+
` git branch -D ${shellQuoteArg(branch)}`,
|
|
1190
|
+
' 4. re-run provision',
|
|
1191
|
+
].join('\n'), { suppressKeptNote: true });
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
// RESUME: the branch HEAD tree is probed UNCONDITIONALLY plus the live index, BEFORE the
|
|
1195
|
+
// handoff-identity reads — the probe result is collected first; the identity parse steers
|
|
1196
|
+
// only the recovery wording (a malformed record never cancels a tracked classification).
|
|
1197
|
+
const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels, fs }) => {
|
|
1198
|
+
const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
|
|
1199
|
+
if (head.error) {
|
|
1200
|
+
throw stop(`--resume: the plans-chain probe failed (branch HEAD tree): ${head.error} — the prior record is untouched`);
|
|
1201
|
+
}
|
|
1202
|
+
const index = listPlansIndexEntries({ git, cwd: wtRoot });
|
|
1203
|
+
if (index.error) {
|
|
1204
|
+
throw stop(`--resume: the plans-chain probe failed (live index): ${index.error} — the prior record is untouched`);
|
|
1205
|
+
}
|
|
1206
|
+
const headTracked = [];
|
|
1207
|
+
const indexOnly = [];
|
|
1208
|
+
for (const rel of rels) {
|
|
1209
|
+
const headEntries = head.entries.get(rel);
|
|
1210
|
+
const indexEntries = index.entries.get(rel);
|
|
1211
|
+
if (headEntries !== undefined) headTracked.push({ rel, ...classifyPlansChainFinding({ headEntries, indexEntries }) });
|
|
1212
|
+
else if (indexEntries !== undefined) indexOnly.push({ rel, ...classifyPlansChainFinding({ indexEntries }) });
|
|
1213
|
+
}
|
|
1214
|
+
if (headTracked.length === 0 && indexOnly.length === 0) return;
|
|
1215
|
+
|
|
1216
|
+
// Best-effort identity read, for RECOVERY WORDING only: destructive removal is offered
|
|
1217
|
+
// ONLY where the record identity provably binds this worktree. The failure REASON is kept
|
|
1218
|
+
// and printed INLINE — advising a re-run would loop (this probe fires before the shipped
|
|
1219
|
+
// identity STOP can ever be reached).
|
|
1220
|
+
const identity = (() => {
|
|
1221
|
+
try {
|
|
1222
|
+
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
|
|
1223
|
+
if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
|
|
1224
|
+
const record = parseProvisionRecord(String(rf.bytes));
|
|
1225
|
+
if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}` };
|
|
1226
|
+
if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}` };
|
|
1227
|
+
return { binds: true, reason: null };
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
return { binds: false, reason: errorText(err) };
|
|
1230
|
+
}
|
|
1231
|
+
})();
|
|
1232
|
+
|
|
1233
|
+
// The kind matrix governs the WHOLE set: any irregular finding (an irregular mode in
|
|
1234
|
+
// EITHER source, or a multistage/unmerged index entry) withholds EVERY recovery command —
|
|
1235
|
+
// destructive or surgical advice for a regular sibling would also cover the irregular
|
|
1236
|
+
// content the contract fails closed on.
|
|
1237
|
+
if ([...headTracked, ...indexOnly].some(({ irregular }) => irregular)) {
|
|
1238
|
+
throw stop([
|
|
1239
|
+
'--resume refuses: irregular plans-chain entr(y/ies) are tracked in this worktree — fail-closed, recovery commands withheld for the whole set:',
|
|
1240
|
+
...headTracked.map(({ rel, kind }) => ` ${rel} (${kind}, branch HEAD)`),
|
|
1241
|
+
...indexOnly.map(({ rel, kind }) => ` ${rel} (${kind}, index only)`),
|
|
1242
|
+
'The prior provision record is untouched.',
|
|
1243
|
+
].join('\n'));
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// A poisoned base dominates the scenario: consented abandon disposes the WHOLE worktree,
|
|
1247
|
+
// so per-path index-only surgical commands beside it would be contradictory noise — the
|
|
1248
|
+
// index-only paths are LISTED as findings and the one salvage-then-abandon lane covers all.
|
|
1249
|
+
if (headTracked.length > 0) {
|
|
1250
|
+
throw stop([
|
|
1251
|
+
...headTracked.map(({ rel }) =>
|
|
1252
|
+
`--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.`),
|
|
1253
|
+
...indexOnly.map(({ rel }) =>
|
|
1254
|
+
`--resume refuses: ${rel} is staged in this worktree's index — undeliverable beside the poisoned base above.`),
|
|
1255
|
+
'Recovery, in order:',
|
|
1256
|
+
' 1. salvage FIRST: copy the live files (and anything else you value) OUT of the worktree — plain file copies, no identity needed',
|
|
1257
|
+
identity.binds
|
|
1258
|
+
? ` 2. then, with consent: ${composeOwnToolPrefix(root)} ${composeCleanupCommand({ slug, branch, abandon: true })} (DESTROYS unlanded work)`
|
|
1259
|
+
: ` 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`,
|
|
1260
|
+
].join('\n'));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const lines = [];
|
|
1264
|
+
for (const { rel } of indexOnly) {
|
|
1265
|
+
// The rule probe reads the EXACT offending path, index-independently: `--no-index` skips
|
|
1266
|
+
// the index consultation that makes a plain check-ignore lie about tracked paths
|
|
1267
|
+
// (live-probed), and a fabricated sibling name could disagree with the real rule set.
|
|
1268
|
+
const rule = git(['check-ignore', '--no-index', '--', rel], wtRoot);
|
|
1269
|
+
if (rule.status !== 0 && rule.status !== 1) {
|
|
1270
|
+
throw stop(`--resume: the ignore-rule probe failed for ${rel}: ${(rule.stderr || rule.stdout).trim()} — the prior record is untouched`);
|
|
1271
|
+
}
|
|
1272
|
+
const ignoreRuleGone = rule.status === 1;
|
|
1273
|
+
lines.push(
|
|
1274
|
+
`--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.`,
|
|
1275
|
+
'Recovery (surgical, in the worktree):',
|
|
1276
|
+
` 1. ${literalIndexRemoval(rel)}`,
|
|
1277
|
+
` (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)`,
|
|
1278
|
+
...(ignoreRuleGone ? [` 2. restore the git-ignore rule covering ${rel} (provision requires ${PLANS_REL} ignored)`] : []),
|
|
1279
|
+
' then re-run --resume.',
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
throw stop(lines.join('\n'));
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1062
1285
|
// ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
|
|
1063
1286
|
|
|
1064
1287
|
// The orientation facts a fresh satellite session cannot derive from its own checkout. They are
|
|
@@ -1075,8 +1298,139 @@ export const NODE_MODULES_NONE = 'no-dependencies';
|
|
|
1075
1298
|
// cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
|
|
1076
1299
|
// into the worktrees mode doc; every ownership STOP emits it.
|
|
1077
1300
|
export const CLEANUP_OWNERSHIP_RULE = "node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed";
|
|
1301
|
+
export const RESUME_VERIFY_RULE = "the resume verify proves only what THIS run placed or kept: every journaled leaf must be tracked or ignored in the worktree, an untracked owned leaf or any probe error stops the run naming the exact leaf, and every other path — the session's own work — is never probed and never a stop cause; a first provision keeps the blanket clean-tree verify";
|
|
1078
1302
|
export const INCLUDE_IDENTITY_RULE = 'An --include source is copied only through the identity door: a file include must still match the identity preflight recorded (device, inode, kind), a directory include root is re-checked at walk start, and every copied file is proven, with both descriptors open, not to be the node that IS the door-time queue — an absent queue keeps the lexical guard alone, and anything unprovable stops the copy';
|
|
1079
1303
|
|
|
1304
|
+
// ── the placement journal (slice R2) ───────────────────────────────────────────────────
|
|
1305
|
+
// The resume verify proves PER PLACED PATH, so it needs a proof list: this run's live placement
|
|
1306
|
+
// journal over a CLOSED-WORLD registry — the surfaces are enumerated POSITIVELY, so no universal
|
|
1307
|
+
// "every mutation" claim exists to puncture. Leaf-only (git tracks no directories), KIND-GATED (a
|
|
1308
|
+
// live node whose kind differs from what the lane's SOURCE places is the pre-existing kept-exit
|
|
1309
|
+
// residual — SESSION for the verifier), and FROZEN at the verify: the sole post-verify write, the
|
|
1310
|
+
// record refresh, is permitted only at its already-journaled path.
|
|
1311
|
+
export const PLACEMENT_REGISTRY = Object.freeze([
|
|
1312
|
+
'handoff-stub',
|
|
1313
|
+
'seed-plan',
|
|
1314
|
+
'copy-set-leaf',
|
|
1315
|
+
'include-leaf',
|
|
1316
|
+
'node-modules-link',
|
|
1317
|
+
'vscode-settings',
|
|
1318
|
+
'pin-rebase-target',
|
|
1319
|
+
'record-refresh',
|
|
1320
|
+
]);
|
|
1321
|
+
const PLACEMENT_SURFACES = new Set(PLACEMENT_REGISTRY);
|
|
1322
|
+
// The ONE droppable class: an --include destination may be salvaged/relocated together with
|
|
1323
|
+
// dropping its source. Every other surface is mandatory — the next resume re-places it, so removal
|
|
1324
|
+
// is not a convergent fix and is never advised.
|
|
1325
|
+
const DROPPABLE_SURFACES = new Set(['include-leaf']);
|
|
1326
|
+
|
|
1327
|
+
const journalKindMatches = (fs, abs, kind) => {
|
|
1328
|
+
const st = lstatNoFollow(fs.lstat, abs);
|
|
1329
|
+
if (st === null) return false;
|
|
1330
|
+
return kind === 'symlink' ? st.isSymbolicLink() : !st.isSymbolicLink() && st.isFile();
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
export const createPlacementJournal = ({ wtRoot, fs }) => {
|
|
1334
|
+
const members = new Map();
|
|
1335
|
+
const state = { frozen: false };
|
|
1336
|
+
return {
|
|
1337
|
+
record: ({ rel, surface, outcome, kind = 'file', root = null }) => {
|
|
1338
|
+
if (!PLACEMENT_SURFACES.has(surface)) {
|
|
1339
|
+
throw stop(`placement journal: "${surface}" is not a registry surface — the placement registry is closed`);
|
|
1340
|
+
}
|
|
1341
|
+
if (state.frozen) {
|
|
1342
|
+
if (!members.has(rel)) {
|
|
1343
|
+
throw stop(`placement journal: refusing a post-verify write at an unjournaled path: ${rel}`);
|
|
1344
|
+
}
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
// The kind gate is the KEPT-outcome residual only. A node THIS attempt created is owned by
|
|
1348
|
+
// construction: dropping it here because its kind changed after the write would leave a path
|
|
1349
|
+
// provision just placed unproven — the opposite of the fail-safe floor.
|
|
1350
|
+
if (outcome !== 'written' && !journalKindMatches(fs, join(wtRoot, rel), kind)) return;
|
|
1351
|
+
const entry = { rel, surface, outcome, ...(root === null ? {} : { root }) };
|
|
1352
|
+
const prior = members.get(rel);
|
|
1353
|
+
if (prior === undefined) members.set(rel, entry);
|
|
1354
|
+
else if (outcome === 'written' && prior.outcome !== 'written') members.set(rel, { ...prior, outcome: 'written' });
|
|
1355
|
+
},
|
|
1356
|
+
freeze: () => {
|
|
1357
|
+
state.frozen = true;
|
|
1358
|
+
return [...members.values()];
|
|
1359
|
+
},
|
|
1360
|
+
};
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
// The per-owned-path lane probe, LITERAL by construction (D11) — live-probed against git 2.43:
|
|
1364
|
+
// `ls-files` accepts an explicit `:(literal)` pathspec, but `check-ignore` REFUSES pathspec magic
|
|
1365
|
+
// outright ("pathspec magic not supported by this command") AND, with the index in play, answers
|
|
1366
|
+
// for a name that GLOB-matches a tracked sibling — a file literally named `feature-[a].md` reads
|
|
1367
|
+
// as "not ignored" once `feature-a.md` is tracked. `--no-index` removes that shadow, leaving a
|
|
1368
|
+
// pure ignore-rule match. Tracked priority stays a VERSION-INDEPENDENT invariant precisely because
|
|
1369
|
+
// this probe decides it FIRST, on its own literal pathspec, instead of leaning on whatever
|
|
1370
|
+
// index-awareness a given git version bakes into `check-ignore`.
|
|
1371
|
+
const probeOwnedLane = ({ git, wtRoot, rel }) => {
|
|
1372
|
+
const tracked = git(['ls-files', '-z', '--', literalPathspec(rel)], wtRoot);
|
|
1373
|
+
if (tracked.status !== 0) {
|
|
1374
|
+
return { lane: 'probe-error', detail: `git ls-files failed: ${(tracked.stderr || tracked.stdout).trim()}` };
|
|
1375
|
+
}
|
|
1376
|
+
// A non-empty result is NOT proof: live-probed on git 2.43, a pathspec naming a DIRECTORY lists
|
|
1377
|
+
// its tracked DESCENDANTS (`:(literal)notes` → notes/note.md …). Only a field byte-equal to the
|
|
1378
|
+
// probed path proves THIS path tracked; anything else falls through to the ignore/untracked
|
|
1379
|
+
// proof, so a non-leaf member fails closed instead of passing on a descendant's back.
|
|
1380
|
+
if (nulFields(tracked.stdout).includes(rel)) return { lane: 'tracked' };
|
|
1381
|
+
const ignored = git(['check-ignore', '--no-index', '--', rel], wtRoot);
|
|
1382
|
+
if (ignored.status === 0) return { lane: 'ignored' };
|
|
1383
|
+
if (ignored.status === 1) return { lane: 'untracked' };
|
|
1384
|
+
return { lane: 'probe-error', detail: `git check-ignore failed: ${(ignored.stderr || ignored.stdout).trim()}` };
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// Recovery for a DROPPABLE surface is emitted ONCE per include ROOT, never per leaf: dropping the
|
|
1388
|
+
// flag orphans every copy under that root (cleanup derives its ownership from `record.includes`),
|
|
1389
|
+
// so leaf-only advice cannot converge. It never offers REMOVAL: the journal cannot see session
|
|
1390
|
+
// content or kind-excluded nodes sitting inside that root, so no derived `rm` could be proven safe.
|
|
1391
|
+
// It also never says "leave it here": an orphaned destination is exactly what stops land — the
|
|
1392
|
+
// convergent action is moving the whole root OUT of the worktree.
|
|
1393
|
+
const droppableRootRecovery = (root) =>
|
|
1394
|
+
` ${root}: salvage or relocate the whole include destination root OUT of the worktree — its contents are preserved wherever you move them — AND drop \`--include ${root}\` in the same run; either alone recurs (a remaining source re-creates the copy, and an orphaned destination stops land)`;
|
|
1395
|
+
|
|
1396
|
+
const ownedRecoveryLines = (failures) => {
|
|
1397
|
+
const lines = [];
|
|
1398
|
+
const seenRoots = new Set();
|
|
1399
|
+
for (const failure of failures) {
|
|
1400
|
+
if (DROPPABLE_SURFACES.has(failure.surface) && failure.root) {
|
|
1401
|
+
if (seenRoots.has(failure.root)) continue;
|
|
1402
|
+
seenRoots.add(failure.root);
|
|
1403
|
+
lines.push(droppableRootRecovery(failure.root));
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
lines.push(` ${failure.rel}: restore the ignore rule covering it in this worktree (.gitignore or the shared exclude), then re-run --resume`);
|
|
1407
|
+
}
|
|
1408
|
+
return lines;
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
// A set containing an unprovable lane withholds EVERY recovery command: advice derived from a
|
|
1412
|
+
// half-read tree is worse than none (the R1 mixed-findings discipline).
|
|
1413
|
+
const composeOwnedVerifyStop = (failures) => [
|
|
1414
|
+
'post-provision verify failed — provision cannot prove the git lane of a path it placed or kept:',
|
|
1415
|
+
...failures.map(({ rel, surface, outcome, lane, detail }) => (lane === 'probe-error'
|
|
1416
|
+
? ` ${rel} (${surface}, ${outcome}) — lane unprovable: ${detail}`
|
|
1417
|
+
: ` ${rel} (${surface}, ${outcome}) — untracked`)),
|
|
1418
|
+
...(failures.some((f) => f.lane === 'probe-error')
|
|
1419
|
+
? ['No recovery command is offered: a lane probe failed, so the tree state is unproven.']
|
|
1420
|
+
: ['Recovery (convergent — through land preflight, not merely the next resume):', ...ownedRecoveryLines(failures)]),
|
|
1421
|
+
RESUME_VERIFY_RULE,
|
|
1422
|
+
].join('\n');
|
|
1423
|
+
|
|
1424
|
+
const verifyPlacedPaths = ({ git, wtRoot, members }) => {
|
|
1425
|
+
const failures = [];
|
|
1426
|
+
for (const member of members) {
|
|
1427
|
+
const probe = probeOwnedLane({ git, wtRoot, rel: member.rel });
|
|
1428
|
+
if (probe.lane === 'tracked' || probe.lane === 'ignored') continue;
|
|
1429
|
+
failures.push({ ...member, ...probe });
|
|
1430
|
+
}
|
|
1431
|
+
if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1080
1434
|
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
1081
1435
|
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
1082
1436
|
// (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
|
|
@@ -1237,10 +1591,12 @@ const pendingHandoffFields = ({ root, slug, branch }) =>
|
|
|
1237
1591
|
({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
|
|
1238
1592
|
|
|
1239
1593
|
// The stub is written only when ABSENT; the final record surgically replaces the tool section.
|
|
1240
|
-
const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) => {
|
|
1594
|
+
const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report, journal = NO_JOURNAL }) => {
|
|
1595
|
+
const rel = `${PLANS_REL}/${handoffBasename(slug)}`;
|
|
1241
1596
|
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
1242
1597
|
const cur = readFileNoFollow(fs, dst);
|
|
1243
1598
|
if (cur.bytes) {
|
|
1599
|
+
journal.record({ rel, surface: 'handoff-stub', outcome: 'kept' });
|
|
1244
1600
|
report.push(' handoff: kept (already present)');
|
|
1245
1601
|
return;
|
|
1246
1602
|
}
|
|
@@ -1250,14 +1606,18 @@ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) =>
|
|
|
1250
1606
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1251
1607
|
fs.mkdir(dirname(dst));
|
|
1252
1608
|
writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
|
|
1609
|
+
journal.record({ rel, surface: 'handoff-stub', outcome: 'written' });
|
|
1253
1610
|
};
|
|
1254
1611
|
|
|
1255
|
-
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
|
|
1612
|
+
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal = NO_JOURNAL }) => {
|
|
1256
1613
|
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
1257
1614
|
const cur = readFileNoFollow(fs, dst);
|
|
1258
1615
|
if (!cur.bytes) {
|
|
1259
1616
|
throw stop(`the handoff at ${PLANS_REL}/${handoffBasename(slug)} is not readable as a regular file — fix or remove it, then re-run --resume`);
|
|
1260
1617
|
}
|
|
1618
|
+
// The freeze lock, checked AFTER the content door so a node problem keeps its own precise error:
|
|
1619
|
+
// this is the ONLY post-verify write, and only at the path the stub already journaled.
|
|
1620
|
+
journal.record({ rel: `${PLANS_REL}/${handoffBasename(slug)}`, surface: 'record-refresh', outcome: 'kept' });
|
|
1261
1621
|
const section = locateProvisionRecordSection(String(cur.bytes));
|
|
1262
1622
|
const updated = `${section.source.slice(0, section.start)}${composeProvisionRecordSection(fields)}${section.source.slice(section.end)}`;
|
|
1263
1623
|
writeContainedFileAtomic(wtRoot, dst, updated, fs, { stop: (m) => stop(m) });
|
|
@@ -1301,9 +1661,10 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
|
1301
1661
|
return { srcAbs: srcReal, name };
|
|
1302
1662
|
};
|
|
1303
1663
|
|
|
1304
|
-
const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
1664
|
+
const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL }) => {
|
|
1305
1665
|
const dst = join(wtRoot, PLANS_REL, name);
|
|
1306
1666
|
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1667
|
+
journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'kept' });
|
|
1307
1668
|
report.push(` kept (already present): ${PLANS_REL}/${name}`);
|
|
1308
1669
|
return;
|
|
1309
1670
|
}
|
|
@@ -1312,6 +1673,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1312
1673
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1313
1674
|
fs.mkdir(dirname(dst));
|
|
1314
1675
|
writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
|
|
1676
|
+
journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'written' });
|
|
1315
1677
|
report.push(` seeded plan: ${PLANS_REL}/${name}`);
|
|
1316
1678
|
};
|
|
1317
1679
|
|
|
@@ -1320,7 +1682,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1320
1682
|
// It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
|
|
1321
1683
|
// realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
|
|
1322
1684
|
// redirect an include at the shared series index between the check and the copy.
|
|
1323
|
-
const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied }) => {
|
|
1685
|
+
const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied, journal = NO_JOURNAL }) => {
|
|
1324
1686
|
const recorded = [];
|
|
1325
1687
|
const queuePath = join(rootReal, PLANS_REL, QUEUE_BASENAME);
|
|
1326
1688
|
for (const { rel, real, identity } of includeSources) {
|
|
@@ -1358,7 +1720,7 @@ const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs,
|
|
|
1358
1720
|
const door = identity.kind === 'file'
|
|
1359
1721
|
? { identity, queuePath, fresh: !resume }
|
|
1360
1722
|
: { queuePath, fresh: !resume };
|
|
1361
|
-
copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied, door });
|
|
1723
|
+
copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied, door, journal, surface: 'include-leaf', journalRoot: rel });
|
|
1362
1724
|
recorded.push(rel);
|
|
1363
1725
|
}
|
|
1364
1726
|
return recorded;
|
|
@@ -1527,14 +1889,18 @@ const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
|
|
|
1527
1889
|
return resolveInstallAdvice({ wtRoot, fs }).instruction;
|
|
1528
1890
|
};
|
|
1529
1891
|
|
|
1530
|
-
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
|
|
1892
|
+
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1893
|
+
// The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
|
|
1894
|
+
// directory (a real install) is the user's, never provision's to prove or advise on.
|
|
1895
|
+
const journalLink = (outcome) => journal.record({ rel: NODE_MODULES_REL, surface: 'node-modules-link', outcome, kind: 'symlink' });
|
|
1531
1896
|
const install = resolveInstallAdvice({ wtRoot, fs });
|
|
1532
1897
|
if (installFlag) {
|
|
1533
|
-
const dst = join(wtRoot,
|
|
1898
|
+
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1534
1899
|
const existing = lstatNoFollow(fs.lstat, dst);
|
|
1535
1900
|
if (existing !== null && existing.isSymbolicLink()) {
|
|
1536
1901
|
// isolation only exists BEFORE the link: an install through it would write into MAIN
|
|
1537
1902
|
const separator = install.command === null ? ' — ' : ' && ';
|
|
1903
|
+
journalLink('kept');
|
|
1538
1904
|
report.push(` node_modules: existing symlink kept — for isolation remove it first: rm ${shellQuoteArg(dst)}${separator}${install.instruction}`);
|
|
1539
1905
|
return 'install-printed-unlink-first';
|
|
1540
1906
|
}
|
|
@@ -1546,8 +1912,9 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1546
1912
|
// LIVE STATE WINS the whole default lane: a node already at the worktree — a directory, or a
|
|
1547
1913
|
// symlink an earlier provision left, even dangling — is what the record states; reporting
|
|
1548
1914
|
// MAIN's state (`absent`) beside an existing node would contradict record.install.
|
|
1549
|
-
const dst = join(wtRoot,
|
|
1915
|
+
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1550
1916
|
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1917
|
+
journalLink('kept');
|
|
1551
1918
|
report.push(' node_modules: already present in the worktree');
|
|
1552
1919
|
return 'present';
|
|
1553
1920
|
}
|
|
@@ -1599,12 +1966,20 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1599
1966
|
report.push(` node_modules: symlink failed (${err?.code ?? 'error'}) — ${install.instruction}`);
|
|
1600
1967
|
return 'symlink-failed';
|
|
1601
1968
|
}
|
|
1969
|
+
journalLink('written');
|
|
1602
1970
|
report.push(` node_modules: symlinked -> ${mainNm} (shared MUTABLE cache — writes through it hit MAIN's node_modules; isolation: --install; workspace self-links resolve to MAIN sources)`);
|
|
1603
1971
|
return 'symlinked';
|
|
1604
1972
|
};
|
|
1605
1973
|
|
|
1606
|
-
const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
1974
|
+
const provisionVscode = ({ root, wtRoot, slug, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1607
1975
|
const relPath = '.vscode/settings.json';
|
|
1976
|
+
// An EXISTING satellite destination is journaled FIRST, before every source-side and gate-side
|
|
1977
|
+
// early return: the doors decide only what this run WRITES, while the journal decides what gets
|
|
1978
|
+
// PROVEN. Membership must not depend on MAIN's current state — a file an earlier run placed
|
|
1979
|
+
// would otherwise skip here (MAIN lost its .vscode dir, MAIN's copy became tracked, or the
|
|
1980
|
+
// ignore rule was lost) and ride a successful resume out as a land-blocking leftover.
|
|
1981
|
+
const present = lstatNoFollow(fs.lstat, join(wtRoot, relPath)) !== null;
|
|
1982
|
+
if (present) journal.record({ rel: relPath, surface: 'vscode-settings', outcome: 'kept' });
|
|
1608
1983
|
const vscodeDir = lstatNoFollow(fs.lstat, join(root, '.vscode'));
|
|
1609
1984
|
if (vscodeDir === null || !vscodeDir.isDirectory()) {
|
|
1610
1985
|
report.push(' .vscode: main has no .vscode/ dir — window title not written');
|
|
@@ -1622,7 +1997,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1622
1997
|
report.push(` .vscode: ${relPath} is not ignored in the worktree — skipped (it would become a land leftover)`);
|
|
1623
1998
|
return 'skipped-not-ignored';
|
|
1624
1999
|
}
|
|
1625
|
-
if (
|
|
2000
|
+
if (present) {
|
|
1626
2001
|
report.push(' .vscode: kept (already present)');
|
|
1627
2002
|
return 'kept';
|
|
1628
2003
|
}
|
|
@@ -1651,6 +2026,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1651
2026
|
guardDst(fs, wtRoot, join(wtRoot, '.vscode'));
|
|
1652
2027
|
fs.mkdir(join(wtRoot, '.vscode'));
|
|
1653
2028
|
writeContainedFileAtomic(wtRoot, join(wtRoot, relPath), body, fs, { stop: (m) => stop(m) });
|
|
2029
|
+
journal.record({ rel: relPath, surface: 'vscode-settings', outcome: 'written' });
|
|
1654
2030
|
report.push(` .vscode: ${relPath} written (window.title = ${slug})`);
|
|
1655
2031
|
return 'written';
|
|
1656
2032
|
};
|
|
@@ -1658,11 +2034,12 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1658
2034
|
// tracked/untracked is decided by GIT (a run-local copy log lies after a crash-resume); an
|
|
1659
2035
|
// untracked pin-carrying file is rewritten ONLY when its bytes equal the MAIN source or its
|
|
1660
2036
|
// already-rebased form — anything else is user work and stays byte-untouched (reported).
|
|
1661
|
-
const rebasePins = ({ root, wtRoot, git, fs, report }) => {
|
|
2037
|
+
const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1662
2038
|
for (const target of REBASE_TARGETS) {
|
|
1663
2039
|
const wtAbs = join(wtRoot, target);
|
|
1664
2040
|
const cur = readFileNoFollow(fs, wtAbs);
|
|
1665
2041
|
if (cur.absent) continue;
|
|
2042
|
+
if (cur.bytes) journal.record({ rel: target, surface: 'pin-rebase-target', outcome: 'kept' });
|
|
1666
2043
|
if (!cur.bytes) {
|
|
1667
2044
|
report.push(` ${target}: ${cur.unsafe ? 'not a regular file' : `unreadable (${cur.error})`} — left untouched`);
|
|
1668
2045
|
continue;
|
|
@@ -1809,13 +2186,22 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1809
2186
|
}
|
|
1810
2187
|
const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
|
|
1811
2188
|
if (wtCommon !== commonDir) throw stop(`--resume identity mismatch: ${targetReal} does not share this repo's git dir`);
|
|
2189
|
+
assertPlansChainCleanOnResume({ git, root, wtRoot: targetReal, slug, branch, rels: plansChainRels({ slug, seedName: seed.name }), fs });
|
|
1812
2190
|
assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
|
|
1813
2191
|
assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
|
|
1814
2192
|
runWritabilityProbe();
|
|
1815
2193
|
report.push(`resuming provision at ${targetReal} (branch ${branch})`);
|
|
1816
2194
|
} else {
|
|
1817
2195
|
runWritabilityProbe();
|
|
1818
|
-
|
|
2196
|
+
// ONE captured commit binds the plans-chain tree proof AND the branch cut: a clean
|
|
2197
|
+
// captured OID with a subsequently-moved HEAD still cuts from the captured OID.
|
|
2198
|
+
const capture = git(['rev-parse', 'HEAD'], root);
|
|
2199
|
+
if (capture.status !== 0) {
|
|
2200
|
+
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`);
|
|
2201
|
+
}
|
|
2202
|
+
const capturedOid = capture.stdout.replace(/\r?\n$/, '');
|
|
2203
|
+
assertPlansChainCleanAtCapturedOid({ git, root, oid: capturedOid, rels: plansChainRels({ slug, seedName: seed.name }) });
|
|
2204
|
+
const add = git(['worktree', 'add', '-b', branch, targetReal, capturedOid], root);
|
|
1819
2205
|
if (add.status !== 0) {
|
|
1820
2206
|
throw stop(
|
|
1821
2207
|
[
|
|
@@ -1830,9 +2216,12 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1830
2216
|
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1831
2217
|
// hand back the exact finish command, never just the local cause
|
|
1832
2218
|
try {
|
|
2219
|
+
if (!flags.resume) {
|
|
2220
|
+
assertPlansChainCleanPostAdd({ git, wtRoot: targetReal, branch, rels: plansChainRels({ slug, seedName: seed.name }) });
|
|
2221
|
+
}
|
|
1833
2222
|
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
|
|
1834
2223
|
} catch (err) {
|
|
1835
|
-
if (!flags.resume && err?.message) {
|
|
2224
|
+
if (!flags.resume && err?.message && !err.suppressKeptNote) {
|
|
1836
2225
|
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
2226
|
}
|
|
1838
2227
|
throw err;
|
|
@@ -1840,41 +2229,27 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1840
2229
|
};
|
|
1841
2230
|
|
|
1842
2231
|
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
|
|
1843
|
-
|
|
2232
|
+
// THIS run's proof set: every lane journals the leaf it placed or kept, and nothing else is ever
|
|
2233
|
+
// examined by the resume verify — the session's own work is out of scope by construction.
|
|
2234
|
+
const journal = createPlacementJournal({ wtRoot: targetPath, fs });
|
|
2235
|
+
writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report, journal });
|
|
1844
2236
|
|
|
1845
2237
|
const copied = new Set();
|
|
1846
2238
|
report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
|
|
1847
2239
|
for (const pattern of provisionSet) {
|
|
1848
2240
|
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
1849
|
-
copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied });
|
|
2241
|
+
copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied, journal });
|
|
1850
2242
|
}
|
|
1851
2243
|
|
|
1852
|
-
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
|
|
1853
|
-
const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied });
|
|
2244
|
+
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report, journal });
|
|
2245
|
+
const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied, journal });
|
|
1854
2246
|
// Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
|
|
1855
2247
|
// lane and the record must state the SAME verdict.
|
|
1856
2248
|
const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
|
|
1857
|
-
const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report });
|
|
1858
|
-
const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
|
|
2249
|
+
const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report, journal });
|
|
2250
|
+
const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report, journal });
|
|
1859
2251
|
|
|
1860
|
-
rebasePins({ root, wtRoot: targetPath, git, fs, report });
|
|
1861
|
-
|
|
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
|
-
});
|
|
2252
|
+
rebasePins({ root, wtRoot: targetPath, git, fs, report, journal });
|
|
1878
2253
|
|
|
1879
2254
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
1880
2255
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
@@ -1883,12 +2258,54 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1883
2258
|
);
|
|
1884
2259
|
}
|
|
1885
2260
|
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
2261
|
+
// The journal FREEZES here: the verify is the boundary, and the only write past it — the record
|
|
2262
|
+
// refresh — is a registry surface permitted solely at its already-journaled path.
|
|
2263
|
+
const placed = journal.freeze();
|
|
2264
|
+
if (flags.resume) {
|
|
2265
|
+
// The resume lane proves PER OWNED PATH. No `git status` runs here at all: the session's work
|
|
2266
|
+
// is out of scope by construction, not by subtraction.
|
|
2267
|
+
verifyPlacedPaths({ git, wtRoot: targetPath, members: placed });
|
|
2268
|
+
} else {
|
|
2269
|
+
// `--untracked-files=normal` is EXPLICIT: `status.showUntrackedFiles=no` empties porcelain
|
|
2270
|
+
// output, which would silently turn this strict verify into a no-op. Default behavior is
|
|
2271
|
+
// unchanged — `normal` IS the default shape.
|
|
2272
|
+
const porcelain = git(['status', '--porcelain', '--untracked-files=normal'], targetPath);
|
|
2273
|
+
if (porcelain.status !== 0) throw stop(`git status failed in the worktree: ${porcelain.stderr.trim()}`);
|
|
2274
|
+
if (porcelain.stdout.trim() !== '') {
|
|
2275
|
+
throw stop(
|
|
2276
|
+
`post-provision verify failed — the worktree status is not clean (everything provision places must be ignored-or-tracked):\n${porcelain.stdout.trimEnd()}`,
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
|
|
2282
|
+
// the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
|
|
2283
|
+
// (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
|
|
2284
|
+
// fire, so a refresh failure here wraps its own: the verify PASSED and the exact re-run
|
|
2285
|
+
// command is the honest recovery, with the original cause preserved.
|
|
2286
|
+
try {
|
|
2287
|
+
writeHandoffRecord({
|
|
2288
|
+
wtRoot: targetPath,
|
|
2289
|
+
slug,
|
|
2290
|
+
branch,
|
|
2291
|
+
journal,
|
|
2292
|
+
fields: {
|
|
2293
|
+
slug,
|
|
2294
|
+
branch,
|
|
2295
|
+
includes: includesRecorded,
|
|
2296
|
+
nodeModules: nodeModulesMode,
|
|
2297
|
+
vscode: vscodeMode,
|
|
2298
|
+
install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
|
|
2299
|
+
...orientationFields({ root, slug }),
|
|
2300
|
+
},
|
|
2301
|
+
fs,
|
|
2302
|
+
report,
|
|
2303
|
+
});
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
if (flags.resume && err?.message) {
|
|
2306
|
+
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).`;
|
|
2307
|
+
}
|
|
2308
|
+
throw err;
|
|
1892
2309
|
}
|
|
1893
2310
|
|
|
1894
2311
|
const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
|