@sabaiway/agent-workflow-kit 3.11.0 → 3.13.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 +104 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/commit-guard.md +14 -7
- package/references/modes/worktrees.md +36 -4
- package/tools/commands.mjs +1 -1
- package/tools/commit-guard.mjs +139 -8
- package/tools/core-evidence.mjs +229 -18
- package/tools/doc-parity.mjs +7 -2
- package/tools/worktrees.mjs +202 -26
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,110 @@ 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.13.0 — the commit guard proves the INDEX carries the verified tree (AD-074)
|
|
8
|
+
|
|
9
|
+
`commit-guard --check` now refuses an index that lags the working tree, so «verified» and «about to
|
|
10
|
+
be committed» are the same bytes by construction instead of by operator discipline.
|
|
11
|
+
|
|
12
|
+
The gap it closes was real and it fired: the gates and the tree fingerprint both describe the
|
|
13
|
+
WORKING tree, while `git commit` builds the commit from the INDEX alone — and against an
|
|
14
|
+
otherwise-empty index the fingerprint is byte-identical whether a hunk sits staged or unstaged. A
|
|
15
|
+
lagging index therefore passed every gate and every guard arm, and the commit shipped a strict
|
|
16
|
+
subset of what was verified. The kit's own 3.12.0 release commit did exactly that: a fix landed
|
|
17
|
+
without its regression arm, and only the publish dispatcher's dirty-tree refusal caught it, one step
|
|
18
|
+
later.
|
|
19
|
+
|
|
20
|
+
- **A new FIRST refusal, ahead of the fingerprint.** It fires on tracked paths differing
|
|
21
|
+
index↔worktree or on reviewable untracked-not-ignored paths — the same never-committable stat
|
|
22
|
+
filter the fingerprint applies, so ignored paths and device/FIFO/socket nodes never refuse. It runs
|
|
23
|
+
first because its recovery re-stages the tree and re-mints the receipt, which re-decides every arm
|
|
24
|
+
below it.
|
|
25
|
+
- **The message is actionable and bounded.** Offending paths are named up to a cap with the
|
|
26
|
+
remainder stated, each rendered through the same escaper the review-state report uses, so no
|
|
27
|
+
filename can break or forge an output line. The recovery is the complete whole-tree sequence
|
|
28
|
+
(`git add -A`, re-run `--final`, commit the whole tree) — a truncated list cannot serve as a
|
|
29
|
+
complete `git add -- <paths>` argument.
|
|
30
|
+
- **The probes cannot be blinded by configuration or index bits.** Entries carrying `skip-worktree`
|
|
31
|
+
or `assume-unchanged` are invisible to `git diff`, so they are compared directly against the
|
|
32
|
+
worktree — type, symlink target, executable bit where `core.fileMode` applies, and the blob oid
|
|
33
|
+
through git's own clean filters. A de-materialised skip-worktree path is an ordinary sparse
|
|
34
|
+
checkout and never refuses; a missing assume-unchanged path does. The submodule probe forces
|
|
35
|
+
`--ignore-submodules=none`, so `diff.ignoreSubmodules` cannot erase a dirty submodule either.
|
|
36
|
+
- **A tracked submodule the index cannot prove current is named separately, with its own recovery**
|
|
37
|
+
(commit or clean inside the submodule, then stage the gitlink): a root-level `git add -A` cannot
|
|
38
|
+
reach a submodule's own worktree, so offering it there would be a recovery known in advance to
|
|
39
|
+
fail. A submodule whose gitlink itself carries one of those index bits is not probed at all — it
|
|
40
|
+
lags by construction. That reduction is deliberate: three review rounds each found a new way for a
|
|
41
|
+
nested probe to answer "clean" wrongly, so the guard stops asking rather than accumulate patches.
|
|
42
|
+
It stays a converging refusal (clear the bit and the guard falls silent), and an unflagged
|
|
43
|
+
submodule is judged exactly as before.
|
|
44
|
+
- **Fail-closed.** An undecidable git probe refuses with its own named cause. The guard's claim is
|
|
45
|
+
that the committed bytes ARE the verified bytes; it cannot make that claim about a tree it failed
|
|
46
|
+
to read.
|
|
47
|
+
|
|
48
|
+
**Behaviour change worth knowing:** a deliberate partial commit is now blocked. `git commit --only
|
|
49
|
+
<path>` hands the hook a temporary index carrying less than the verified tree — precisely the
|
|
50
|
+
blindness this closes — so it refuses. No opt-out flag exists, deliberately: a flag that suspends
|
|
51
|
+
the arm would suspend the guard's whole claim. `git commit --no-verify` remains the stated residual.
|
|
52
|
+
`git commit -a` is unaffected when it captures the whole verified tree, and refuses when a reviewable
|
|
53
|
+
untracked path would be left behind.
|
|
54
|
+
|
|
55
|
+
The tree fingerprint itself is unchanged — making it stage-sensitive would have closed the same gap
|
|
56
|
+
at the cost of the lockstep with the wrappers' bash twin. Internally, one new computation of the
|
|
57
|
+
index↔worktree split now serves both this arm and `isTreeClean`, so the two can never disagree.
|
|
58
|
+
|
|
59
|
+
**Stated residual.** This makes the COMMIT capture the whole current working tree; it does not make
|
|
60
|
+
the RECEIPT unforgeable. The fingerprint payload still runs its diffs without
|
|
61
|
+
`--ignore-submodules=none`, so under `diff.ignoreSubmodules=all` a submodule can be changed and its
|
|
62
|
+
gitlink staged after a green final run while the fingerprint stays put, and the stale receipt is
|
|
63
|
+
reused. That is a receipt collision, not an under-capture — no commit ships less than the working
|
|
64
|
+
tree because of it — and closing it means moving the node payload and both bash twins in one
|
|
65
|
+
release. Tracked as its own class, deliberately not folded here.
|
|
66
|
+
|
|
67
|
+
## 3.12.0 — `--resume` tolerates the session's work: the verify proves per placed path (AD-073)
|
|
68
|
+
|
|
69
|
+
`provision --resume` no longer refuses a satellite you have worked in. The closing slice of the
|
|
70
|
+
resume-verify design: the post-provision verify stopped asking "is the whole tree clean?" and now
|
|
71
|
+
asks "is every path THIS run placed or kept in a git lane provision can prove?" — so your
|
|
72
|
+
uncommitted edits, untracked scratch and hook-created files are out of scope BY CONSTRUCTION.
|
|
73
|
+
|
|
74
|
+
- **What `--resume` now tolerates:** uncommitted tracked edits (including a dirty `package.json`,
|
|
75
|
+
whose live state steers the refreshed install posture in both directions), untracked scratch at
|
|
76
|
+
any depth, renamed tracked files, hook-created content, and every ignored file. A worktree wedged
|
|
77
|
+
by a `post-checkout` hook that dirtied its fresh checkout now completes on `--resume` — previously
|
|
78
|
+
both lanes refused at the same verify and the only way out was deleting the hook's files by hand.
|
|
79
|
+
- **What still refuses, fail-closed:** a path provision itself placed or kept whose lane is
|
|
80
|
+
UNTRACKED, and any lane probe that errors. The STOP names the exact leaf — never a directory, never
|
|
81
|
+
a session path — and carries the convergent fix first: restore the ignore rule (the only
|
|
82
|
+
convergent fix for copy-set leaves, the `node_modules` link and `.vscode/settings.json`, since the
|
|
83
|
+
next resume simply re-places a removed node). A droppable `--include` gets ONE instruction for its
|
|
84
|
+
whole destination ROOT — dropping the flag orphans every copy under it — namely: move the root OUT
|
|
85
|
+
of the worktree and drop `--include <root>` together. No removal command is ever derived (the tool
|
|
86
|
+
cannot see what else you put inside that directory), and leaving it in place is not offered either
|
|
87
|
+
(an orphan is what blocks landing). A node this attempt did not create is never advised away, and
|
|
88
|
+
an unprovable probe carries no recovery command at all.
|
|
89
|
+
- **The proof set is a closed, frozen registry** (`PLACEMENT_REGISTRY`): the handoff stub, the seeded
|
|
90
|
+
plan, copy-set leaves, include leaves, the `node_modules` link, `.vscode/settings.json`,
|
|
91
|
+
pin-rebase targets, and the record refresh. It is leaf-only and kind-gated — a real `node_modules`
|
|
92
|
+
directory where the tool places a symlink is YOUR content, never probed and never touched — and it
|
|
93
|
+
freezes at the verify, so the record refresh can only write at the path the stub already
|
|
94
|
+
journaled. The kind gate applies to KEPT nodes only: anything the run itself created stays proven.
|
|
95
|
+
- **The lane probes are literal.** Live-probed on git 2.43: `check-ignore` refuses pathspec magic and
|
|
96
|
+
otherwise answers for a name that glob-matches a TRACKED sibling, so a file literally named
|
|
97
|
+
`feature-[a].md` would read "not ignored" merely because `feature-a.md` is tracked. Tracked-ness is
|
|
98
|
+
decided first by an explicit literal pathspec whose output must match the path BYTE-EXACTLY (a
|
|
99
|
+
pathspec naming a directory lists its descendants, which prove nothing about the path itself), and
|
|
100
|
+
the ignore probe runs `--no-index`. A `.vscode/settings.json` an earlier run placed is proven even
|
|
101
|
+
when the current run's door skips writing it, so a lost ignore rule can no longer hide behind a
|
|
102
|
+
successful resume.
|
|
103
|
+
- **The FIRST provision stays deliberately strict** — any dirt still refuses — and its untracked
|
|
104
|
+
visibility is now explicit, so a repo's `status.showUntrackedFiles=no` can no longer turn that
|
|
105
|
+
check into a silent no-op. Default behavior is unchanged.
|
|
106
|
+
- **The contract ships as a live constant** (`RESUME_VERIFY_RULE`) printed on every resume-verify
|
|
107
|
+
STOP and pinned into the mode doc by the doc-parity gate. The record's fields are documented for
|
|
108
|
+
what they are: `slug`, `branch` and the seeded plan name authorize a resume; `include`,
|
|
109
|
+
`node_modules` and `vscode-settings` are recorded facts that never do.
|
|
110
|
+
|
|
7
111
|
## 3.11.0 — the record attests only a verified provision; tracked plans-chain paths refuse (AD-072)
|
|
8
112
|
|
|
9
113
|
Two provision honesty fixes from the converged resume-verify design (its slice R1; the
|
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
240
240
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
|
|
241
241
|
| `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
242
242
|
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
243
|
-
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
243
|
+
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
244
244
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, or the worktrees-dir hand-apply-first grant advice) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
245
245
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
246
246
|
| `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
|
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.13.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.13.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",
|
|
@@ -4,16 +4,23 @@ The **read-only pre-commit guard** (strip-the-kit D10) — the last line of the
|
|
|
4
4
|
|
|
5
5
|
Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` — it refuses, each with a named recovery, on:
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
12
|
-
6.
|
|
13
|
-
7.
|
|
7
|
+
1. **an INDEX that lags the verified working tree** — the gates and the fingerprint both describe the WORKING tree, while `git commit` builds the commit from the INDEX alone, and the fingerprint domain is identical whether a hunk sits staged or unstaged (staging a lone tracked modification does not even move it). So this arm runs FIRST, before the fingerprint is computed: it refuses when the working tree holds anything the index does not — tracked paths differing between index and worktree, or reviewable untracked-not-ignored paths (the SAME never-committable stat filter the fingerprint applies; ignored paths never refuse). An index entry carrying **skip-worktree** or **assume-unchanged** is invisible to `git diff`, so those entries are compared DIRECTLY against the worktree (type, symlink target, executable bit where `core.fileMode` says so, and the blob oid through git's own clean filters) — a de-materialised skip-worktree path is an ordinary sparse checkout and never refuses, while a missing assume-unchanged path does. The submodule probe forces `--ignore-submodules=none`, so `diff.ignoreSubmodules` / `submodule.<name>.ignore` cannot erase a dirty submodule from the comparison. Offending paths are named in THREE categories — plain lagging paths, paths held back by an index bit, and submodules — because their recoveries differ; ONE shared cap spans all of them, each category reserves a slot so no clause is ever nameless, the remainder is stated once, and every path is rendered as one escaped line. Recovery is ONE ordered sequence, and `git add -A` alone is NOT it: a bit-carrying entry must have its `skip-worktree` / `assume-unchanged` bit cleared FIRST (scoped to the paths the refusal names — never to everything `git ls-files -v` reports, which includes de-materialised sparse paths whose deletions would then be staged), then `git add -A`, then re-run `run-gates --final` at its RESOLVED path beside this tool, then commit the WHOLE tree. The listed paths are capped, so the loop is the completion signal: re-run the guard until it names none. A tracked **submodule** the index cannot prove current is named separately with its own recovery — commit or clean inside the submodule and stage the gitlink — because a root-level `git add -A` cannot capture a submodule's internal worktree changes. A submodule whose gitlink itself carries one of those index bits is **not probed at all**: it lags by construction. That is a deliberate REDUCTION — three consecutive review rounds each found a new way for a nested probe to answer "clean" wrongly (inherited superproject `GIT_*`, status config blindness, the submodule's OWN flagged entries, a symlink standing in for the directory) — and it stays a CONVERGING refusal, since clearing the bit is a recovery the guard prints and then falls silent on. An UNflagged submodule is judged by the ordinary probe exactly as before. **Fail-closed:** an undecidable git probe refuses with its own named cause, never a silent pass;
|
|
8
|
+
2. no completed final record for the CURRENT fingerprint (the tree moved after the final run — any edit re-stales it);
|
|
9
|
+
3. a RED latest attempt (a dead green never revives — the latest attempt at a fingerprint is authoritative);
|
|
10
|
+
4. fingerprint before ≠ after on the receipt (the tree moved UNDER the final run);
|
|
11
|
+
5. a LATER `final-start` whose attempt never completed (interrupted run / failed receipt append — an attempt of unknown outcome never lets an earlier green stand);
|
|
12
|
+
6. declaration content drift (the current `docs/ai/gates.json` {id, cmd} array no longer matches the receipt's recorded one);
|
|
13
|
+
7. evidence-hash drift (the store's canonical red-proof/degrade serializations moved under the receipt) or lcov drift (the consumed file's sha moved or vanished);
|
|
14
|
+
8. unsatisfied review obligations — the SAME normative decision `review-state --check` computes (configured recipe backends, ship-class-only on the latest normal receipt, veto, the explicit degrade escape), recomputed over a SANITIZED env: the guard resolves FIXED git-dir paths for its own reads and ignores `AW_REVIEW_RECEIPTS`/`AW_CORE_EVIDENCE` (producer test seams are never guard inputs — a forged out-of-repo store never satisfies).
|
|
14
15
|
|
|
15
16
|
**Wiring:** this repo's dogfood rides `scripts/install-git-hooks.mjs`; a consumer install is a consented surface (init/recommendations) — the hook INSTALLER resolves the installed kit location at install time and writes the RESOLVED invocation into the hook it places (no runtime guessing). The final-run ordering that keeps the guard green is D13: stage everything FIRST → run the reviews on the staged tree → `run-gates --final` → commit immediately (any index/worktree mutation after the final run re-stales the receipt).
|
|
16
17
|
|
|
18
|
+
**The deliberate partial commit is BLOCKED — stated, accepted.** Refusal 1 means staging a subset on purpose no longer commits: a pathspec commit (`git commit --only <path>`, `git commit <path>`) hands the hook a temporary index carrying less than the verified tree, and that is exactly the capture blindness this arm closes. No opt-out flag exists, deliberately — the guard's whole claim is that the committed bytes ARE the verified bytes, and a flag that suspends it would suspend the claim. An intentional partial commit stays `--no-verify` territory.
|
|
19
|
+
|
|
20
|
+
**Boundary — what «the commit captures the tree» means for submodules.** The guard proves the SUPERPROJECT commit captures the SUPERPROJECT working tree. A superproject commit stores a submodule as a gitlink OID and never captures its file content — that content is the submodule's own commit boundary. The dirty-submodule refusal is therefore a best-effort COURTESY beyond the guard's boundary, and its completeness is bounded by what the submodule itself reports: a file held behind a `skip-worktree` / `assume-unchanged` bit in the SUBMODULE's own index is invisible to the submodule's status and so to this refusal. Deepening the nested probe was tried and abandoned — three consecutive review rounds each produced a new way for it to answer wrongly — so the honest contract is the boundary, not a completeness claim. Tracked as its own class.
|
|
21
|
+
|
|
22
|
+
**Stated residual — the fingerprint can still be blinded by config.** Refusal 1 makes the COMMIT capture the whole current working tree. It does not make the RECEIPT unforgeable: `computeFingerprintPayload` still runs its diffs without `--ignore-submodules=none`, so under `diff.ignoreSubmodules=all` a submodule can be changed and its gitlink staged AFTER a green `--final` while the fingerprint stays put, and the stale receipt is reused. That is a receipt-collision defect, not an under-capture one — no commit ships less than the working tree because of it — and closing it means moving the node payload and both bash twins together (the AD-044 lockstep). Tracked as its own class.
|
|
23
|
+
|
|
17
24
|
**Human residual (stated, accepted):** `git commit --no-verify` bypasses any pre-commit hook — a self-discipline mechanism, not a security boundary.
|
|
18
25
|
|
|
19
26
|
**Invariants:** read-only · re-runs nothing · fixed git-dir reads (env overrides ignored) · exit 0 pass / 1 refused (reason + recovery named) / 2 usage.
|
|
@@ -38,6 +38,36 @@ own verbatim error through the existing Git-error surface.
|
|
|
38
38
|
record attests only a verified provision, and a failed run leaves the prior record bytes: on a
|
|
39
39
|
first provision that failed after the stub write, the stub; a refusal BEFORE any provision
|
|
40
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).
|
|
41
71
|
`--include` sources are identity-bound: preflight records each include root's identity (device,
|
|
42
72
|
inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
|
|
43
73
|
regular file nor a directory — or whose identity probe fails — is refused before any mutation.
|
|
@@ -98,8 +128,10 @@ own verbatim error through the existing Git-error surface.
|
|
|
98
128
|
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
99
129
|
requires the handoff identity, and is the only path where `--force` may appear.
|
|
100
130
|
|
|
101
|
-
**Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):**
|
|
102
|
-
(`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 —
|
|
103
135
|
PLUS the three facts a fresh satellite session cannot derive from its own checkout:
|
|
104
136
|
|
|
105
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.
|
|
@@ -109,9 +141,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
|
|
|
109
141
|
isolated-install command when the package manager is unambiguous, the honest install-by-hand
|
|
110
142
|
advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
|
|
111
143
|
unlink-first form, because a plain install through the symlink writes into MAIN and is never
|
|
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`
|
|
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
|
|
113
145
|
isolated-install command.
|
|
114
|
-
All manifest/lockfile install evidence — the dependency-free proof AND the package-manager selection (the `packageManager` field, lockfiles) — is read from the worktree's own LIVE files at the moment the posture is resolved (on `--resume` too,
|
|
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.
|
|
115
147
|
|
|
116
148
|
**Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
|
|
117
149
|
commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
|
package/tools/commands.mjs
CHANGED
|
@@ -222,7 +222,7 @@ const CATALOG = [
|
|
|
222
222
|
invocation: invocationOf('commit-guard'),
|
|
223
223
|
group: 'Orchestrate',
|
|
224
224
|
kind: READ_ONLY,
|
|
225
|
-
oneLine: 'The read-only pre-commit guard: binds the LATEST completed run-gates --final receipt to the CURRENT tree
|
|
225
|
+
oneLine: 'The read-only pre-commit guard: FIRST refuses an INDEX that lags the working tree — so the commit cannot ship less than was verified, which deliberately blocks a partial commit (--no-verify stays the residual) — then binds the LATEST completed run-gates --final receipt to the CURRENT tree, refusing on any fingerprint, declaration, evidence-hash, or lcov drift, a dangling later attempt, or unsatisfied review obligations; re-runs no gate or test.',
|
|
226
226
|
},
|
|
227
227
|
{
|
|
228
228
|
key: 'doc-parity',
|
package/tools/commit-guard.mjs
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
// commit-guard.mjs — the read-only pre-commit guard (strip-the-kit 2.5, D10). It re-runs NO
|
|
3
3
|
// gate/test subprocess: the heavy D3(b)/(c)/(d) verification lives in `run-gates --final`, whose
|
|
4
4
|
// receipt this guard binds. `--check`:
|
|
5
|
+
// 0. refuses an INDEX that lags the verified working tree — FIRST, before the fingerprint is
|
|
6
|
+
// computed. The gates and the fingerprint describe the WORKING tree while `git commit` builds
|
|
7
|
+
// the commit from the INDEX alone, and the fingerprint domain is identical either way, so
|
|
8
|
+
// without this arm a lagging index ships a strict SUBSET of what was verified. Refuses on
|
|
9
|
+
// tracked paths differing index↔worktree or reviewable untracked-not-ignored paths (the same
|
|
10
|
+
// never-committable filter the fingerprint applies; ignored paths never refuse), naming them
|
|
11
|
+
// up to INDEX_LAG_PATH_CAP with the remainder stated. A dirty tracked SUBMODULE is named
|
|
12
|
+
// separately with its own recovery. Fail-closed on an undecidable probe. This BLOCKS the
|
|
13
|
+
// deliberate partial commit by design — `--no-verify` is the stated residual, not a flag;
|
|
5
14
|
// 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing);
|
|
6
15
|
// 2. reads the LATEST completed final-run record from the core-evidence store (only the latest
|
|
7
16
|
// attempt at a fingerprint is authoritative — a green receipt is DEAD once a later attempt at
|
|
@@ -18,11 +27,11 @@
|
|
|
18
27
|
|
|
19
28
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
20
29
|
import { resolve } from 'node:path';
|
|
21
|
-
import { pathToFileURL } from 'node:url';
|
|
30
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
22
31
|
import { spawnSync } from 'node:child_process';
|
|
23
32
|
import { createHash } from 'node:crypto';
|
|
24
|
-
import { computeTreeFingerprint, buildState, decideCheck } from './review-state.mjs';
|
|
25
|
-
import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization } from './core-evidence.mjs';
|
|
33
|
+
import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
|
|
34
|
+
import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization, computeWorkingState } from './core-evidence.mjs';
|
|
26
35
|
import { resolveLcovPath } from './coverage-check.mjs';
|
|
27
36
|
import { GATES_REL, loadDeclaration } from './run-gates.mjs';
|
|
28
37
|
|
|
@@ -44,10 +53,130 @@ export const resolveGitHooksPath = (projectDir) => {
|
|
|
44
53
|
return line == null ? null : resolve(projectDir, line);
|
|
45
54
|
};
|
|
46
55
|
|
|
56
|
+
// How many offending paths the index-lag refusal names before it states a remainder count: enough
|
|
57
|
+
// to act on, bounded so a wide lag cannot bury a pre-commit hook's output.
|
|
58
|
+
export const INDEX_LAG_PATH_CAP = 10;
|
|
59
|
+
|
|
60
|
+
// The recovery must name the run-gates the CONSUMER actually has. A repo-relative literal is only
|
|
61
|
+
// correct inside this monorepo; every installed deployment keeps the tool beside this file. Shell-
|
|
62
|
+
// quoted, because an install path carrying a space or a metacharacter would otherwise render an
|
|
63
|
+
// instruction that is unrunnable at best and dangerous to paste at worst.
|
|
64
|
+
const FINAL_RUN_TOOL = shellQuoteArg(fileURLToPath(new URL('./run-gates.mjs', import.meta.url)));
|
|
65
|
+
|
|
66
|
+
// ONE budget across every named category — a per-category cap would print 2× the stated number.
|
|
67
|
+
const renderBudgeted = (paths, budget) => ({
|
|
68
|
+
text: paths.slice(0, Math.max(budget, 0)).map(quoteReportName).join(', '),
|
|
69
|
+
used: Math.min(paths.length, Math.max(budget, 0)),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The ONE ordered recovery plan — text and executable `argv` built from the SAME structure, so a
|
|
73
|
+
// test can run exactly what the operator is shown instead of reconstructing its own commands.
|
|
74
|
+
// Order matters: the submodule step first (staging and re-running --final before it would stale the
|
|
75
|
+
// fresh receipt at once), then the index bits, because `git add -A` CANNOT restage a skip-worktree
|
|
76
|
+
// or assume-unchanged entry — printing it alone is a recovery that silently does nothing. The two
|
|
77
|
+
// bits get SEPARATE commands: one `update-index` invocation carrying both flags applies only one.
|
|
78
|
+
// The text points at `git ls-files -v` rather than pasting names — the displayed list is capped,
|
|
79
|
+
// and a name safe to display is not automatically safe to paste into a shell.
|
|
80
|
+
export const buildIndexLagRecovery = (state) => {
|
|
81
|
+
const flags = state.flaggedPaths ?? [];
|
|
82
|
+
const steps = [];
|
|
83
|
+
if (state.unstagedSubmodulePaths.length > 0) {
|
|
84
|
+
steps.push({ text: 'commit or clean INSIDE every dirty submodule named above and stage its gitlink — a root-level git add -A cannot reach a submodule\'s own worktree' });
|
|
85
|
+
}
|
|
86
|
+
for (const [bit, key] of [['--no-skip-worktree', 'skipWorktree'], ['--no-assume-unchanged', 'assumeUnchanged']]) {
|
|
87
|
+
const affected = flags.filter((flag) => flag[key]);
|
|
88
|
+
if (affected.length === 0) continue;
|
|
89
|
+
// Scoped to the LAGGING paths only, never to `git ls-files -v`: that set also holds every
|
|
90
|
+
// de-materialised sparse-checkout entry, and clearing THEIR bit before `git add -A` would stage
|
|
91
|
+
// their deletions. The cap is handled by iteration, not by a wider enumeration. The executable
|
|
92
|
+
// form is offered ONLY when every affected name survives a byte round-trip — a lossily decoded
|
|
93
|
+
// name would address a DIFFERENT path, so there the text stands alone and says so.
|
|
94
|
+
const exact = affected.every((flag) => flag.exactName);
|
|
95
|
+
steps.push({
|
|
96
|
+
text: `clear the ${bit.slice(5)} bit on the bit-carrying path(s) named above — it is what makes git add -A a no-op on them — with git update-index ${bit} -- <path>, for those paths ONLY (never every entry git ls-files -v reports: that set includes de-materialised sparse paths whose deletions would then be staged)${exact ? '' : '; at least one of these names carries bytes that do not decode cleanly, so the name shown above is LOSSY and this refusal cannot give you a runnable command for it — its record is visible in git ls-files -v -z, and clearing that one is a by-hand step'}`,
|
|
97
|
+
...(exact ? { argv: ['update-index', bit, '--', ...affected.map((flag) => flag.rel)] } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
steps.push({ text: 'run git add -A from the work-tree root', argv: ['add', '-A'] });
|
|
101
|
+
steps.push({ text: `re-run node ${FINAL_RUN_TOOL} --final` });
|
|
102
|
+
steps.push({ text: 'commit the WHOLE tree' });
|
|
103
|
+
return steps;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// decideIndexLag(state) → a refusal, or null when the index already carries the verified tree.
|
|
107
|
+
// The gates and the fingerprint both describe the WORKING tree; `git commit` takes the INDEX, and
|
|
108
|
+
// the fingerprint domain cannot tell the two apart — so without this arm a lagging index ships a
|
|
109
|
+
// strict subset of what was verified (it did, on 2026-07-25). FAIL-CLOSED on an undecidable probe:
|
|
110
|
+
// the guard's whole claim is that the committed bytes ARE the verified bytes, and it cannot make
|
|
111
|
+
// that claim about a tree it failed to read.
|
|
112
|
+
export const decideIndexLag = (state) => {
|
|
113
|
+
if (state == null) {
|
|
114
|
+
return { code: 1, lines: ['commit-guard: REFUSED — the index/worktree comparison could not be decided (a git probe failed); re-run inside the work tree and inspect `git status` by hand before committing'] };
|
|
115
|
+
}
|
|
116
|
+
// THREE categories, because they take DIFFERENT recoveries. A bit-carrying path folded into the
|
|
117
|
+
// plain list would be un-actionable: its clause is the only one whose recovery is not `git add -A`,
|
|
118
|
+
// and on cap overflow the plain paths could hide every one of them.
|
|
119
|
+
const flaggedSet = new Set((state.flaggedPaths ?? []).map((flag) => flag.rel));
|
|
120
|
+
const all = [...state.unstagedPaths, ...state.untrackedPaths];
|
|
121
|
+
const plain = all.filter((rel) => !flaggedSet.has(rel));
|
|
122
|
+
const bitCarrying = all.filter((rel) => flaggedSet.has(rel));
|
|
123
|
+
const submodules = state.unstagedSubmodulePaths;
|
|
124
|
+
const total = plain.length + bitCarrying.length + submodules.length;
|
|
125
|
+
if (total === 0) return null;
|
|
126
|
+
// Every non-empty category reserves a slot before the budget is spent — a clause that names no
|
|
127
|
+
// path cannot deliver the recovery it exists to state.
|
|
128
|
+
const groups = [plain, bitCarrying, submodules];
|
|
129
|
+
const rendered = [];
|
|
130
|
+
let spent = 0;
|
|
131
|
+
groups.forEach((group, index) => {
|
|
132
|
+
if (group.length === 0) {
|
|
133
|
+
rendered[index] = { text: '', used: 0 };
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const stillToReserve = groups.slice(index + 1).filter((later) => later.length > 0).length;
|
|
137
|
+
rendered[index] = renderBudgeted(group, INDEX_LAG_PATH_CAP - spent - stillToReserve);
|
|
138
|
+
spent += rendered[index].used;
|
|
139
|
+
});
|
|
140
|
+
const hidden = total - spent;
|
|
141
|
+
const remainder = hidden > 0 ? `, plus ${hidden} further path(s) not listed` : '';
|
|
142
|
+
const clauses = [];
|
|
143
|
+
if (plain.length > 0) {
|
|
144
|
+
clauses.push(`paths the index does not carry: ${rendered[0].text}`);
|
|
145
|
+
}
|
|
146
|
+
if (bitCarrying.length > 0) {
|
|
147
|
+
clauses.push(`path(s) held back by a skip-worktree / assume-unchanged index bit: ${rendered[1].text}`);
|
|
148
|
+
}
|
|
149
|
+
if (submodules.length > 0) {
|
|
150
|
+
clauses.push(`tracked submodule(s) not proven current: ${rendered[2].text}`);
|
|
151
|
+
}
|
|
152
|
+
// ONE ordered recovery, and every step must actually converge. The submodule step comes FIRST:
|
|
153
|
+
// staging and re-running --final before it would stale the fresh receipt at once. The index-bit
|
|
154
|
+
// step comes next, because `git add -A` CANNOT restage a skip-worktree / assume-unchanged entry —
|
|
155
|
+
// printing it alone would be a recovery that silently does nothing. It deliberately points at
|
|
156
|
+
// `git ls-files -v` rather than pasting names: the list above is capped, and a filename safe to
|
|
157
|
+
// display is not automatically safe to paste into a shell.
|
|
158
|
+
const steps = buildIndexLagRecovery(state);
|
|
159
|
+
// The iterate-until-silent hint must also fire when the CAP hid work — otherwise a truncated
|
|
160
|
+
// list of submodules with no index bits would send the operator to --final and commit while
|
|
161
|
+
// unnamed ones are still unhandled.
|
|
162
|
+
const converge = hidden > 0 || (state.flaggedPaths ?? []).length > 0
|
|
163
|
+
? ' The listed paths are capped: re-run this guard after each pass and it names the next batch, until it names none — that is the completion signal.'
|
|
164
|
+
: '';
|
|
165
|
+
return {
|
|
166
|
+
code: 1,
|
|
167
|
+
lines: [`commit-guard: REFUSED — the index does NOT carry the whole CURRENT working tree, so this commit would leave part of it behind: ${clauses.join('; ')}${remainder}. To recover, in order: ${steps.map((step, i) => `(${i + 1}) ${step.text}`).join('; ')}. An intentional partial commit stays git commit --no-verify.${converge}`],
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
47
171
|
// runGuard({ cwd, env }) → { code, lines }. Every refusal names its recovery.
|
|
48
172
|
export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
49
173
|
const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
50
174
|
if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
|
|
175
|
+
// FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
|
|
176
|
+
// the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
|
|
177
|
+
// would send the operator down a recovery they must redo.
|
|
178
|
+
const indexLag = decideIndexLag(computeWorkingState(cwd));
|
|
179
|
+
if (indexLag !== null) return indexLag;
|
|
51
180
|
const fingerprint = computeTreeFingerprint(cwd);
|
|
52
181
|
// The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
|
|
53
182
|
// in the committing shell must never redirect the LAST line of defense to a forged artifact
|
|
@@ -128,11 +257,13 @@ const HELP = `commit-guard — the read-only pre-commit guard (agent-workflow fa
|
|
|
128
257
|
Usage:
|
|
129
258
|
node commit-guard.mjs --check [--cwd <dir>]
|
|
130
259
|
|
|
131
|
-
Re-runs NOTHING:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
260
|
+
Re-runs NOTHING: refuses an INDEX that lags the verified working tree (FIRST — unstaged tracked
|
|
261
|
+
paths, reviewable untracked paths, or a dirty tracked submodule, each named with its recovery;
|
|
262
|
+
this deliberately blocks a partial commit), then recomputes the current tree fingerprint and binds
|
|
263
|
+
the LATEST completed run-gates --final receipt — refusing on { no receipt for this tree · a red
|
|
264
|
+
latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
|
|
265
|
+
unsatisfied review obligations (the review-state decision) }. Wire it into pre-commit;
|
|
266
|
+
\`git commit --no-verify\` stays the stated residual (self-discipline, not a security boundary).
|
|
136
267
|
|
|
137
268
|
Exit codes: 0 pass; 1 refused (reason named); 2 usage.`;
|
|
138
269
|
|
package/tools/core-evidence.mjs
CHANGED
|
@@ -152,29 +152,240 @@ export const computeTreeFingerprint = (cwd, fsx) => {
|
|
|
152
152
|
return payload == null ? null : createHash('sha256').update(payload).digest('hex');
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
155
|
+
// The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
|
|
156
|
+
// the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
|
|
157
|
+
// leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the
|
|
158
|
+
// ONE computation of that split; isTreeClean and the commit guard's index-lag arm both read it, so
|
|
159
|
+
// they can never disagree about what "the index carries the verified tree" means. Submodule paths
|
|
160
|
+
// are separated because a root-level `git add -A` cannot reach a submodule's own worktree, so they
|
|
161
|
+
// need their own recovery. Anchored at the work-tree ROOT (ls-files is cwd-scoped); null when not
|
|
162
|
+
// decidable — every consumer treats that as fail-closed, never as clean.
|
|
163
|
+
const LS_FILES_TAG_SKIP_WORKTREE = 'S';
|
|
164
|
+
const GITLINK_MODE = '160000';
|
|
165
|
+
const SYMLINK_MODE = '120000';
|
|
166
|
+
const EXECUTABLE_MODE = '100755';
|
|
167
|
+
const OWNER_EXECUTE_BIT = 0o100;
|
|
168
|
+
// `ls-files -v` lowercases the tag for assume-unchanged; skip-worktree is `S`, and an entry
|
|
169
|
+
// carrying BOTH bits prints lowercase `s` (live-pinned by test). The skip test is therefore
|
|
170
|
+
// case-INSENSITIVE — a case-sensitive one would lose skip-worktree on such an entry and turn a
|
|
171
|
+
// legitimate sparse checkout into an endless refusal.
|
|
172
|
+
const isAssumeUnchangedTag = (tag) => tag >= 'a' && tag <= 'z';
|
|
173
|
+
const isSkipWorktreeTag = (tag) => tag.toUpperCase() === LS_FILES_TAG_SKIP_WORKTREE;
|
|
174
|
+
|
|
175
|
+
// `git diff` SKIPS index entries carrying skip-worktree or assume-unchanged, so such a path can
|
|
176
|
+
// hold worktree bytes the gates read while `git commit` takes the stale INDEX blob — the same
|
|
177
|
+
// capture blindness one layer down, and invisible to the plain probe. Those entries are therefore
|
|
178
|
+
// compared DIRECTLY against the worktree. A MISSING skip-worktree path is an ordinary sparse
|
|
179
|
+
// checkout and never a lag; a missing assume-unchanged path is one. Gitlinks belong to the
|
|
180
|
+
// submodule lane. Any probe that cannot answer counts the path as lagging (fail-safe).
|
|
181
|
+
const flaggedIndexLag = (top, runGit, lstat, readlink) => {
|
|
182
|
+
const buf = (args) => {
|
|
183
|
+
const r = runGit(args, top);
|
|
184
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
185
|
+
};
|
|
186
|
+
const splitZ = (b) => b.toString('utf8').split('\0').filter(Boolean);
|
|
187
|
+
// Git emits raw path BYTES. Decoding to UTF-8 first turns a name carrying invalid bytes into a
|
|
188
|
+
// DIFFERENT path, whose lstat then answers ENOENT — which for a skip-worktree entry reads as a
|
|
189
|
+
// de-materialised sparse path and lets a stale index walk through. So each record keeps its
|
|
190
|
+
// original slice and a name that does not survive a byte round-trip is lagging by construction.
|
|
191
|
+
const splitZBytes = (b) => {
|
|
192
|
+
const out = [];
|
|
193
|
+
let start = 0;
|
|
194
|
+
for (let i = 0; i < b.length; i += 1) {
|
|
195
|
+
if (b[i] !== 0) continue;
|
|
196
|
+
if (i > start) out.push(b.subarray(start, i));
|
|
197
|
+
start = i + 1;
|
|
198
|
+
}
|
|
199
|
+
if (start < b.length) out.push(b.subarray(start));
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
const decodesExactly = (slice) => Buffer.from(slice.toString('utf8'), 'utf8').equals(slice);
|
|
203
|
+
const taggedZ = buf(['ls-files', '-v', '-z']);
|
|
204
|
+
if (taggedZ == null) return null;
|
|
205
|
+
// The two bits are INDEPENDENT — an entry can carry both (which is what lowercases the `S`), so
|
|
206
|
+
// they travel as a pair and the recovery clears whichever are actually set.
|
|
207
|
+
const flagged = splitZBytes(taggedZ)
|
|
208
|
+
.map((record) => {
|
|
209
|
+
const tag = record.subarray(0, 1).toString('utf8');
|
|
210
|
+
const pathBytes = record.subarray(2);
|
|
211
|
+
return {
|
|
212
|
+
rel: pathBytes.toString('utf8'),
|
|
213
|
+
pathBytes,
|
|
214
|
+
exactName: decodesExactly(pathBytes),
|
|
215
|
+
skipWorktree: isSkipWorktreeTag(tag),
|
|
216
|
+
assumeUnchanged: isAssumeUnchangedTag(tag),
|
|
217
|
+
};
|
|
218
|
+
})
|
|
219
|
+
.filter(({ skipWorktree, assumeUnchanged }) => skipWorktree || assumeUnchanged);
|
|
220
|
+
if (flagged.length === 0) return { paths: [], submodules: [], flagged: [] };
|
|
221
|
+
const stagedZ = buf(['ls-files', '-s', '-z']);
|
|
222
|
+
if (stagedZ == null) return null;
|
|
223
|
+
const entries = new Map();
|
|
224
|
+
for (const line of splitZ(stagedZ)) {
|
|
225
|
+
const tab = line.indexOf('\t');
|
|
226
|
+
if (tab === -1) continue;
|
|
227
|
+
const [mode, oid] = line.slice(0, tab).split(' ');
|
|
228
|
+
entries.set(line.slice(tab + 1), { mode, oid });
|
|
229
|
+
}
|
|
230
|
+
// `git diff` honours core.fileMode; mirroring it keeps a false-mode host (WSL, network mounts)
|
|
231
|
+
// from reading every executable bit as a lag. Exit 1 is the only "unset" (git's default is true);
|
|
232
|
+
// any other failure leaves the comparison undecidable rather than guessing.
|
|
233
|
+
const boolConfig = (key) => {
|
|
234
|
+
const r = runGit(['config', '--type=bool', '--get', key], top);
|
|
235
|
+
if (r.error || (r.status !== 0 && r.status !== 1)) return null; // undecidable — never guessed
|
|
236
|
+
return r.status === 1 || r.stdout.toString('utf8').trim() !== 'false'; // exit 1 = unset = git's default true
|
|
237
|
+
};
|
|
238
|
+
const honoursFileMode = boolConfig('core.fileMode');
|
|
239
|
+
// On a host without symlink support git materialises a symlink as a REGULAR FILE holding the
|
|
240
|
+
// target bytes; demanding a real link there would refuse forever.
|
|
241
|
+
const materialisesSymlinks = boolConfig('core.symlinks');
|
|
242
|
+
if (honoursFileMode === null || materialisesSymlinks === null) return null;
|
|
243
|
+
const lagging = [];
|
|
244
|
+
const laggingSubmodules = [];
|
|
245
|
+
const laggingFlags = [];
|
|
246
|
+
for (const { rel, pathBytes, exactName, skipWorktree, assumeUnchanged } of flagged) {
|
|
247
|
+
const entry = entries.get(rel);
|
|
248
|
+
if (entry === undefined) continue;
|
|
249
|
+
const isGitlink = entry.mode === GITLINK_MODE;
|
|
250
|
+
// Every path this loop reports needs its index bits cleared before ANY staging command can
|
|
251
|
+
// pick it up — `git add -A` alone is a recovery that silently does nothing here.
|
|
252
|
+
const lag = () => {
|
|
253
|
+
(isGitlink ? laggingSubmodules : lagging).push(rel);
|
|
254
|
+
laggingFlags.push({ rel, skipWorktree, assumeUnchanged, exactName });
|
|
255
|
+
};
|
|
256
|
+
// Probed by RAW BYTES, never by the decoded name: a lossy decode addresses a DIFFERENT path,
|
|
257
|
+
// and answering ENOENT for it would either wave a stale index through or — worse — call a
|
|
258
|
+
// legitimately absent sparse entry "lagging", whose prescribed bit-clear plus `git add -A`
|
|
259
|
+
// would then stage its DELETION.
|
|
260
|
+
const absPath = Buffer.concat([Buffer.from(top), Buffer.from(sep), pathBytes]);
|
|
261
|
+
let stat = null;
|
|
262
|
+
let absent = false;
|
|
263
|
+
try {
|
|
264
|
+
stat = lstat(absPath);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// ONLY a genuine absence is the sparse-checkout case; EACCES / EIO leave the path unproven,
|
|
267
|
+
// and an unproven path can never be waved through as "not materialised".
|
|
268
|
+
absent = err != null && err.code === 'ENOENT';
|
|
269
|
+
}
|
|
270
|
+
if (stat === null) {
|
|
271
|
+
if (!absent || !skipWorktree) lag();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!exactName) {
|
|
275
|
+
// Materialised, but the name cannot be addressed through an argv string, so it can never be
|
|
276
|
+
// PROVEN current — fail-safe. Absence was already decided above, on the real bytes.
|
|
277
|
+
lag();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// A flagged GITLINK is hidden from `git diff` too, so the submodule lane would never see it —
|
|
281
|
+
// and it is deliberately NOT proven current here. Three consecutive review rounds each found a
|
|
282
|
+
// new way for a nested probe to answer "clean" wrongly (inherited superproject GIT_*, status
|
|
283
|
+
// config blindness, the submodule's OWN flagged entries, a symlink standing in for the
|
|
284
|
+
// directory). The set was not shrinking, so the verdict is REDUCTION rather than another patch:
|
|
285
|
+
// a materialised flagged gitlink LAGS by construction. It is a refusal, never an endless one —
|
|
286
|
+
// the printed recovery (clear the bit, then git add -A) converges, and an UNflagged submodule
|
|
287
|
+
// is unaffected because the ordinary --ignore-submodules=none probe still judges it.
|
|
288
|
+
if (isGitlink) {
|
|
289
|
+
lag();
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (entry.mode === SYMLINK_MODE && !stat.isSymbolicLink() && !materialisesSymlinks) {
|
|
293
|
+
// The placeholder file's RAW bytes are the stored target — no filters ever apply to a link.
|
|
294
|
+
const placeholder = runGit(['hash-object', '--no-filters', '-t', 'blob', '--', join(top, rel)], top);
|
|
295
|
+
if (placeholder.error || placeholder.status !== 0 || placeholder.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (entry.mode === SYMLINK_MODE || stat.isSymbolicLink()) {
|
|
299
|
+
if (entry.mode !== SYMLINK_MODE || !stat.isSymbolicLink()) {
|
|
300
|
+
lag();
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
let target = null;
|
|
304
|
+
try {
|
|
305
|
+
target = readlink(join(top, rel));
|
|
306
|
+
} catch {
|
|
307
|
+
target = null;
|
|
308
|
+
}
|
|
309
|
+
if (target === null) {
|
|
310
|
+
lag(); // an unreadable link can never be proven current — fail-safe
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const hashed = runGit(['hash-object', '-t', 'blob', '--stdin'], top, target);
|
|
314
|
+
if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!stat.isFile()) {
|
|
318
|
+
lag();
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// Git canonicalises the executable mode on the OWNER bit alone — a file with only group/other
|
|
322
|
+
// exec set is still `100644` to git, so testing 0o111 would call a stale index current.
|
|
323
|
+
if (honoursFileMode && (entry.mode === EXECUTABLE_MODE) !== ((stat.mode & OWNER_EXECUTE_BIT) !== 0)) {
|
|
324
|
+
lag();
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const hashed = runGit(['hash-object', '--path', rel, '--', join(top, rel)], top);
|
|
328
|
+
if (hashed.error || hashed.status !== 0 || hashed.stdout.toString('utf8').trim() !== entry.oid) lag();
|
|
329
|
+
}
|
|
330
|
+
return { paths: lagging, submodules: laggingSubmodules, flagged: laggingFlags };
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const computeWorkingState = (cwd, { lstat = lstatSync, readlink = readlinkSync, runGit = null } = {}) => {
|
|
334
|
+
const run = runGit ?? ((args, dir, input, env) => spawnSync('git', args, { cwd: dir, input, env: env ?? process.env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true }));
|
|
335
|
+
const topRun = run(['rev-parse', '--show-toplevel'], cwd);
|
|
336
|
+
if (topRun.error || topRun.status !== 0) return null;
|
|
337
|
+
const top = topRun.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
338
|
+
// `--ignore-submodules=none` on the staged probe too: a config-hidden STAGED gitlink would
|
|
339
|
+
// otherwise make stagedDirty false, and isTreeClean would call such a tree clean.
|
|
340
|
+
const staged = run(['diff', '--cached', '--quiet', '--ignore-submodules=none'], top);
|
|
341
|
+
// ONLY 0 or 1 is a usable answer: a signal-killed probe reports status null, which the old
|
|
342
|
+
// `> 1` guard let through as "nothing staged" — a fail-OPEN the whole arm cannot afford.
|
|
343
|
+
if (staged.error || (staged.status !== 0 && staged.status !== 1)) return null;
|
|
344
|
+
const buf = (args) => {
|
|
345
|
+
const r = run(args, top);
|
|
346
|
+
return r.error || r.status !== 0 ? null : r.stdout;
|
|
347
|
+
};
|
|
348
|
+
// The FULL probe forces submodules back in: diff.ignoreSubmodules / submodule.<n>.ignore would
|
|
349
|
+
// otherwise erase a dirty submodule from the comparison entirely.
|
|
350
|
+
const changedZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=none']);
|
|
351
|
+
const plainZ = buf(['diff', '--name-only', '-z', '--ignore-submodules=all']);
|
|
352
|
+
const untrackedZ = buf(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
353
|
+
if (changedZ == null || plainZ == null || untrackedZ == null) return null;
|
|
354
|
+
const split = (b) => b.toString('utf8').split('\0').filter(Boolean);
|
|
355
|
+
const changed = split(changedZ);
|
|
356
|
+
const plain = new Set(split(plainZ));
|
|
357
|
+
const flagged = flaggedIndexLag(top, run, lstat, readlink);
|
|
358
|
+
if (flagged == null) return null;
|
|
359
|
+
const unstaged = changed.filter((rel) => plain.has(rel));
|
|
360
|
+
const submodules = changed.filter((rel) => !plain.has(rel));
|
|
361
|
+
return {
|
|
362
|
+
stagedDirty: staged.status === 1,
|
|
363
|
+
unstagedPaths: [...unstaged, ...flagged.paths.filter((rel) => !unstaged.includes(rel))],
|
|
364
|
+
unstagedSubmodulePaths: [...submodules, ...flagged.submodules.filter((rel) => !submodules.includes(rel))],
|
|
365
|
+
// Which lagging paths carry index bits, and which — `git add -A` cannot restage these at all.
|
|
366
|
+
flaggedPaths: flagged.flagged,
|
|
367
|
+
untrackedPaths: split(untrackedZ).filter((rel) => {
|
|
171
368
|
try {
|
|
172
369
|
return !isNeverCommittableStat(lstat(join(top, rel)));
|
|
173
370
|
} catch {
|
|
174
371
|
return true;
|
|
175
372
|
}
|
|
176
|
-
})
|
|
177
|
-
|
|
373
|
+
}),
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Clean = nothing staged, nothing unstaged, no REVIEWABLE untracked-not-ignored paths — the same
|
|
378
|
+
// never-committable filter as the fingerprint, so the two can never disagree about a masks-only
|
|
379
|
+
// tree. Null when not decidable.
|
|
380
|
+
export const isTreeClean = (cwd, fsx) => {
|
|
381
|
+
const state = computeWorkingState(cwd, fsx);
|
|
382
|
+
if (state == null) return null;
|
|
383
|
+
return (
|
|
384
|
+
!state.stagedDirty &&
|
|
385
|
+
state.unstagedPaths.length === 0 &&
|
|
386
|
+
state.unstagedSubmodulePaths.length === 0 &&
|
|
387
|
+
state.untrackedPaths.length === 0
|
|
388
|
+
);
|
|
178
389
|
};
|
|
179
390
|
|
|
180
391
|
// ── the review-receipt read path + attesting predicate (ONE home) ────────────────────────────────
|
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}`);
|
|
@@ -1292,8 +1298,139 @@ export const NODE_MODULES_NONE = 'no-dependencies';
|
|
|
1292
1298
|
// cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
|
|
1293
1299
|
// into the worktrees mode doc; every ownership STOP emits it.
|
|
1294
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";
|
|
1295
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';
|
|
1296
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
|
+
|
|
1297
1434
|
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
1298
1435
|
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
1299
1436
|
// (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
|
|
@@ -1454,10 +1591,12 @@ const pendingHandoffFields = ({ root, slug, branch }) =>
|
|
|
1454
1591
|
({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
|
|
1455
1592
|
|
|
1456
1593
|
// The stub is written only when ABSENT; the final record surgically replaces the tool section.
|
|
1457
|
-
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)}`;
|
|
1458
1596
|
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
1459
1597
|
const cur = readFileNoFollow(fs, dst);
|
|
1460
1598
|
if (cur.bytes) {
|
|
1599
|
+
journal.record({ rel, surface: 'handoff-stub', outcome: 'kept' });
|
|
1461
1600
|
report.push(' handoff: kept (already present)');
|
|
1462
1601
|
return;
|
|
1463
1602
|
}
|
|
@@ -1467,14 +1606,18 @@ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) =>
|
|
|
1467
1606
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1468
1607
|
fs.mkdir(dirname(dst));
|
|
1469
1608
|
writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
|
|
1609
|
+
journal.record({ rel, surface: 'handoff-stub', outcome: 'written' });
|
|
1470
1610
|
};
|
|
1471
1611
|
|
|
1472
|
-
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
|
|
1612
|
+
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal = NO_JOURNAL }) => {
|
|
1473
1613
|
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
1474
1614
|
const cur = readFileNoFollow(fs, dst);
|
|
1475
1615
|
if (!cur.bytes) {
|
|
1476
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`);
|
|
1477
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' });
|
|
1478
1621
|
const section = locateProvisionRecordSection(String(cur.bytes));
|
|
1479
1622
|
const updated = `${section.source.slice(0, section.start)}${composeProvisionRecordSection(fields)}${section.source.slice(section.end)}`;
|
|
1480
1623
|
writeContainedFileAtomic(wtRoot, dst, updated, fs, { stop: (m) => stop(m) });
|
|
@@ -1518,9 +1661,10 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
|
1518
1661
|
return { srcAbs: srcReal, name };
|
|
1519
1662
|
};
|
|
1520
1663
|
|
|
1521
|
-
const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
1664
|
+
const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL }) => {
|
|
1522
1665
|
const dst = join(wtRoot, PLANS_REL, name);
|
|
1523
1666
|
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1667
|
+
journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'kept' });
|
|
1524
1668
|
report.push(` kept (already present): ${PLANS_REL}/${name}`);
|
|
1525
1669
|
return;
|
|
1526
1670
|
}
|
|
@@ -1529,6 +1673,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1529
1673
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1530
1674
|
fs.mkdir(dirname(dst));
|
|
1531
1675
|
writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
|
|
1676
|
+
journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'written' });
|
|
1532
1677
|
report.push(` seeded plan: ${PLANS_REL}/${name}`);
|
|
1533
1678
|
};
|
|
1534
1679
|
|
|
@@ -1537,7 +1682,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1537
1682
|
// It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
|
|
1538
1683
|
// realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
|
|
1539
1684
|
// redirect an include at the shared series index between the check and the copy.
|
|
1540
|
-
const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied }) => {
|
|
1685
|
+
const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied, journal = NO_JOURNAL }) => {
|
|
1541
1686
|
const recorded = [];
|
|
1542
1687
|
const queuePath = join(rootReal, PLANS_REL, QUEUE_BASENAME);
|
|
1543
1688
|
for (const { rel, real, identity } of includeSources) {
|
|
@@ -1575,7 +1720,7 @@ const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs,
|
|
|
1575
1720
|
const door = identity.kind === 'file'
|
|
1576
1721
|
? { identity, queuePath, fresh: !resume }
|
|
1577
1722
|
: { queuePath, fresh: !resume };
|
|
1578
|
-
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 });
|
|
1579
1724
|
recorded.push(rel);
|
|
1580
1725
|
}
|
|
1581
1726
|
return recorded;
|
|
@@ -1744,14 +1889,18 @@ const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
|
|
|
1744
1889
|
return resolveInstallAdvice({ wtRoot, fs }).instruction;
|
|
1745
1890
|
};
|
|
1746
1891
|
|
|
1747
|
-
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' });
|
|
1748
1896
|
const install = resolveInstallAdvice({ wtRoot, fs });
|
|
1749
1897
|
if (installFlag) {
|
|
1750
|
-
const dst = join(wtRoot,
|
|
1898
|
+
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1751
1899
|
const existing = lstatNoFollow(fs.lstat, dst);
|
|
1752
1900
|
if (existing !== null && existing.isSymbolicLink()) {
|
|
1753
1901
|
// isolation only exists BEFORE the link: an install through it would write into MAIN
|
|
1754
1902
|
const separator = install.command === null ? ' — ' : ' && ';
|
|
1903
|
+
journalLink('kept');
|
|
1755
1904
|
report.push(` node_modules: existing symlink kept — for isolation remove it first: rm ${shellQuoteArg(dst)}${separator}${install.instruction}`);
|
|
1756
1905
|
return 'install-printed-unlink-first';
|
|
1757
1906
|
}
|
|
@@ -1763,8 +1912,9 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1763
1912
|
// LIVE STATE WINS the whole default lane: a node already at the worktree — a directory, or a
|
|
1764
1913
|
// symlink an earlier provision left, even dangling — is what the record states; reporting
|
|
1765
1914
|
// MAIN's state (`absent`) beside an existing node would contradict record.install.
|
|
1766
|
-
const dst = join(wtRoot,
|
|
1915
|
+
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1767
1916
|
if (lstatNoFollow(fs.lstat, dst) !== null) {
|
|
1917
|
+
journalLink('kept');
|
|
1768
1918
|
report.push(' node_modules: already present in the worktree');
|
|
1769
1919
|
return 'present';
|
|
1770
1920
|
}
|
|
@@ -1816,12 +1966,20 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1816
1966
|
report.push(` node_modules: symlink failed (${err?.code ?? 'error'}) — ${install.instruction}`);
|
|
1817
1967
|
return 'symlink-failed';
|
|
1818
1968
|
}
|
|
1969
|
+
journalLink('written');
|
|
1819
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)`);
|
|
1820
1971
|
return 'symlinked';
|
|
1821
1972
|
};
|
|
1822
1973
|
|
|
1823
|
-
const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
1974
|
+
const provisionVscode = ({ root, wtRoot, slug, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1824
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' });
|
|
1825
1983
|
const vscodeDir = lstatNoFollow(fs.lstat, join(root, '.vscode'));
|
|
1826
1984
|
if (vscodeDir === null || !vscodeDir.isDirectory()) {
|
|
1827
1985
|
report.push(' .vscode: main has no .vscode/ dir — window title not written');
|
|
@@ -1839,7 +1997,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1839
1997
|
report.push(` .vscode: ${relPath} is not ignored in the worktree — skipped (it would become a land leftover)`);
|
|
1840
1998
|
return 'skipped-not-ignored';
|
|
1841
1999
|
}
|
|
1842
|
-
if (
|
|
2000
|
+
if (present) {
|
|
1843
2001
|
report.push(' .vscode: kept (already present)');
|
|
1844
2002
|
return 'kept';
|
|
1845
2003
|
}
|
|
@@ -1868,6 +2026,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1868
2026
|
guardDst(fs, wtRoot, join(wtRoot, '.vscode'));
|
|
1869
2027
|
fs.mkdir(join(wtRoot, '.vscode'));
|
|
1870
2028
|
writeContainedFileAtomic(wtRoot, join(wtRoot, relPath), body, fs, { stop: (m) => stop(m) });
|
|
2029
|
+
journal.record({ rel: relPath, surface: 'vscode-settings', outcome: 'written' });
|
|
1871
2030
|
report.push(` .vscode: ${relPath} written (window.title = ${slug})`);
|
|
1872
2031
|
return 'written';
|
|
1873
2032
|
};
|
|
@@ -1875,11 +2034,12 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
|
|
|
1875
2034
|
// tracked/untracked is decided by GIT (a run-local copy log lies after a crash-resume); an
|
|
1876
2035
|
// untracked pin-carrying file is rewritten ONLY when its bytes equal the MAIN source or its
|
|
1877
2036
|
// already-rebased form — anything else is user work and stays byte-untouched (reported).
|
|
1878
|
-
const rebasePins = ({ root, wtRoot, git, fs, report }) => {
|
|
2037
|
+
const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1879
2038
|
for (const target of REBASE_TARGETS) {
|
|
1880
2039
|
const wtAbs = join(wtRoot, target);
|
|
1881
2040
|
const cur = readFileNoFollow(fs, wtAbs);
|
|
1882
2041
|
if (cur.absent) continue;
|
|
2042
|
+
if (cur.bytes) journal.record({ rel: target, surface: 'pin-rebase-target', outcome: 'kept' });
|
|
1883
2043
|
if (!cur.bytes) {
|
|
1884
2044
|
report.push(` ${target}: ${cur.unsafe ? 'not a regular file' : `unreadable (${cur.error})`} — left untouched`);
|
|
1885
2045
|
continue;
|
|
@@ -2069,24 +2229,27 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2069
2229
|
};
|
|
2070
2230
|
|
|
2071
2231
|
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
|
|
2072
|
-
|
|
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 });
|
|
2073
2236
|
|
|
2074
2237
|
const copied = new Set();
|
|
2075
2238
|
report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
|
|
2076
2239
|
for (const pattern of provisionSet) {
|
|
2077
2240
|
const rel = patternToProbe(pattern).replace(/\/$/, '');
|
|
2078
|
-
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 });
|
|
2079
2242
|
}
|
|
2080
2243
|
|
|
2081
|
-
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
|
|
2082
|
-
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 });
|
|
2083
2246
|
// Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
|
|
2084
2247
|
// lane and the record must state the SAME verdict.
|
|
2085
2248
|
const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
|
|
2086
|
-
const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report });
|
|
2087
|
-
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 });
|
|
2088
2251
|
|
|
2089
|
-
rebasePins({ root, wtRoot: targetPath, git, fs, report });
|
|
2252
|
+
rebasePins({ root, wtRoot: targetPath, git, fs, report, journal });
|
|
2090
2253
|
|
|
2091
2254
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
2092
2255
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
@@ -2095,12 +2258,24 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2095
2258
|
);
|
|
2096
2259
|
}
|
|
2097
2260
|
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
+
}
|
|
2104
2279
|
}
|
|
2105
2280
|
|
|
2106
2281
|
// The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
|
|
@@ -2113,6 +2288,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2113
2288
|
wtRoot: targetPath,
|
|
2114
2289
|
slug,
|
|
2115
2290
|
branch,
|
|
2291
|
+
journal,
|
|
2116
2292
|
fields: {
|
|
2117
2293
|
slug,
|
|
2118
2294
|
branch,
|