@sabaiway/agent-workflow-kit 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/worktrees.md +19 -2
- package/tools/doc-parity.mjs +17 -2
- package/tools/review-state.mjs +36 -5
- package/tools/worktrees.mjs +163 -31
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 3.5.0 — the provision record orients a fresh satellite session (AD-065)
|
|
8
|
+
|
|
9
|
+
The worktrees provision record was an identity stub; a fresh satellite session could not derive
|
|
10
|
+
three facts from its own checkout. The record now carries all three, and the mode doc is pinned to
|
|
11
|
+
the live constants:
|
|
12
|
+
|
|
13
|
+
- **`shared-queue`** — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the verbatim
|
|
14
|
+
rule: the series index is SHARED, read it at that path, never copy it (a machine-local copy
|
|
15
|
+
silently diverges); findings ride the handoff record and main appends them to the index. The rule
|
|
16
|
+
ships only WITH the path — a record from an earlier kit carries neither. `--include` refuses to
|
|
17
|
+
copy the index (or any directory containing it) into the worktree, at preflight AND re-asserted
|
|
18
|
+
at the point of copy on the canonical pair resolved before `git worktree add`.
|
|
19
|
+
- **`landing`** — landing runs FROM MAIN, never from the worktree, with the runnable
|
|
20
|
+
`… land <slug> --prepare` command already `cd`-ing back to main.
|
|
21
|
+
- **`install`** — the resolved install posture: the runnable isolated-install command, or the
|
|
22
|
+
honest by-hand advice when the package manager is ambiguous.
|
|
23
|
+
|
|
24
|
+
The record is line-oriented and parsed back for IDENTITY, so it now REFUSES any value it cannot
|
|
25
|
+
round-trip: control bytes (an injected newline forges a field line or an `## …` heading that
|
|
26
|
+
truncates the section) and U+2028/U+2029 (which write fine and are then silently DROPPED on read —
|
|
27
|
+
a lost field with no error) are a typed STOP, never sanitized. Every value the record will carry is
|
|
28
|
+
validated BEFORE any git mutation — a refusal at compose time would strand a created worktree with
|
|
29
|
+
no handoff, which neither `--resume` nor `cleanup --abandon` can recover. Optional fields are
|
|
30
|
+
omitted when absent, never rendered as `null`, so a record written by an earlier kit survives a
|
|
31
|
+
refresh. Two new `doc-parity` bindings (`queue-shared-rule`, `landing-from-main`) pin
|
|
32
|
+
`references/modes/worktrees.md` to the emitted strings.
|
|
33
|
+
|
|
34
|
+
Second safe slice extracted from the deferred parallel-track work (AD-063) — no
|
|
35
|
+
node_modules-ownership coupling; the provable dependency-free install posture is the next slice.
|
|
36
|
+
|
|
37
|
+
## 3.4.0 — review-state names a latent arm on a clean-tree PASS (AD-064)
|
|
38
|
+
|
|
39
|
+
`review-state --check` under a configured `reviewed` or `council` recipe on a clean tree no longer
|
|
40
|
+
returns a bare `PASS — nothing to review`. It now NAMES every plan still in flight and states the
|
|
41
|
+
forward consequence — `this gate arms as soon as the
|
|
42
|
+
tree is dirty` — so a spent boot prompt left under a bare name is discoverable BEFORE it blocks,
|
|
43
|
+
instead of surfacing at the worst moment: a pending commit, or the landing of a feature worktree when
|
|
44
|
+
main first turns dirty. The quiet case (configured `solo`, no plan in flight, a non-git cwd) still
|
|
45
|
+
passes silently through the earlier-returning arms; dirty-tree behaviour is unchanged. A `doc-parity`
|
|
46
|
+
binding pins the
|
|
47
|
+
live notice to `references/modes/review-state.md`, so a reworded doc that drops it fails the gate — a
|
|
48
|
+
prose-only bar becomes a mechanism. This is the safe, converged slice of the deferred parallel-track
|
|
49
|
+
work (AD-063), shipped on its own with no worktrees-ownership coupling.
|
|
50
|
+
|
|
7
51
|
## 3.3.0 — version-pin honesty: the profile stops claiming a limit it never observed (AD-062)
|
|
8
52
|
|
|
9
53
|
`velocity --autonomy` told every user their credentials could not be protected by the sandbox —
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '3.
|
|
6
|
+
version: '3.5.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -5,7 +5,7 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
|
|
|
5
5
|
Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
|
|
6
6
|
|
|
7
7
|
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
|
|
8
|
-
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend SATISFIED for the current tree. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (ANY edit after a review moves the fingerprint), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
|
|
8
|
+
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend SATISFIED for the current tree. **A clean-tree PASS under a NON-SOLO review obligation is never silent about a latent arm:** when a `reviewed` or `council` recipe is configured, it NAMES every plan in flight and states that this gate arms as soon as the tree is dirty — the condition is discoverable BEFORE it blocks, instead of surfacing at the worst moment (a pending commit, or the landing of a feature worktree when main first turns dirty). Nothing is announced under a configured `solo` recipe or a non-git cwd, where the gate can never arm. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (ANY edit after a review moves the fingerprint), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
|
|
9
9
|
3. **Wire it as a gate by hand OR via the explicit-consent init preview — never without consent (AD-021/D9).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated gates-init preview (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-fill section) offers exactly this entry — path resolved and quoted — ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate — and `run-gates --final` requires this check among its canonical core gates.
|
|
10
10
|
4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly.
|
|
11
11
|
|
|
@@ -59,6 +59,20 @@ own verbatim error through the existing Git-error surface.
|
|
|
59
59
|
Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
|
|
60
60
|
requires the handoff identity, and is the only path where `--force` may appear.
|
|
61
61
|
|
|
62
|
+
**Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):** identity
|
|
63
|
+
(`slug`, `branch`, `include`, `node_modules`, `vscode-settings`, and after a prepare `prepared-tree`)
|
|
64
|
+
PLUS the three facts a fresh satellite session cannot derive from its own checkout:
|
|
65
|
+
|
|
66
|
+
- `shared-queue` — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the rule the record states verbatim: the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here. Provision never seeds a copy: the queue is deliberately absent from the satellite, and the absolute path is the only pointer — `--include` refuses to copy the index (or any directory containing it) into the worktree.
|
|
67
|
+
- `landing` — landing runs FROM MAIN, never from this worktree, with the runnable
|
|
68
|
+
`… land <slug> --prepare` command already `cd`-ing back to main.
|
|
69
|
+
- `install` — the install posture the tool resolved for THIS worktree: the runnable
|
|
70
|
+
isolated-install command when the package manager is unambiguous, the honest install-by-hand
|
|
71
|
+
advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
|
|
72
|
+
unlink-first form, because a plain install through the symlink writes into MAIN and is never
|
|
73
|
+
presented as isolated. `--install` remains an EXPLICIT request and is always answered with the
|
|
74
|
+
isolated-install command.
|
|
75
|
+
|
|
62
76
|
**Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
|
|
63
77
|
commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
|
|
64
78
|
goes through its one no-follow descriptor door (identity-bound source, exclusive destination,
|
|
@@ -98,7 +112,9 @@ free-form session-records digest slot (every section outside `## Provision recor
|
|
|
98
112
|
and byte-preserved by the tool).
|
|
99
113
|
|
|
100
114
|
**Ownership:** MAIN owns MAIN-tree files, commits, pushes, releases, the gate matrix, every
|
|
101
|
-
docs/ai record, `docs/plans/queue.md
|
|
115
|
+
docs/ai record, `docs/plans/queue.md` (the satellite READS it at the absolute main path and never
|
|
116
|
+
writes it — its findings ride the handoff and main appends them; see **Provision record** above),
|
|
117
|
+
and all shared git state — stash, hooks, repo config,
|
|
102
118
|
`.git/info/exclude`, and every ref except the satellite's configured branch. The SATELLITE owns
|
|
103
119
|
that one branch (`aw/<slug>` or the `--branch` override), its feature edits, its seeded plan, and
|
|
104
120
|
the user-owned handoff sections; `## Provision record` remains tool-owned. Satellite forbidden
|
|
@@ -106,7 +122,8 @@ verbs (the v1 docs-only bar): no `git commit`/`push`/`tag`/`git stash`/history r
|
|
|
106
122
|
legal rewrite is the tool-printed `git reset --hard` recovery of the satellite's OWN configured
|
|
107
123
|
branch (`aw/<slug>` or the `--branch` override) — no kit lifecycle writers
|
|
108
124
|
(`init`/`upgrade`/`setup`/`hide-footprint`/`install-git-hooks`/`sandbox-masks`/`ack-write`), no
|
|
109
|
-
queue.md writes
|
|
125
|
+
queue.md writes and no LOCAL queue.md copy — findings go into the handoff record, which main folds
|
|
126
|
+
into the index at landing, no version bumps or publishes, no edits to MAIN's files from the satellite
|
|
110
127
|
session — divergence and the landed verification enforce the observable half; the rest is the
|
|
111
128
|
stated contract. A symlinked `node_modules` under npm workspaces resolves
|
|
112
129
|
workspace self-links to MAIN-tree sources — use the printed isolated install when that matters.
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
ACKS_FILE,
|
|
34
34
|
} from './recommendations.mjs';
|
|
35
35
|
import { SKIPPED_READONLY } from './setup-backends.mjs';
|
|
36
|
+
import { LATENT_ARM_NOTICE } from './review-state.mjs';
|
|
37
|
+
import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN } from './worktrees.mjs';
|
|
36
38
|
|
|
37
39
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
40
|
|
|
@@ -41,6 +43,8 @@ const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
|
|
|
41
43
|
const UPGRADE_DOC = 'references/modes/upgrade.md';
|
|
42
44
|
const VELOCITY_DOC = 'references/modes/velocity.md';
|
|
43
45
|
const SETUP_DOC = 'references/modes/setup.md';
|
|
46
|
+
const REVIEW_STATE_DOC = 'references/modes/review-state.md';
|
|
47
|
+
const WORKTREES_DOC = 'references/modes/worktrees.md';
|
|
44
48
|
|
|
45
49
|
// A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
|
|
46
50
|
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -90,6 +94,15 @@ export const BINDINGS = Object.freeze([
|
|
|
90
94
|
// (setup.md owns --refresh-placed; upgrade.md pastes its lines) — a reworded doc dropping the
|
|
91
95
|
// outcome fails this pin plus the gate. The token tracks the exported SETUP constant.
|
|
92
96
|
valueBinding('refresh-skipped-readonly', SKIPPED_READONLY, SKIPPED_READONLY, [SETUP_DOC, UPGRADE_DOC]),
|
|
97
|
+
// The "the tool knows and does not say" contract: a clean-tree PASS must still name a latent arm.
|
|
98
|
+
// It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
|
|
99
|
+
// actually emits — a reworded doc dropping the notice fails this pin plus the gate.
|
|
100
|
+
valueBinding('latent-arm-notice', LATENT_ARM_NOTICE, LATENT_ARM_NOTICE, [REVIEW_STATE_DOC]),
|
|
101
|
+
// The provision-record orientation contract (same "the tool knows and does not say" class): the
|
|
102
|
+
// shared-queue rule and the landing-from-main fact were prose-only bars a doc could silently
|
|
103
|
+
// drop, so both are pinned to the live strings the record actually carries.
|
|
104
|
+
valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
|
|
105
|
+
valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
|
|
93
106
|
].map((b) => Object.freeze(b)));
|
|
94
107
|
|
|
95
108
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -135,8 +148,10 @@ Usage:
|
|
|
135
148
|
|
|
136
149
|
A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
|
|
137
150
|
table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
|
|
138
|
-
contract (section header, empty line, verdict templates), the acks-store path,
|
|
139
|
-
|
|
151
|
+
contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
|
|
152
|
+
degrade token, the review-state clean-tree latent-arm notice, and the worktrees provision-record
|
|
153
|
+
orientation contract (shared-queue rule, landing-from-main) — to the exact token its
|
|
154
|
+
references/modes/*.md contract must carry, and
|
|
140
155
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
141
156
|
or an absent token FAILS CLOSED.
|
|
142
157
|
|
package/tools/review-state.mjs
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
// absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
|
|
14
14
|
// holds no top-level .md that is not queue.md and not scratch by the naming convention:
|
|
15
15
|
// prefixes EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when
|
|
16
|
-
// the tree is clean (nothing to review
|
|
16
|
+
// the tree is clean (nothing to review — under a non-solo review obligation the PASS
|
|
17
|
+
// still NAMES every plan in flight and states that the gate arms as soon as the tree
|
|
18
|
+
// turns dirty, so a latent arm is discoverable before it blocks a pending commit); when
|
|
19
|
+
// the cwd is not a git work tree
|
|
20
|
+
// (the gate can never arm there, so nothing is announced); and when
|
|
17
21
|
// the obligations are SATISFIED: under `reviewed`, >=1 backend's latest normal receipt
|
|
18
22
|
// attests SHIP-CLASS (ship / ship with nits) for the current tree; under `council`,
|
|
19
23
|
// EVERY review-capable backend attests ship-class OR carries an explicit current-tree
|
|
@@ -172,6 +176,25 @@ const maskAdvisoryLine = (state) =>
|
|
|
172
176
|
|
|
173
177
|
// ── plan-in-flight detector (the AD-038 naming convention; documented in queue.md) ─────
|
|
174
178
|
|
|
179
|
+
// The clean-tree PASS wording, split so the doc-parity registry can bind the forward-looking half
|
|
180
|
+
// to the mode doc — the notice is the contract, not decoration.
|
|
181
|
+
export const CLEAN_TREE_PASS = 'the working tree is clean — nothing to review';
|
|
182
|
+
export const LATENT_ARM_NOTICE = 'this gate arms as soon as the tree is dirty';
|
|
183
|
+
|
|
184
|
+
const UNSAFE_INLINE_SCALARS = /[\u007f-\u009f\u2028\u2029]/gu;
|
|
185
|
+
// A plan FILENAME reaches a one-line gate report. JSON quoting handles the C0 control bytes; the C1
|
|
186
|
+
// range (DEL + U+0080..U+009F, including the U+0085 line break) and the two Unicode line separators
|
|
187
|
+
// survive JSON.stringify and can still break a line, so they are escaped explicitly — every escape a
|
|
188
|
+
// valid JSON \uXXXX, so the token round-trips through JSON.parse. The plan set stays STRING-typed for
|
|
189
|
+
// its consumers (procedures / worktrees / the JSON surface); a name carrying INVALID UTF-8 bytes is a
|
|
190
|
+
// stated residual (queue: PARALLEL-TRACK-REDESIGN — two such names could collapse in the DISPLAY only,
|
|
191
|
+
// never in the gate decision, which keys on the receipt fingerprint, not the name).
|
|
192
|
+
export const quoteReportName = (name) =>
|
|
193
|
+
JSON.stringify(name).replace(
|
|
194
|
+
UNSAFE_INLINE_SCALARS,
|
|
195
|
+
(value) => `\\u${value.codePointAt(0).toString(16).padStart(4, '0')}`,
|
|
196
|
+
);
|
|
197
|
+
|
|
175
198
|
// Scratch by the naming convention: EXECUTE-/FEEDBACK- prefixes, or a name carrying PROMPT/prompt/
|
|
176
199
|
// handoff. queue.md is the series index, never a plan.
|
|
177
200
|
export const isScratchPlanName = (name) =>
|
|
@@ -183,7 +206,7 @@ export const isScratchPlanName = (name) =>
|
|
|
183
206
|
name.includes('handoff');
|
|
184
207
|
|
|
185
208
|
// The in-flight plan files: top-level docs/plans/*.md minus queue.md minus scratch. [] when the
|
|
186
|
-
// directory is absent (no plans → nothing in flight).
|
|
209
|
+
// directory is absent (no plans → nothing in flight). STRING-typed for every consumer.
|
|
187
210
|
export const plansInFlight = (cwd, readdir = readdirSync) => {
|
|
188
211
|
let entries;
|
|
189
212
|
try {
|
|
@@ -392,7 +415,14 @@ export const decideCheck = (state) => {
|
|
|
392
415
|
}
|
|
393
416
|
if (state.plans.length === 0) return { code: 0, reason: `no plan in flight (docs/plans/ holds no active plan) — no receipt required${earlyNotes}` };
|
|
394
417
|
if (state.fingerprint == null) return { code: 0, reason: `not a git work tree — nothing to fingerprint${earlyNotes}` };
|
|
395
|
-
|
|
418
|
+
// A clean tree PASSES, but never silently: the no-plan arm above already returned, so reaching
|
|
419
|
+
// here means >=1 plan IS in flight and this gate arms the moment the tree turns dirty. Naming
|
|
420
|
+
// each file forward-looking is what makes the arm discoverable BEFORE it blocks a pending commit.
|
|
421
|
+
if (state.clean === true) {
|
|
422
|
+
// Each name is byte-preserving and quoted because this reason is one gate-report line.
|
|
423
|
+
const named = state.plans.map((p) => quoteReportName(p)).join(', ');
|
|
424
|
+
return { code: 0, reason: `${CLEAN_TREE_PASS} — ${state.plans.length} plan(s) in flight: ${named} — ${LATENT_ARM_NOTICE}${earlyNotes}` };
|
|
425
|
+
}
|
|
396
426
|
const exempt = new Set(state.degradedExempt);
|
|
397
427
|
const satisfied = state.backends.filter((b) => b.state === 'current' && b.shipClass);
|
|
398
428
|
const vetoed = state.backends.filter((b) => b.state === 'current' && !b.shipClass);
|
|
@@ -463,7 +493,7 @@ const formatHuman = (state, check) => {
|
|
|
463
493
|
`review-state — ${ACTIVITY}.${SLOT} = ${state.obligations.recipe ?? '(unknowable)'} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}${state.obligations.perBackend ? '' : ' (any one, ship-class)'}` : ''}`,
|
|
464
494
|
];
|
|
465
495
|
if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
|
|
466
|
-
lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
|
|
496
|
+
lines.push(` plan in flight: ${state.plans.length ? state.plans.map((name) => quoteReportName(name)).join(', ') : '(none)'}`);
|
|
467
497
|
if (state.fingerprint == null) lines.push(' tree: not a git work tree');
|
|
468
498
|
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
469
499
|
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
@@ -517,7 +547,8 @@ The ONLY escape for an unavailable backend under council is an explicit current-
|
|
|
517
547
|
record (node core-evidence.mjs degrade) — and never all backends.
|
|
518
548
|
|
|
519
549
|
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
520
|
-
a clean tree
|
|
550
|
+
a clean tree (under a non-solo review obligation the PASS names every plan in flight and its arm) /
|
|
551
|
+
not-a-git-tree / obligations satisfied (reviewed: >=1 ship-class attestation;
|
|
521
552
|
council: every backend ship-class or degrade-recorded, >=1 real ship); 1 on a veto, an
|
|
522
553
|
unrecognized verdict, a missing/stale/ungrounded/probe-only backend without a degrade record,
|
|
523
554
|
an all-degraded tree, or a down detector with no configured recipe.
|
package/tools/worktrees.mjs
CHANGED
|
@@ -927,16 +927,58 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
|
927
927
|
|
|
928
928
|
// ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
|
|
929
929
|
|
|
930
|
-
|
|
930
|
+
// The orientation facts a fresh satellite session cannot derive from its own checkout. They are
|
|
931
|
+
// CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
|
|
932
|
+
export const QUEUE_BASENAME = 'queue.md';
|
|
933
|
+
export const QUEUE_SHARED_RULE =
|
|
934
|
+
'the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here';
|
|
935
|
+
export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
|
|
936
|
+
|
|
937
|
+
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
938
|
+
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
939
|
+
// (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
|
|
940
|
+
// the whole section). Values reach here from the repo ROOT path and from --include, both of which
|
|
941
|
+
// may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
|
|
942
|
+
// record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
|
|
943
|
+
// not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
|
|
944
|
+
// a lost field with no error, which is the one outcome this codebase never allows.
|
|
945
|
+
// Fail closed: refuse to write, never sanitize silently.
|
|
946
|
+
const RECORD_CONTROL_BYTE = /[\u0000-\u001F\u007F\u2028\u2029]/;
|
|
947
|
+
const recordValue = (name, value) => {
|
|
948
|
+
const text = String(value);
|
|
949
|
+
if (RECORD_CONTROL_BYTE.test(text)) {
|
|
950
|
+
throw stop(`handoff record: the ${name} value carries a control character (newline/CR/NUL) — refusing to write a record whose fields could be forged by an injected line`);
|
|
951
|
+
}
|
|
952
|
+
// The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
|
|
953
|
+
// — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
|
|
954
|
+
// back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
|
|
955
|
+
if (text !== text.trim()) {
|
|
956
|
+
throw stop(`handoff record: the ${name} value carries leading or trailing whitespace, which the record trims on read — the identity would change across a write→read round-trip: ${JSON.stringify(text)}`);
|
|
957
|
+
}
|
|
958
|
+
return text;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
|
|
962
|
+
// earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
|
|
963
|
+
// that kit never wrote must survive the round-trip as absence, not as a literal null string.
|
|
964
|
+
const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
|
|
965
|
+
|
|
966
|
+
const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null }) => [
|
|
931
967
|
'## Provision record',
|
|
932
968
|
'',
|
|
933
|
-
`- slug: ${slug}`,
|
|
934
|
-
`- branch: ${branch}`,
|
|
935
|
-
...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${p}`)),
|
|
936
|
-
`- node_modules: ${nodeModules}`,
|
|
937
|
-
`- vscode-settings: ${vscode}`,
|
|
938
|
-
...(
|
|
969
|
+
`- slug: ${recordValue('slug', slug)}`,
|
|
970
|
+
`- branch: ${recordValue('branch', branch)}`,
|
|
971
|
+
...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
|
|
972
|
+
`- node_modules: ${recordValue('node_modules', nodeModules)}`,
|
|
973
|
+
`- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
|
|
974
|
+
...optionalField('install', install),
|
|
975
|
+
...optionalField('shared-queue', sharedQueue),
|
|
976
|
+
...optionalField('landing', landing),
|
|
977
|
+
...optionalField('prepared-tree', prepared),
|
|
939
978
|
'',
|
|
979
|
+
// The rule says "at the absolute path above", so it ships only WITH that path: a record from an
|
|
980
|
+
// earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
|
|
981
|
+
...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
|
|
940
982
|
].join('\n');
|
|
941
983
|
|
|
942
984
|
export const composeHandoffStub = (fields) => [
|
|
@@ -965,10 +1007,11 @@ const locateProvisionRecordSection = (text) => {
|
|
|
965
1007
|
export const parseProvisionRecord = (text) => {
|
|
966
1008
|
const section = locateProvisionRecordSection(text);
|
|
967
1009
|
const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
|
|
968
|
-
const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, prepared: null };
|
|
1010
|
+
const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null };
|
|
969
1011
|
const single = {
|
|
970
1012
|
slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
|
|
971
1013
|
'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
|
|
1014
|
+
install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
|
|
972
1015
|
};
|
|
973
1016
|
const seen = new Set();
|
|
974
1017
|
for (const line of scan) {
|
|
@@ -988,11 +1031,67 @@ export const parseProvisionRecord = (text) => {
|
|
|
988
1031
|
return record;
|
|
989
1032
|
};
|
|
990
1033
|
|
|
991
|
-
|
|
992
|
-
|
|
1034
|
+
// Derived from MAIN's root, so the satellite reads an absolute path and a command that already
|
|
1035
|
+
// cd-s back to main — neither is derivable from inside the worktree.
|
|
1036
|
+
const orientationFields = ({ root, slug }) => ({
|
|
1037
|
+
sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
|
|
1038
|
+
landing: `${LANDING_FROM_MAIN} — ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
|
|
1042
|
+
// the repo ROOT, so validating them here validates the root itself.
|
|
1043
|
+
const assertRecordValuesComposable = ({ root, slug, branch }) => {
|
|
1044
|
+
recordValue('slug', slug);
|
|
1045
|
+
recordValue('branch', branch);
|
|
1046
|
+
const orientation = orientationFields({ root, slug });
|
|
1047
|
+
recordValue('shared-queue', orientation.sharedQueue);
|
|
1048
|
+
recordValue('landing', orientation.landing);
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
// An `- include:` value also round-trips through the literal `(none)` empty-list sentinel, so that
|
|
1052
|
+
// exact text reads back as NO value at all and cleanup would not recognize the copied path.
|
|
1053
|
+
// (Edge whitespace is refused inside recordValue — the same trim-on-read hazard for every field.)
|
|
1054
|
+
const assertIncludeRoundTrips = (rel) => {
|
|
1055
|
+
recordValue('include', rel);
|
|
1056
|
+
// No empty-rel arm: an include resolving TO the repo root is already refused by the containment
|
|
1057
|
+
// check above (`isInside` excludes the root itself), so a guard here would be unreachable.
|
|
1058
|
+
if (rel === '(none)') {
|
|
1059
|
+
throw stop('--include resolves to the literal "(none)", which the provision record uses as its empty-list sentinel — rename the path before provisioning');
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
// The shared series index must never reach a satellite — a machine-local copy silently diverges
|
|
1064
|
+
// from what main and every other worktree are writing, which is the whole point of the
|
|
1065
|
+
// read-only-at-the-absolute-path contract. `--include` is the one lane that could smuggle it in,
|
|
1066
|
+
// by naming the file OR any directory that contains it. The compare runs in BOTH spaces: `incReal`
|
|
1067
|
+
// is canonical, so a queue.md (or docs/plans) that is itself a symlink canonicalizes AWAY from the
|
|
1068
|
+
// lexical queue path and would walk straight through a lexical-only compare while copying the very
|
|
1069
|
+
// content the rule fences. Fail closed: ONLY an ABSENT queue path (ENOENT — nothing exists there
|
|
1070
|
+
// to smuggle) falls back to the lexical compare alone; any other realpath failure (EACCES/EIO)
|
|
1071
|
+
// means the canonical identity cannot be established, and a silent fallback would quietly disable
|
|
1072
|
+
// the guard it exists to enforce.
|
|
1073
|
+
const assertIncludeNeverCopiesTheQueue = ({ rootReal, incReal, inc, fs }) => {
|
|
1074
|
+
const queueLexical = join(rootReal, PLANS_REL, QUEUE_BASENAME);
|
|
1075
|
+
const queuePaths = [queueLexical];
|
|
1076
|
+
try {
|
|
1077
|
+
queuePaths.push(fs.realpath(queueLexical));
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
if (err?.code !== 'ENOENT') {
|
|
1080
|
+
throw stop(`--include: cannot resolve the shared series index path (${err?.code ?? 'error'}), so the queue-copy guard cannot establish its canonical identity: ${PLANS_REL}/${QUEUE_BASENAME} — fix the path (or drop the --include) and re-run`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
for (const queuePath of queuePaths) {
|
|
1084
|
+
if (incReal === queuePath || isInside(incReal, queuePath)) {
|
|
1085
|
+
throw stop(`--include would copy the SHARED series index (${PLANS_REL}/${QUEUE_BASENAME}) into the worktree: ${inc}. The index lives only in main and is read there — a local copy silently diverges.`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const pendingHandoffFields = ({ root, slug, branch }) =>
|
|
1091
|
+
({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
|
|
993
1092
|
|
|
994
1093
|
// The stub is written only when ABSENT; the final record surgically replaces the tool section.
|
|
995
|
-
const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
|
|
1094
|
+
const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) => {
|
|
996
1095
|
const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
|
|
997
1096
|
const cur = readFileNoFollow(fs, dst);
|
|
998
1097
|
if (cur.bytes) {
|
|
@@ -1004,7 +1103,7 @@ const writeHandoffStubIfAbsent = ({ wtRoot, slug, branch, fs, report }) => {
|
|
|
1004
1103
|
}
|
|
1005
1104
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1006
1105
|
fs.mkdir(dirname(dst));
|
|
1007
|
-
writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ slug, branch })), fs, { stop: (m) => stop(m) });
|
|
1106
|
+
writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
|
|
1008
1107
|
};
|
|
1009
1108
|
|
|
1010
1109
|
const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
|
|
@@ -1070,26 +1169,25 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
|
|
|
1070
1169
|
report.push(` seeded plan: ${PLANS_REL}/${name}`);
|
|
1071
1170
|
};
|
|
1072
1171
|
|
|
1073
|
-
|
|
1172
|
+
// The include set is resolved and identity-checked in runProvision — BEFORE `git worktree add` —
|
|
1173
|
+
// against the queue-copy guard and the round-trip guard, and arrives here as {rel, real} pairs.
|
|
1174
|
+
// It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
|
|
1175
|
+
// realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
|
|
1176
|
+
// redirect an include at the shared series index between the check and the copy.
|
|
1177
|
+
const provisionIncludes = ({ rootReal, wtRoot, includeSources, git, fs, report, copied }) => {
|
|
1074
1178
|
const recorded = [];
|
|
1075
|
-
for (const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
} catch {
|
|
1081
|
-
throw stop(`--include: not found: ${inc}`);
|
|
1082
|
-
}
|
|
1083
|
-
if (!isInside(rootReal, srcReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
|
|
1084
|
-
const rel = relative(rootReal, srcReal);
|
|
1085
|
-
const probeRel = fs.lstat(srcReal).isDirectory() ? `${rel}/` : rel;
|
|
1179
|
+
for (const { rel, real } of includeSources) {
|
|
1180
|
+
// Defence in depth: re-assert the queue-copy prohibition on the canonical path at the POINT OF
|
|
1181
|
+
// USE, so it holds where the copy happens and not only where the path was first checked.
|
|
1182
|
+
assertIncludeNeverCopiesTheQueue({ rootReal, incReal: real, inc: rel, fs });
|
|
1183
|
+
const probeRel = fs.lstat(real).isDirectory() ? `${rel}/` : rel;
|
|
1086
1184
|
if (!checkIgnored(git, probeRel, wtRoot)) {
|
|
1087
1185
|
throw stop(
|
|
1088
1186
|
`--include destination is not ignored in the worktree: ${rel} — it would become a land-preflight leftover. ` +
|
|
1089
1187
|
'Recovery: ignore the path (shared exclude / .gitignore) or drop the --include.',
|
|
1090
1188
|
);
|
|
1091
1189
|
}
|
|
1092
|
-
copyNode({ srcAbs:
|
|
1190
|
+
copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied });
|
|
1093
1191
|
recorded.push(rel);
|
|
1094
1192
|
}
|
|
1095
1193
|
return recorded;
|
|
@@ -1152,6 +1250,20 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
|
|
|
1152
1250
|
return { command, instruction: command };
|
|
1153
1251
|
};
|
|
1154
1252
|
|
|
1253
|
+
// What the RECORD states about installing — the resolved posture for THIS worktree (the runnable
|
|
1254
|
+
// isolated-install command, or the honest by-hand advice), never a lane-dependent hint. Probed on
|
|
1255
|
+
// the LIVE worktree after the node_modules step: a plain `cd … && install` through a SYMLINKED
|
|
1256
|
+
// node_modules writes into MAIN — never presented as isolated; the symlink case records the
|
|
1257
|
+
// unlink-first form instead (the same contract the --install lane already prints).
|
|
1258
|
+
const resolveInstallPosture = ({ root, wtRoot, fs }) => {
|
|
1259
|
+
const advice = resolveInstallAdvice({ root, wtRoot, fs });
|
|
1260
|
+
const nmPath = join(wtRoot, 'node_modules');
|
|
1261
|
+
const nm = lstatNoFollow(fs.lstat, nmPath);
|
|
1262
|
+
if (nm === null || !nm.isSymbolicLink()) return advice.instruction;
|
|
1263
|
+
const separator = advice.command === null ? ' — ' : ' && ';
|
|
1264
|
+
return `the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN) — for isolation remove it first: rm ${shellQuoteArg(nmPath)}${separator}${advice.instruction}`;
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1155
1267
|
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, git, fs, report }) => {
|
|
1156
1268
|
const install = resolveInstallAdvice({ root, wtRoot, fs });
|
|
1157
1269
|
if (installFlag) {
|
|
@@ -1323,6 +1435,11 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1323
1435
|
const branch = flags.branch ?? `${DEFAULT_BRANCH_PREFIX}${slug}`;
|
|
1324
1436
|
if (flags.plan == null) throw usageStop('provision requires --plan <path> (the ONE feature plan the worktree starts with)');
|
|
1325
1437
|
const { root, commonDir } = resolveRoots(cwd, git);
|
|
1438
|
+
// Composing the record is the LAST step of provision, so a refusal there would leave a created
|
|
1439
|
+
// worktree with no handoff — which neither --resume nor `cleanup --abandon` can recover, because
|
|
1440
|
+
// both bind on the handoff identity. Every value the record will carry is therefore checked HERE,
|
|
1441
|
+
// before the first fs read and long before `git worktree add`.
|
|
1442
|
+
assertRecordValuesComposable({ root, slug, branch });
|
|
1326
1443
|
const rootReal = fs.realpath(root);
|
|
1327
1444
|
const config = loadWorktreesConfig(root, deps);
|
|
1328
1445
|
const targetAbs = resolveTargetDir({ root, slug, dirFlag: flags.dir ?? null, parentDir: config.parentDir });
|
|
@@ -1356,9 +1473,16 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1356
1473
|
throw stop(`--include: not found: ${inc}`);
|
|
1357
1474
|
}
|
|
1358
1475
|
if (!isInside(rootReal, incReal)) throw stop(`--include must resolve inside the main repo: ${inc}`);
|
|
1359
|
-
|
|
1476
|
+
assertIncludeNeverCopiesTheQueue({ rootReal, incReal, inc, fs });
|
|
1477
|
+
const rel = relative(rootReal, incReal);
|
|
1478
|
+
assertIncludeRoundTrips(rel);
|
|
1479
|
+
includeSources.push({ rel, real: incReal });
|
|
1360
1480
|
}
|
|
1361
1481
|
assertTargetOutsideSources({ targetReal, sources: [...sources, ...includeSources] });
|
|
1482
|
+
// The TARGET path reaches the record too — the `install` field embeds the worktree dir — and it
|
|
1483
|
+
// is only known here, after --dir/parentDir resolution. Validating it now keeps the whole record
|
|
1484
|
+
// composable BEFORE `git worktree add`, which is the point of every check above.
|
|
1485
|
+
recordValue('target-dir', targetReal);
|
|
1362
1486
|
|
|
1363
1487
|
const probeDir = resolveProbeDir(dirname(targetReal), deps);
|
|
1364
1488
|
// the probe itself is a create+delete write — on resume it runs only AFTER every identity check
|
|
@@ -1399,7 +1523,7 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1399
1523
|
// any failure past this point leaves a real created worktree — the error must say so and
|
|
1400
1524
|
// hand back the exact finish command, never just the local cause
|
|
1401
1525
|
try {
|
|
1402
|
-
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, git, deps, fs, report, log });
|
|
1526
|
+
return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, git, deps, fs, report, log });
|
|
1403
1527
|
} catch (err) {
|
|
1404
1528
|
if (!flags.resume && err?.message) {
|
|
1405
1529
|
err.message += `\nNOTE: the worktree at ${targetReal} (branch ${branch}) was created and KEPT — finish with: ${composeProvisionArgv({ root, slug, flags: { ...flags, resume: true } })} (or reclaim it with the consented cleanup).`;
|
|
@@ -1408,8 +1532,8 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
1408
1532
|
}
|
|
1409
1533
|
};
|
|
1410
1534
|
|
|
1411
|
-
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, git, deps, fs, report, log }) => {
|
|
1412
|
-
writeHandoffStubIfAbsent({ wtRoot: targetPath, slug, branch, fs, report });
|
|
1535
|
+
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, git, deps, fs, report, log }) => {
|
|
1536
|
+
writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
|
|
1413
1537
|
|
|
1414
1538
|
const copied = new Set();
|
|
1415
1539
|
report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
|
|
@@ -1419,7 +1543,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1419
1543
|
}
|
|
1420
1544
|
|
|
1421
1545
|
writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
|
|
1422
|
-
const includesRecorded = provisionIncludes({
|
|
1546
|
+
const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, git, fs, report, copied });
|
|
1423
1547
|
const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, git, fs, report });
|
|
1424
1548
|
const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
|
|
1425
1549
|
|
|
@@ -1429,7 +1553,15 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
1429
1553
|
wtRoot: targetPath,
|
|
1430
1554
|
slug,
|
|
1431
1555
|
branch,
|
|
1432
|
-
fields: {
|
|
1556
|
+
fields: {
|
|
1557
|
+
slug,
|
|
1558
|
+
branch,
|
|
1559
|
+
includes: includesRecorded,
|
|
1560
|
+
nodeModules: nodeModulesMode,
|
|
1561
|
+
vscode: vscodeMode,
|
|
1562
|
+
install: resolveInstallPosture({ root, wtRoot: targetPath, fs }),
|
|
1563
|
+
...orientationFields({ root, slug }),
|
|
1564
|
+
},
|
|
1433
1565
|
fs,
|
|
1434
1566
|
report,
|
|
1435
1567
|
});
|