@sabaiway/agent-workflow-kit 7.0.0 → 7.2.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 +84 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/mcp.md +2 -0
- package/references/modes/worktrees.md +16 -1
- package/references/templates/agent_rules.md +2 -0
- package/tools/fold-scope-cli.mjs +93 -0
- package/tools/fold-scope.mjs +307 -0
- package/tools/known-footprint.mjs +7 -1
- package/tools/mcp-registration.mjs +70 -0
- package/tools/mcp.mjs +26 -5
- package/tools/procedures.mjs +29 -4
- package/tools/worktrees.mjs +105 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,90 @@ 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
|
+
## 7.2.0 — `.mcp.json` joins the hidden-mode footprint, and a worktree never owns it (AD-111)
|
|
8
|
+
|
|
9
|
+
7.0.0 taught the kit to register its MCP server. It did not teach hidden mode about the file that
|
|
10
|
+
registration writes — so a hidden deployment that ran `/agent-workflow-kit mcp` left `.mcp.json`
|
|
11
|
+
sitting in `git status`, one `git add -A` away from a commit. That is exactly the leak the footprint
|
|
12
|
+
registry exists to prevent, and it shipped anyway.
|
|
13
|
+
|
|
14
|
+
- **`/.mcp.json` is in `KIT_OWN_PATHS`.** It joins `/.claude/settings.json` as a SHARED container the
|
|
15
|
+
kit merges into rather than owns, on the same terms: the tracked→ASK arm means a project that
|
|
16
|
+
already commits its own registration is asked, never silently un-tracked. Candidacy is
|
|
17
|
+
unconditional on purpose — the path is CONSIDERED before a later `mcp --apply` writes the file
|
|
18
|
+
rather than only after it appears, and is then hidden, dropped as already covered by a tracked
|
|
19
|
+
`.gitignore`, or surfaced as an ASK. That is what removes the follow-up reconcile the bug depended
|
|
20
|
+
on. The `contracts.md` prose mirror names it.
|
|
21
|
+
- **A worktree is never HANDED one, and never owns one.** The scope is the untracked lanes this tool
|
|
22
|
+
drives — the registry copy set and `--include`; a TRACKED `.mcp.json` still arrives with
|
|
23
|
+
`git worktree add`, because that is the checkout, not a copy. One frozen never-provisioned set,
|
|
24
|
+
read at three sites because each needs it for a different reason: the copy set would hand a
|
|
25
|
+
satellite a launcher pinned to a path consented for the MAIN checkout; the containment sweep would
|
|
26
|
+
STOP the whole provision on an escaping symlink at that path (a device-node mask realpaths inside
|
|
27
|
+
the repo and reaches the later special-file refusal instead); and `registryRoots` decides what
|
|
28
|
+
`cleanup` may REMOVE. That third one was measured — without the guard, `cleanup` on a landed
|
|
29
|
+
worktree carrying a satellite's own `.mcp.json` exited 0 and took the file with it. `--include` is
|
|
30
|
+
refused pre-mutation for the same paths: it is the one door that bypasses the copy set, and it
|
|
31
|
+
would have recorded the launcher as ours.
|
|
32
|
+
- **A settings copy this tool wrote never enables a server the worktree cannot declare.** The
|
|
33
|
+
guarantee is exactly that narrow. `.claude/settings.json` rides `REBASE_TARGETS` into a worktree
|
|
34
|
+
carrying `enabledMcpjsonServers`, which `mcp.md` itself calls a client error on every startup. An
|
|
35
|
+
untracked copy still equal to MAIN loses our enable and our two derived allow rules — and ONLY
|
|
36
|
+
those; every foreign server, rule and key survives, as do the file's EOL and the order of its
|
|
37
|
+
non-index keys (`withoutRegistration`, the token-removal complement of `mergeSettings`, not its
|
|
38
|
+
exact inverse — a JSON round-trip still hoists integer-like keys, as JS object semantics require).
|
|
39
|
+
**Where a rewrite would LOSE something the kit does not own, it is refused whole and says why.**
|
|
40
|
+
Three ways, each detected before anything is written: a number past double precision comes back a
|
|
41
|
+
different value; a duplicate key at any depth collapses to the last one (both found on the source
|
|
42
|
+
TEXT, because after the parse the evidence is gone); and invalid UTF-8 would become U+FFFD, so both
|
|
43
|
+
rewriting lanes — the pin rebase and the token strip — decode with FATAL UTF-8 and refuse
|
|
44
|
+
separately. The MAIN-equality proof compares BYTES, with the BOM preserved through the decode, so
|
|
45
|
+
a BOM-carrying original and a BOM-less copy can never pass for the same file.
|
|
46
|
+
The rewrite is admitted by ONE positive conjunction — untracked, bytes still MAIN's or their
|
|
47
|
+
rebased form, and a launcher proven ABSENT — so a tracked or user-modified settings file keeps its
|
|
48
|
+
registration tokens on purpose, as does one standing beside a launcher that is present or that
|
|
49
|
+
cannot be read. Those states get a neutral line naming the tokens; orphanhood is only CLAIMED where
|
|
50
|
+
it is proven.
|
|
51
|
+
- **All three output arms name the reconcile.** Applied, masked hand-off, and already-registered —
|
|
52
|
+
the last one matters most, because a deployment registered under 7.0.0 learns about the reconcile
|
|
53
|
+
on its next run. A settings write that fails after the entry landed carries the note too: that
|
|
54
|
+
path never reaches the report, and it strands a standing registration.
|
|
55
|
+
|
|
56
|
+
**Known residual, stated:** a VISIBLE deployment still commits a launcher carrying an absolute
|
|
57
|
+
machine path. The fix is not symmetric with hidden mode — a visible `.mcp.json` may legitimately hold
|
|
58
|
+
a team's own committable servers — so it is its own queued decision, not a line in this release.
|
|
59
|
+
|
|
60
|
+
## 7.1.0 — `fold-scope`: the fold channel gets a checker that refuses a claim whose reference does not resolve (AD-110)
|
|
61
|
+
|
|
62
|
+
The engine canon now asks every finding to NAME the invariant its fix enforces before the edit
|
|
63
|
+
(engine **3.1.0**). Prose cannot check that a named invariant is really where you say it is.
|
|
64
|
+
|
|
65
|
+
- **`tools/fold-scope.mjs` + `tools/fold-scope-cli.mjs`** — the pure rule and its CLI half:
|
|
66
|
+
`--class in-scope|new-invariant|blocking`, `--claim "<the invariant>"`, `--plan <plan-file>`,
|
|
67
|
+
`--queue <queue-file>`. Exit 0 for every ACCEPT, non-zero for every REFUSE, one case per arm.
|
|
68
|
+
- **`--plan` and `--queue` are REQUIRED and never defaulted.** A project can have two registers that
|
|
69
|
+
both look like "the queue" — the planning lifecycle's `docs/plans/queue.md` and a declared
|
|
70
|
+
`flow.debtQueue` — and a checker that guessed would attest a deferral against the wrong file.
|
|
71
|
+
- **What it refuses**, each naming the lane out of it: an `in-scope` claim matching no acceptance
|
|
72
|
+
bullet; a `new-invariant` whose invariant IS an acceptance bullet (route to fold); a deferral with
|
|
73
|
+
no queue row, with several, whose row title carries `DONE`/`CLOSED`, that repeats a field label,
|
|
74
|
+
that is missing a field (named, including an `origin` that is not a `file:line`), whose claim is
|
|
75
|
+
not inside the row's `invariant` field, or whose residual exposure is declared live (route to
|
|
76
|
+
blocking) or declares neither.
|
|
77
|
+
- **`/agent-workflow-kit procedures plan-execution` renders the populated command** — under solo,
|
|
78
|
+
reviewed AND council, because the rule routes every finding and a Solo project must see the checker
|
|
79
|
+
too — and NAMES which of the two registers it chose for `--queue`. Every PATH operand goes through
|
|
80
|
+
the family's shell quoter (bare when the value is already safe, single-quoted when it is not), and
|
|
81
|
+
the two placeholders are single-quoted literals so their angle brackets and pipe are inert — the
|
|
82
|
+
line is safe to paste as rendered. `--json` gains an additive `foldScope` array.
|
|
83
|
+
- **Markdown is read through `references/scripts/markdown-blocks.mjs`**, the block model the
|
|
84
|
+
archivers already use, so fenced examples, heading levels and CRLF are solved once for the family;
|
|
85
|
+
a document that model refuses is a loud refusal, never a silent empty read.
|
|
86
|
+
|
|
87
|
+
**Advisory by design:** nothing records that the checker ran, so a skipped or late call is
|
|
88
|
+
indistinguishable from a pre-edit declaration. A fingerprint-bound receipt a gate reads is the next
|
|
89
|
+
slice. Additive throughout — no existing command, flag, output field or exit code changed.
|
|
90
|
+
|
|
7
91
|
## 7.0.0 — `/agent-workflow-kit mcp` registers the typed channel in ONE project, and `uninstall` stops reporting an interrupted teardown as a success (AD-108)
|
|
8
92
|
|
|
9
93
|
> ### ⚠ BREAKING — an `uninstall` run that silently passed can now refuse, and its plan reports more
|
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: '7.
|
|
6
|
+
version: '7.2.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": "7.
|
|
3
|
+
"version": "7.2.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",
|
package/references/contracts.md
CHANGED
|
@@ -42,7 +42,7 @@ diverge:
|
|
|
42
42
|
| `/.aider.input.history` | Aider | file | no | input history |
|
|
43
43
|
| `/.continue/` | Continue | dir | no | project config dir |
|
|
44
44
|
|
|
45
|
-
The kit's OWN footprint (`KIT_OWN_PATHS`) — `AGENTS.md`, `CLAUDE.md`, `docs/ai/` (subsumes the stamp), the added `scripts/*.mjs`, `docs/plans/`, `.claude/settings.local.json`,
|
|
45
|
+
The kit's OWN footprint (`KIT_OWN_PATHS`) — `AGENTS.md`, `CLAUDE.md`, `docs/ai/` (subsumes the stamp), the added `scripts/*.mjs`, `docs/plans/`, `.claude/settings.local.json`, `.claude/settings.json` (hidden-only — visible mode commits it), and `.mcp.json` (the `mcp` mode's registration; hidden-only for the same reason, and it carries an absolute machine path) — is always a candidate in hidden mode.
|
|
46
46
|
|
|
47
47
|
Not in this version: a fully-external hidden mode (artifacts relocated outside the repo tree).
|
|
48
48
|
Deferred to a later release + migration.
|
package/references/modes/mcp.md
CHANGED
|
@@ -17,6 +17,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/mcp.mjs [--dry-run | --apply] [--cwd <dir>]`
|
|
|
17
17
|
2. **Ask consent** via **`AskUserQuestion` where supported**, the no-change option first: keep the shell lanes, or register the typed channel — presenting the trust posture above in plain language.
|
|
18
18
|
3. **Only on an explicit yes**, re-run with `--apply`. It writes **`.mcp.json` FIRST, then `.claude/settings.json`** — settings that enable a server whose entry is not yet there is a client error on every startup. Both writes are merge-don't-clobber (foreign servers, foreign keys and existing allow rules preserved; a re-apply adds nothing twice) and keep each file's existing EOL. New MCP servers are picked up when the client next starts — unlike the `hook` mode, this is not a hot reload.
|
|
19
19
|
|
|
20
|
+
4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) so the registration stays invisible to `git status` — `/.mcp.json` is in the known-footprint registry; every output arm reminds you.
|
|
21
|
+
|
|
20
22
|
**The two arms that are not a write:**
|
|
21
23
|
|
|
22
24
|
- **DIFFERING** — `.mcp.json` already carries an `"agent-workflow"` entry that **structurally differs** from ours (another kit copy, a hand-edited path, an added `env`, a `null`). The comparison is deliberately key-order-independent, so a re-serialized identical entry is the SAME registration, while any real difference in what would be launched is a difference. The run **STOPs unwritten, on both lanes**: silently changing what an MCP server launches is exactly what consent must not slide past. The recovery is named — review that entry, then remove or rename it and re-run. The kit never resolves this for you.
|
|
@@ -14,10 +14,25 @@ The ONE stored-metadata exception is the PREPARED OID recorded in the handoff: l
|
|
|
14
14
|
Git ≥ 2.36 is required for NUL-terminated worktree porcelain; an older Git fails closed with its
|
|
15
15
|
own verbatim error through the existing Git-error surface.
|
|
16
16
|
|
|
17
|
+
**The MCP registration is never HANDED to a worktree, and never owned by one.** The scope is the
|
|
18
|
+
untracked lanes this tool drives — the registry copy set and `--include` (refused pre-mutation, and
|
|
19
|
+
a recorded include from an older handoff is filtered out too); a TRACKED `.mcp.json` still arrives
|
|
20
|
+
with `git worktree add`, because that is the checkout, not a copy. `/.mcp.json` names an absolute
|
|
21
|
+
machine path and its consent is per checkout, so it is excluded from the copy set, from the provision
|
|
22
|
+
containment sweep, and from what `cleanup` may remove — a satellite's own `.mcp.json` therefore stays
|
|
23
|
+
classified as foreign work and blocks cleanup, rather than being deleted as ours. The settings half
|
|
24
|
+
is rewritten only under ONE conjunction of proven facts: untracked, bytes still MAIN's (or their
|
|
25
|
+
rebased form), and a launcher proven ABSENT — then the copy loses our `enabledMcpjsonServers`
|
|
26
|
+
membership and our two derived allow rules and nothing else. Every other state keeps its registration
|
|
27
|
+
TOKENS — not necessarily its bytes, since `rebasePins` may still rewrite absolute pins in that same
|
|
28
|
+
file on its own lane — and, where the settings file is readable, well-formed and actually carries
|
|
29
|
+
tokens, gets a neutral line naming them; orphanhood is claimed only where it is proven.
|
|
30
|
+
|
|
17
31
|
- `provision <slug> --plan <path> [--as <name>.md] [--dir <path>] [--branch <name>] [--include <path>]... [--install] [--resume]`
|
|
18
32
|
— create a feature worktree (default: the visible sibling `<repoParent>/<repoName>--<slug>`,
|
|
19
33
|
branch `aw/<slug>`) and populate it: the registry-derived footprint copy-if-missing (a tracked
|
|
20
|
-
file is NEVER overwritten
|
|
34
|
+
file is NEVER overwritten, and `/.mcp.json` is never copied or owned — see below), EXACTLY ONE seeded
|
|
35
|
+
feature plan, the `handoff-<slug>.md` stub
|
|
21
36
|
(written at provision — the tool's own record; `list` and `cleanup` read it), a
|
|
22
37
|
`node_modules` symlink when main has one and the link stays ignored — a SHARED MUTABLE cache:
|
|
23
38
|
writes through it hit MAIN's node_modules; for isolation RUN the printed isolated-install
|
|
@@ -76,10 +76,12 @@ Apply this as part of §2 before any user-facing summary:
|
|
|
76
76
|
### 2.6. Planning, review & process-fidelity invariants
|
|
77
77
|
Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
|
|
78
78
|
- **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
|
|
79
|
+
- **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
|
|
79
80
|
- **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
|
|
80
81
|
- **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
|
|
81
82
|
- **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
|
|
82
83
|
- **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
|
|
84
|
+
- **State table BEFORE the guard — enumerate by PROOF, never by exclusion.** The subtraction rule above fires on a repeat finding, which is a LATE signal: by then the review has paid for each miss. The EARLY signal is structural — a decision whose input has **several independent state dimensions** (is it tracked? do the bytes still match the source? does the neighbouring file exist?). Write the table first, admit the write with **ONE conjunction of proven facts**, and funnel every other cell into a single refusal; the table is then the table-driven test. An exclusion list (`if (bad1) return; if (bad2) return;`) fails **OPEN** on the first state nobody enumerated — and "unreadable" is a state, distinct from "absent". A reviewer cannot save you here: it judges the patch in front of it and can only name the NEXT missing state, one round at a time.
|
|
83
85
|
- **Fold minimally — prose has no checker.** An ephemeral, gitignored plan is prose with no executable checker; fold **minimally, in ONE place** and run a **self-consistency** read across the plan before every re-review — a fold that drifts several prose spots is what turns a 2-round review into churn.
|
|
84
86
|
- **Heavy review at the diff.** Plan-review settles architecture only (≤2 rounds, stop at the pre-existing→fold-induced crossover); the exhaustive per-row review runs against real compiling code + the full suite, where a regression fails a gate immediately. **Backend divergence** (one backend grounded-ships while another keeps revising mechanics) IS that crossover — resolve at altitude, don't exhaust the strictest backend; route an all-mechanics/CI or prose-only artifact to a **thin plan + diff-review**.
|
|
85
87
|
- **Convergence bar.** A review loop is CLEAN only when one round returns **0 blockers + 0 majors** from EVERY backend the recipe names (nits + a ship verdict is the stop). Folding ≠ convergence — re-review after folding.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The CLI half of the finding-scope checker: argv and fs, no rule (the rule is fold-scope.mjs).
|
|
3
|
+
//
|
|
4
|
+
// `--plan` and `--queue` are REQUIRED and never defaulted. A project has two registers that can both
|
|
5
|
+
// look like "the queue" - the planning lifecycle's docs/plans/queue.md and a declared
|
|
6
|
+
// flow.debtQueue - and a checker that guessed would attest a deferral against the wrong file. The
|
|
7
|
+
// procedures advisor renders this command already populated, and NAMES which register it chose.
|
|
8
|
+
//
|
|
9
|
+
// Read-only: reads the two files it is pointed at, writes nothing, records nothing (advisory).
|
|
10
|
+
// Dependency-free, Node >= 22.
|
|
11
|
+
//
|
|
12
|
+
// Exit codes: 0 ACCEPT; 1 a matrix REFUSE; 2 usage - a missing/unknown flag, an unreadable path, or
|
|
13
|
+
// a refusal about the arguments themselves (an unknown or absent --class, an absent --claim).
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
17
|
+
import { CLASSES, ROW_FIELDS, decideFoldScope } from './fold-scope.mjs';
|
|
18
|
+
|
|
19
|
+
const HELP = `fold-scope — declare a finding's SCOPE before the edit (agent-workflow family).
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
node fold-scope-cli.mjs --class <${CLASSES.join('|')}> --claim "<the invariant>" \\
|
|
23
|
+
--plan <plan-file> --queue <queue-file>
|
|
24
|
+
|
|
25
|
+
Every finding NAMES the invariant its fix enforces, BEFORE the edit. Where that invariant already
|
|
26
|
+
lives decides the arm, and this checker refuses a claim whose reference does not resolve:
|
|
27
|
+
|
|
28
|
+
in-scope the claim matches WITHIN ONE \`- \` bullet under the plan's ## Verification
|
|
29
|
+
(those bullets ARE the acceptance criteria) -> fold here.
|
|
30
|
+
new-invariant the claim matches NO acceptance bullet AND exactly one queue row carries all five
|
|
31
|
+
fields (${ROW_FIELDS.join(', ')}), its residual exposure
|
|
32
|
+
declared "not live" -> the narrow fix ships now, only the generalization defers.
|
|
33
|
+
blocking no correct narrow fix exists -> the phase does not close. There is no queue arm.
|
|
34
|
+
|
|
35
|
+
--plan and --queue are required and never defaulted: guessing the register would attest a deferral
|
|
36
|
+
against the wrong file. The check is advisory — nothing records that it ran, so a skipped or late
|
|
37
|
+
call is indistinguishable from a pre-edit declaration.
|
|
38
|
+
|
|
39
|
+
Exit codes: 0 ACCEPT; 1 REFUSE; 2 usage (missing/unknown flag, unreadable path, bad --class/--claim).`;
|
|
40
|
+
|
|
41
|
+
const FLAGS = ['class', 'claim', 'plan', 'queue'];
|
|
42
|
+
|
|
43
|
+
const parseArgs = (argv) => {
|
|
44
|
+
const opts = {};
|
|
45
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
46
|
+
const arg = argv[i];
|
|
47
|
+
const eq = arg.indexOf('=');
|
|
48
|
+
const name = arg.startsWith('--') ? arg.slice(2, eq === -1 ? undefined : eq) : null;
|
|
49
|
+
if (!name || !FLAGS.includes(name)) throw new Error(`unexpected argument "${arg}" (flags: ${FLAGS.map((f) => `--${f}`).join(', ')})`);
|
|
50
|
+
if (eq !== -1) {
|
|
51
|
+
opts[name] = arg.slice(eq + 1);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const value = argv[i + 1];
|
|
55
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`--${name} requires a value`);
|
|
56
|
+
opts[name] = value;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
for (const name of ['plan', 'queue']) {
|
|
60
|
+
if (!opts[name]) throw new Error(`--${name} is required and is never defaulted — name the file this claim is checked against`);
|
|
61
|
+
}
|
|
62
|
+
return opts;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// main(argv, deps) -> { code, stdout, stderr }. Never calls process.exit itself (the direct-run
|
|
66
|
+
// guard does), and never reads anything the caller did not point it at.
|
|
67
|
+
export const main = (argv, deps = {}) => {
|
|
68
|
+
const read = deps.readFileSync ?? readFileSync;
|
|
69
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
70
|
+
let opts;
|
|
71
|
+
try {
|
|
72
|
+
opts = parseArgs(argv);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { code: 2, stdout: '', stderr: `fold-scope: ${err.message}` };
|
|
75
|
+
}
|
|
76
|
+
const text = {};
|
|
77
|
+
for (const name of ['plan', 'queue']) {
|
|
78
|
+
try {
|
|
79
|
+
text[name] = read(opts[name], 'utf8');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { code: 2, stdout: '', stderr: `fold-scope: --${name} "${opts[name]}" is unreadable — ${(err && err.message) || err}` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const decided = decideFoldScope({ cls: opts.class, claim: opts.claim, planText: text.plan, queueText: text.queue });
|
|
85
|
+
return { code: decided.exit, stdout: decided.lines.join('\n'), stderr: '' };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (isDirectRun(import.meta.url)) {
|
|
89
|
+
const result = main(process.argv.slice(2));
|
|
90
|
+
if (result.stdout) console.log(result.stdout);
|
|
91
|
+
if (result.stderr) console.error(result.stderr);
|
|
92
|
+
process.exit(result.code);
|
|
93
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The finding-scope rule as a checker — the half prose cannot do (the engine canon: procedures.md,
|
|
2
|
+
// plan-execution step 5). A finding NAMES the invariant its fix enforces BEFORE the edit; WHERE that
|
|
3
|
+
// invariant already lives decides the disposition, and this module REFUSES a claim whose reference
|
|
4
|
+
// does not resolve:
|
|
5
|
+
//
|
|
6
|
+
// in-scope the claim matches WITHIN ONE acceptance bullet of the plan -> fold here.
|
|
7
|
+
// new-invariant the claim matches NO acceptance bullet AND one OPEN queue row carries the claim in
|
|
8
|
+
// its invariant field and all five fields (invariant, origin as file:line, narrow
|
|
9
|
+
// fix, proof, residual exposure) with that exposure declared NOT live -> the narrow
|
|
10
|
+
// fix ships now, only the generalization defers.
|
|
11
|
+
// blocking no correct narrow fix exists -> the phase does not close; there is no queue arm.
|
|
12
|
+
//
|
|
13
|
+
// Markdown is read through the family's ONE block model (references/scripts/markdown-blocks.mjs, the
|
|
14
|
+
// module the archivers already read through): fenced regions, ATX headings at 0-3 columns of indent,
|
|
15
|
+
// CRLF and the backtick-info-string rule are ITS problem, not a second hand-rolled grammar here. A
|
|
16
|
+
// document it refuses is a loud refusal, never a silent empty read.
|
|
17
|
+
//
|
|
18
|
+
// ADVISORY in this slice: nothing records that it ran, so a skipped or late call is
|
|
19
|
+
// indistinguishable from a pre-edit declaration. A fingerprint-bound receipt a gate reads is next.
|
|
20
|
+
//
|
|
21
|
+
// Pure string functions - every input is text; the CLI half (fold-scope-cli.mjs) owns the fs.
|
|
22
|
+
// Dependency-free, Node >= 22.
|
|
23
|
+
|
|
24
|
+
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
25
|
+
|
|
26
|
+
export const CLASSES = ['in-scope', 'new-invariant', 'blocking'];
|
|
27
|
+
export const ROW_FIELDS = ['invariant', 'origin', 'narrow fix', 'proof', 'residual exposure'];
|
|
28
|
+
export const ACCEPTANCE_HEADING = '## Verification';
|
|
29
|
+
// The canon says a deferral row carries "the origin `file:line`". Anchored at the start of the value
|
|
30
|
+
// and a POSITIVE line number, so "file.mjs:12junk" and "file.mjs:0" are not one; trailing context
|
|
31
|
+
// after the token is fine, because the canon asks the row to CARRY a file:line, not to carry nothing
|
|
32
|
+
// else.
|
|
33
|
+
const ORIGIN_SHAPE = /^\S+:[1-9]\d*(\s|$)/;
|
|
34
|
+
// A closed row is not a live deferral. Narrow on purpose: the row TITLE carrying one of these
|
|
35
|
+
// literals refuses fail-closed; the general per-project status grammar is queued, not guessed here.
|
|
36
|
+
const CLOSED_MARKERS = ['DONE', 'CLOSED'];
|
|
37
|
+
const ORIGIN_MISSING = 'origin (the canon requires a file:line)';
|
|
38
|
+
const BULLET = /^-\s+\S/;
|
|
39
|
+
|
|
40
|
+
const normalize = (s) => String(s ?? '').replace(/\r/g, '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
const contains = (haystack, needle) => normalize(haystack).toLowerCase().includes(needle);
|
|
42
|
+
|
|
43
|
+
// The ONE bullet scan both readers use, over the block model's lines. A fenced region is a quotation
|
|
44
|
+
// AND a boundary: it closes the block it interrupts, so text past a fence can never join the bullet
|
|
45
|
+
// before it (which would let a far-side literal satisfy a near-side claim). A `-` plus any whitespace
|
|
46
|
+
// run opens a block; a blank or indented line continues it; any other unindented line closes it.
|
|
47
|
+
// Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them.
|
|
48
|
+
const bulletBlocks = (lines, fencedLines, from, to) => {
|
|
49
|
+
const blocks = [];
|
|
50
|
+
let current = null;
|
|
51
|
+
const close = () => {
|
|
52
|
+
if (current) blocks.push(current);
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
for (let index = from; index < to; index += 1) {
|
|
56
|
+
if (fencedLines.has(index)) {
|
|
57
|
+
close();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const line = lines[index];
|
|
61
|
+
if (BULLET.test(line)) {
|
|
62
|
+
close();
|
|
63
|
+
current = [line];
|
|
64
|
+
} else if (current && (line.trim() === '' || /^\s+\S/.test(line))) {
|
|
65
|
+
current.push(line);
|
|
66
|
+
} else {
|
|
67
|
+
close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
close();
|
|
71
|
+
return blocks;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// extractAcceptance(planText) -> the top-level bullets under `## Verification`, each collapsed to one
|
|
75
|
+
// line. Per the planning canon those bullets ARE the acceptance criteria and they are the WHOLE list.
|
|
76
|
+
// ONE heading recognizer decides both ends: the section opens on the block model's `## Verification`
|
|
77
|
+
// heading and closes at the next heading of level 1 or 2, so a level-3 subheading stays INSIDE (which
|
|
78
|
+
// is what "in this section" means) and a 4-space line, being an indented code block rather than a
|
|
79
|
+
// heading, neither opens nor closes it. A Verification with no bullets declares NO criteria.
|
|
80
|
+
export const extractAcceptance = (planText) => {
|
|
81
|
+
const { lines, headings, fencedLines } = tokenizeMarkdown(String(planText ?? ''), 'the plan');
|
|
82
|
+
const open = headings.find((heading) => heading.text.trim() === ACCEPTANCE_HEADING);
|
|
83
|
+
if (!open) return [];
|
|
84
|
+
const next = headings.find((heading) => heading.index > open.index && heading.level <= 2);
|
|
85
|
+
return bulletBlocks(lines, fencedLines, open.index + 1, next ? next.index : lines.length)
|
|
86
|
+
.map((block) => normalize(block.join('\n').replace(/^-\s+/, '')))
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ONE question per pattern: this one asks whether the line CARRIES a label, never whether the value
|
|
91
|
+
// behind it is any good. Requiring a non-empty value here made an empty repeat invisible, so a row
|
|
92
|
+
// could repeat a label and still be admitted; the value is judged downstream, where "" is missing.
|
|
93
|
+
const FIELD_LINE = new RegExp(`^\\s*(?:[-*]\\s+)?(${ROW_FIELDS.map((f) => f.replace(' ', '\\s+')).join('|')})\\s*:\\s*(.*)$`, 'i');
|
|
94
|
+
|
|
95
|
+
// The labelled fields of one row, folding CONTINUATION lines into the value they belong to (a wrapped
|
|
96
|
+
// invariant read to its first line only would refuse a legitimate deferral). A REPEATED label is
|
|
97
|
+
// recorded and REFUSED, never resolved by keeping the first value: a row saying "not live" and then
|
|
98
|
+
// "live" would otherwise be accepted as not-live, which is the contradiction this fails closed on.
|
|
99
|
+
const parseFields = (block) => {
|
|
100
|
+
const values = {};
|
|
101
|
+
let open = null;
|
|
102
|
+
for (const line of block.split('\n')) {
|
|
103
|
+
const match = line.match(FIELD_LINE);
|
|
104
|
+
if (match) {
|
|
105
|
+
open = match[1].toLowerCase().replace(/\s+/g, ' ');
|
|
106
|
+
values[open] = [...(values[open] ?? []), match[2].trim()];
|
|
107
|
+
} else if (open && /^\s+\S/.test(line) && !BULLET.test(line.trim())) {
|
|
108
|
+
values[open][values[open].length - 1] += ` ${line.trim()}`;
|
|
109
|
+
} else {
|
|
110
|
+
open = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const fields = {};
|
|
114
|
+
for (const label of ROW_FIELDS) fields[label] = values[label]?.[0] ?? null;
|
|
115
|
+
return { fields, values, duplicates: ROW_FIELDS.filter((label) => (values[label] ?? []).length > 1) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// The disposition a residual exposure declares. Exactly TWO forms declare the negative: "not live"
|
|
119
|
+
// and "not-live", each a standalone token on both sides — so "not--live", "not - live",
|
|
120
|
+
// "not-live-ish" and "maybe-not-live" declare nothing. EVERY not-live span is removed before the
|
|
121
|
+
// positive is looked for, malformed ones included: the `live` inside a botched negative is not a
|
|
122
|
+
// declaration of the positive, and reading it as one would route the author to blocking over a row
|
|
123
|
+
// that only needs re-wording. A standalone bare `live` then decides, and a row carrying both a
|
|
124
|
+
// declared negative and a real positive is a contradiction that fails closed as live.
|
|
125
|
+
const NEGATIVE_SPAN = /(?<![-\w])not[\s-]*live(?![-\w])/g;
|
|
126
|
+
const DECLARED_NEGATIVE = /(?<![-\w])not(?:\s+|-)live(?![-\w])/;
|
|
127
|
+
const LIVE_TOKEN = /(?<![-\w])live(?![-\w])/;
|
|
128
|
+
const exposureOf = (value) => {
|
|
129
|
+
const text = String(value ?? '').toLowerCase();
|
|
130
|
+
const declaredNegative = DECLARED_NEGATIVE.test(text);
|
|
131
|
+
if (LIVE_TOKEN.test(text.replace(NEGATIVE_SPAN, ' '))) return 'live';
|
|
132
|
+
return declaredNegative ? 'not-live' : null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const topLevelRows = (queueText) => {
|
|
136
|
+
const { lines, fencedLines } = tokenizeMarkdown(String(queueText ?? ''), 'the queue');
|
|
137
|
+
return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.join('\n'));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const EMPTY_ROW = () => ({ found: false, matches: 0, fields: {}, missing: [...ROW_FIELDS], duplicates: [], exposure: null, closed: null, claimInInvariant: false });
|
|
141
|
+
|
|
142
|
+
// findDebtRow(queueText, claim) -> the ONE queue row FOR this invariant, its fields and its
|
|
143
|
+
// disposition. Discovery prefers the row whose INVARIANT field carries the claim; the whole-block
|
|
144
|
+
// match is only the fallback, and it exists so a row that carries the claim but no invariant field is
|
|
145
|
+
// still FOUND and its missing field can be named. Zero and several matches are both "not found" with
|
|
146
|
+
// the count: a deferral names ONE row, and guessing which is the unresolved reference this refuses.
|
|
147
|
+
export const findDebtRow = (queueText, claim) => {
|
|
148
|
+
const needle = normalize(claim).toLowerCase();
|
|
149
|
+
if (!needle) return EMPTY_ROW();
|
|
150
|
+
const rows = topLevelRows(queueText).map((block) => ({ block, ...parseFields(block) }));
|
|
151
|
+
// EVERY recorded value of `invariant` is a candidate key, not just the first: a claim sitting in a
|
|
152
|
+
// repeated label would otherwise be invisible here and resolve to some OTHER row.
|
|
153
|
+
const owns = (r) => (r.values.invariant ?? []).some((v) => contains(v, needle));
|
|
154
|
+
const byInvariant = rows.filter(owns);
|
|
155
|
+
const hits = byInvariant.length ? byInvariant : rows.filter((r) => contains(r.block, needle));
|
|
156
|
+
// A candidate that repeats a label cannot be reasoned about at all, so the refusal comes BEFORE the
|
|
157
|
+
// row is resolved — ahead of the ambiguity count and ahead of every field judgement.
|
|
158
|
+
const broken = [...new Set(hits.flatMap((r) => r.duplicates))];
|
|
159
|
+
if (broken.length) return { ...EMPTY_ROW(), matches: hits.length, duplicates: broken };
|
|
160
|
+
if (hits.length !== 1) return { ...EMPTY_ROW(), matches: hits.length };
|
|
161
|
+
const { block, fields, duplicates } = hits[0];
|
|
162
|
+
const missing = ROW_FIELDS.filter((label) => !fields[label]);
|
|
163
|
+
if (fields.origin && !ORIGIN_SHAPE.test(fields.origin)) missing.push(ORIGIN_MISSING);
|
|
164
|
+
return {
|
|
165
|
+
found: true,
|
|
166
|
+
matches: 1,
|
|
167
|
+
block,
|
|
168
|
+
fields,
|
|
169
|
+
missing,
|
|
170
|
+
duplicates,
|
|
171
|
+
exposure: exposureOf(fields['residual exposure']),
|
|
172
|
+
closed: CLOSED_MARKERS.find((marker) => block.split('\n')[0].includes(marker)) ?? null,
|
|
173
|
+
claimInInvariant: owns(hits[0]),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const verdict = (kind, code, exit, claim, lines) => ({
|
|
178
|
+
verdict: kind,
|
|
179
|
+
code,
|
|
180
|
+
exit,
|
|
181
|
+
lines: [`fold-scope: ${kind} ${code}${claim ? ` — "${claim}"` : ''}`, ...lines],
|
|
182
|
+
});
|
|
183
|
+
const accept = (code, claim, lines) => verdict('ACCEPT', code, 0, claim, lines);
|
|
184
|
+
const refuse = (code, claim, lines, exit = 1) => verdict('REFUSE', code, exit, claim, lines);
|
|
185
|
+
|
|
186
|
+
const FIVE_FIELDS = ROW_FIELDS.join(', ');
|
|
187
|
+
|
|
188
|
+
// A document read is the ONE place a throw is expected: the block model REFUSES an unclosed fence and
|
|
189
|
+
// an ambiguous leading `---` rather than guessing, and its message names the file and line.
|
|
190
|
+
const read = (fn) => {
|
|
191
|
+
try {
|
|
192
|
+
return { value: fn() };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { error: (err && err.message) || String(err) };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const unreadable = (claim, message) => refuse('document-unreadable', claim, [
|
|
198
|
+
` ${message}`,
|
|
199
|
+
' a document that cannot be read as markdown is never guessed around — fix it, then re-run.',
|
|
200
|
+
], 2);
|
|
201
|
+
|
|
202
|
+
// decideFoldScope({ cls, claim, planText, queueText }) -> { verdict, code, exit, lines }.
|
|
203
|
+
// Exit 0 for every ACCEPT, 1 for every matrix REFUSE, 2 for a refusal about the ARGUMENTS themselves
|
|
204
|
+
// (an unknown or absent class, an absent claim, an unreadable document) - the CLI prints the lines
|
|
205
|
+
// and returns the code.
|
|
206
|
+
export const decideFoldScope = ({ cls, claim, planText, queueText } = {}) => {
|
|
207
|
+
const claimText = normalize(claim);
|
|
208
|
+
if (!CLASSES.includes(cls)) {
|
|
209
|
+
return refuse('class-unknown', typeof cls === 'string' ? cls : '', [
|
|
210
|
+
` --class must be one of: ${CLASSES.join(', ')} — there is no default arm.`,
|
|
211
|
+
' name the invariant the fix would enforce, then say where it already lives.',
|
|
212
|
+
], 2);
|
|
213
|
+
}
|
|
214
|
+
if (!claimText) {
|
|
215
|
+
return refuse('claim-absent', '', [
|
|
216
|
+
' --claim carries the invariant the fix would enforce, as a literal.',
|
|
217
|
+
' a finding with no named invariant has no scope to decide.',
|
|
218
|
+
], 2);
|
|
219
|
+
}
|
|
220
|
+
if (cls === 'blocking') {
|
|
221
|
+
return accept('blocking', claimText, [
|
|
222
|
+
' no correct narrow fix exists, so the phase does not close.',
|
|
223
|
+
' there is no deferral arm here: fix it in this phase, or the phase stays open.',
|
|
224
|
+
]);
|
|
225
|
+
}
|
|
226
|
+
const criteria = read(() => extractAcceptance(planText));
|
|
227
|
+
if (criteria.error) return unreadable(claimText, criteria.error);
|
|
228
|
+
const acceptance = criteria.value;
|
|
229
|
+
const needle = claimText.toLowerCase();
|
|
230
|
+
const matched = acceptance.find((bullet) => contains(bullet, needle)) ?? null;
|
|
231
|
+
if (cls === 'in-scope') {
|
|
232
|
+
return matched
|
|
233
|
+
? accept('in-scope', claimText, [
|
|
234
|
+
` matched acceptance bullet: ${matched}`,
|
|
235
|
+
' fold here: the invariant is already required by this plan. Fold the finding as a red->green test and re-review.',
|
|
236
|
+
])
|
|
237
|
+
: refuse('in-scope-unmatched', claimText, [
|
|
238
|
+
` no acceptance bullet of the plan carries this literal (${acceptance.length} bullet(s) read).`,
|
|
239
|
+
' the lane: ship the NARROW fix for the found site (red first, then green); the generalization defers only as',
|
|
240
|
+
` --class new-invariant, once a queue row carries ${FIVE_FIELDS}. With no correct narrow fix, --class blocking.`,
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
if (matched) {
|
|
244
|
+
return refuse('new-invariant-already-accepted', claimText, [
|
|
245
|
+
` the invariant IS an acceptance bullet: ${matched}`,
|
|
246
|
+
' route to the fold arm: --class in-scope. Already-required work is never a deferral.',
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
const found = read(() => findDebtRow(queueText, claimText));
|
|
250
|
+
if (found.error) return unreadable(claimText, found.error);
|
|
251
|
+
const row = found.value;
|
|
252
|
+
if (row.duplicates.length) {
|
|
253
|
+
return refuse('new-invariant-row-duplicate-field', claimText, [
|
|
254
|
+
` a candidate queue row declares more than once: ${row.duplicates.join(', ')}.`,
|
|
255
|
+
' a repeated label is not resolved by keeping the first value — a row that says both "not live"',
|
|
256
|
+
' and "live" declares a contradiction, and a claim hidden in a repeat would resolve to another row.',
|
|
257
|
+
' Delete the duplicate so the row states ONE value per field.',
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
if (row.matches > 1) {
|
|
261
|
+
return refuse('new-invariant-row-ambiguous', claimText, [
|
|
262
|
+
` ${row.matches} queue rows carry this literal — a deferral names ONE row.`,
|
|
263
|
+
' narrow the claim to the invariant statement of the row you mean.',
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
if (!row.found) {
|
|
267
|
+
return refuse('new-invariant-row-absent', claimText, [
|
|
268
|
+
' no queue row carries this literal.',
|
|
269
|
+
` a deferral owes a row carrying all five fields: ${FIVE_FIELDS}, that exposure declared NOT live.`,
|
|
270
|
+
' if the exposure IS live, this is not a deferral at all — it is --class blocking.',
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
if (row.closed) {
|
|
274
|
+
return refuse('new-invariant-row-closed', claimText, [
|
|
275
|
+
` the queue row title carries "${row.closed}" — a closed row is not a live deferral.`,
|
|
276
|
+
' re-open that row, or write a new one for the invariant this finding names.',
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
if (row.missing.length) {
|
|
280
|
+
return refuse('new-invariant-row-incomplete', claimText, [
|
|
281
|
+
` the queue row is missing: ${row.missing.join(', ')}.`,
|
|
282
|
+
' a row short of a field is a note, not a deferral — write the field, then re-run.',
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
if (!row.claimInInvariant) {
|
|
286
|
+
return refuse('new-invariant-claim-not-invariant', claimText, [
|
|
287
|
+
` the row carries this literal, but NOT in its invariant field: ${row.fields.invariant}`,
|
|
288
|
+
' a deferral is keyed on the invariant the row states — quote that, or write the row this finding needs.',
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
if (row.exposure === 'live') {
|
|
292
|
+
return refuse('new-invariant-exposure-live', claimText, [
|
|
293
|
+
` the residual exposure is declared LIVE: ${row.fields['residual exposure']}`,
|
|
294
|
+
' a live defect in shipped behaviour is never deferred — route to --class blocking.',
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
if (row.exposure === null) {
|
|
298
|
+
return refuse('new-invariant-exposure-undeclared', claimText, [
|
|
299
|
+
` the residual exposure declares neither "live" nor "not live": ${row.fields['residual exposure']}`,
|
|
300
|
+
' silence is not a declaration — state the disposition in the row.',
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
return accept('new-invariant', claimText, [
|
|
304
|
+
` the queue row carries all five fields and declares its residual exposure NOT live: ${row.fields.origin}`,
|
|
305
|
+
' the narrow fix ships in this phase; ONLY the generalization defers.',
|
|
306
|
+
]);
|
|
307
|
+
};
|
|
@@ -33,7 +33,12 @@ export const stop = (message, fields = {}) =>
|
|
|
33
33
|
// may have unrelated scripts). `/.claude/settings.json` is carried HIDDEN-ONLY: in hidden mode the
|
|
34
34
|
// kit's own attribution file is a footprint; in visible mode the kit commits it and never runs this
|
|
35
35
|
// tool. It passes the same tracked→ASK classifier, so a project that already commits it gets an ASK,
|
|
36
|
-
// never a silent un-track.
|
|
36
|
+
// never a silent un-track. `/.mcp.json` (the `mcp` mode's registration) is the second shared
|
|
37
|
+
// container carried on those terms — the kit merges into it and preserves foreign servers, so the
|
|
38
|
+
// same tracked→ASK arm is what protects a team that commits its own. It is seated here rather than
|
|
39
|
+
// in KNOWN_FOOTPRINT deliberately: KIT_OWN candidacy is unconditional, so the path is in the block
|
|
40
|
+
// BEFORE a later `mcp --apply` writes the file — present-only candidacy would re-open the very
|
|
41
|
+
// follow-up-reconcile gap this entry closes. Never provisioned into a worktree (worktrees.mjs). `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
|
|
37
42
|
// hidden deploy has no tracked `.gitignore`; the classifier drops any candidate a tracked `.gitignore`
|
|
38
43
|
// already covers, so in a repo that DOES track those ignores they are never re-written.
|
|
39
44
|
// The enumeration must cover EVERY file the deploy copies into `scripts/` (bootstrap step 8 copies
|
|
@@ -67,6 +72,7 @@ export const KIT_OWN_PATHS = [
|
|
|
67
72
|
'/docs/plans/',
|
|
68
73
|
'/.claude/settings.local.json',
|
|
69
74
|
'/.claude/settings.json',
|
|
75
|
+
'/.mcp.json',
|
|
70
76
|
];
|
|
71
77
|
|
|
72
78
|
// Every OTHER tool's footprint. `falsePositiveRisk` flags a name generic/ambiguous enough that a
|
|
@@ -183,6 +183,76 @@ export const decideSettingsText = (text, allowRules) => {
|
|
|
183
183
|
};
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
+
// The token-removal COMPLEMENT of mergeSettings (not its exact inverse — merging into an empty file
|
|
187
|
+
// and removing again leaves the managed containers behind), for a settings copy moved away from its `.mcp.json`
|
|
188
|
+
// (a provisioned worktree: it gets no launcher, so a copied enable would be a client error on every
|
|
189
|
+
// client start). Removes ONLY our own tokens — our `SERVER_NAME` membership and our derived allow
|
|
190
|
+
// rules — and preserves every foreign server, foreign rule, foreign key and the file's EOL. Returns
|
|
191
|
+
// `{ text, changed, hasTokens, reason }` (the four outcomes are tabulated at the function); `text`
|
|
192
|
+
// is the input byte-for-byte whenever `changed` is false — this never repairs, and never edits what
|
|
193
|
+
// it did not understand.
|
|
194
|
+
// A JSON round-trip is not content-preserving for everything a file may legally carry, and this
|
|
195
|
+
// function rewrites a file it does NOT own — so each way the trip can lose data is detected and
|
|
196
|
+
// REFUSES the rewrite whole. Two are known and both are checked on the SOURCE TEXT, because after
|
|
197
|
+
// `JSON.parse` the evidence is already gone:
|
|
198
|
+
// • a number past double precision (9007199254740993 → …992) comes back a different value;
|
|
199
|
+
// • a duplicate key ({"a":1,"a":2}) collapses to the last one, dropping foreign data silently.
|
|
200
|
+
// String bodies are blanked to `""` first, so a digit run or a colon inside a string is never read
|
|
201
|
+
// as a number or a key. Deliberately strict on numbers: `1.0` and `1e2` are re-spellings rather
|
|
202
|
+
// than losses and are refused too — fail-closed is the correct direction here.
|
|
203
|
+
const blankStrings = (text) => text.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
204
|
+
const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?/g;
|
|
205
|
+
|
|
206
|
+
const countKeys = (value) => (Array.isArray(value)
|
|
207
|
+
? value.reduce((n, v) => n + countKeys(v), 0)
|
|
208
|
+
: isPlainObject(value)
|
|
209
|
+
? Object.entries(value).reduce((n, [, v]) => n + 1 + countKeys(v), 0)
|
|
210
|
+
: 0);
|
|
211
|
+
|
|
212
|
+
// Why a COUNT: a duplicate at any depth makes the source carry more key tokens than the parsed
|
|
213
|
+
// value has keys. No second JSON parser, and nothing depends on where the duplicate sits.
|
|
214
|
+
const roundTripLoss = (text, parsed) => {
|
|
215
|
+
const blanked = blankStrings(text);
|
|
216
|
+
const numbers = blanked.match(JSON_NUMBER) ?? [];
|
|
217
|
+
if (!numbers.every((lit) => String(JSON.parse(lit)) === lit)) return 'a number would not survive the rewrite';
|
|
218
|
+
const keyTokens = (blanked.match(/""\s*:/g) ?? []).length;
|
|
219
|
+
if (keyTokens !== countKeys(parsed)) return 'a duplicate key would be collapsed by the rewrite';
|
|
220
|
+
return null;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Four outcomes over `{ text, changed, hasTokens, reason }`, and a caller must be able to tell them
|
|
224
|
+
// apart — `changed:false` alone conflates
|
|
225
|
+
// "nothing of ours is here" with "ours is here and the rewrite was REFUSED", and the second one owes
|
|
226
|
+
// the operator a word:
|
|
227
|
+
// unreadable → {changed:false, hasTokens:false, reason:'…'} not JSON of the shape we write
|
|
228
|
+
// nothing of ours → {changed:false, hasTokens:false, reason:null}
|
|
229
|
+
// refused (lossy) → {changed:false, hasTokens:true, reason:'…'} tokens stay, say why
|
|
230
|
+
// rewritten → {changed:true, hasTokens:true, reason:null}
|
|
231
|
+
export const withoutRegistration = (text, allowRules = allowRulesFor()) => {
|
|
232
|
+
const keep = (hasTokens, reason) => ({ text, changed: false, hasTokens, reason });
|
|
233
|
+
const decided = decideSettingsText(text, allowRules);
|
|
234
|
+
if (decided.state !== STATE.PRESENT) return keep(false, 'not readable as settings JSON');
|
|
235
|
+
if (!decided.enabled && decided.allowPresent.length === 0) return keep(false, null);
|
|
236
|
+
const loss = roundTripLoss(text, decided.data);
|
|
237
|
+
if (loss !== null) return keep(true, loss);
|
|
238
|
+
const { [ENABLED_KEY]: enabledList, permissions } = decided.data;
|
|
239
|
+
const keptEnabled = (Array.isArray(enabledList) ? enabledList : []).filter((n) => n !== SERVER_NAME);
|
|
240
|
+
const keptAllow = (isPlainObject(permissions) && Array.isArray(permissions.allow) ? permissions.allow : [])
|
|
241
|
+
.filter((rule) => !allowRules.includes(rule));
|
|
242
|
+
// Only an allow array that ALREADY existed is rewritten — synthesising `allow: []` where the file
|
|
243
|
+
// had no such key would be adding foreign content under the name of removing ours.
|
|
244
|
+
const nextPermissions = isPlainObject(permissions) && Array.isArray(permissions.allow)
|
|
245
|
+
? { ...permissions, allow: keptAllow }
|
|
246
|
+
: permissions;
|
|
247
|
+
// Rebuilt through the original order of the NON-INDEX keys — a settings file the user reads should
|
|
248
|
+
// not be reordered by a removal it did not ask for. Integer-like keys are the stated exception:
|
|
249
|
+
// any JSON round-trip hoists them to the front, because JS object semantics say so.
|
|
250
|
+
const next = Object.fromEntries(Object.entries(decided.data).map(([key, value]) => (
|
|
251
|
+
key === ENABLED_KEY ? [key, keptEnabled] : key === 'permissions' ? [key, nextPermissions] : [key, value]
|
|
252
|
+
)));
|
|
253
|
+
return { text: formatJson(next, decided.eol), changed: true, hasTokens: true, reason: null };
|
|
254
|
+
};
|
|
255
|
+
|
|
186
256
|
// readRegistration(root, io?) → the full registration picture of ONE project. `io.serverPath`
|
|
187
257
|
// overrides the running kit's server path (tests); every fs primitive in `io` is the fs-read-nofollow
|
|
188
258
|
// injection contract. NEVER throws.
|
package/tools/mcp.mjs
CHANGED
|
@@ -185,16 +185,35 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
185
185
|
writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
|
|
186
186
|
}
|
|
187
187
|
if (plan.writeSettings) {
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
// A settings failure AFTER the entry landed leaves a STANDING registration on disk that never
|
|
189
|
+
// reaches formatResult — so the reconcile note has to ride the failure too, or a hidden
|
|
190
|
+
// deployment is left with a visible `.mcp.json` and no mention of it anywhere.
|
|
191
|
+
// The catch is around the WRITE, not inside it: a raw fs error (a failing rename) never passes
|
|
192
|
+
// through the injected stop, and it is exactly the case that strands a standing registration.
|
|
193
|
+
try {
|
|
194
|
+
// The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so
|
|
195
|
+
// it belongs on the apply lane only — a preview that made a directory would not be a preview.
|
|
196
|
+
assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
|
|
197
|
+
writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Reaching here always leaves a STANDING registration on disk — either this run wrote the
|
|
200
|
+
// entry (`writeMcpJson`) or the preflight found it already current (`matches`), and those two
|
|
201
|
+
// are exhaustive, so the note is unconditional rather than guarded by a branch nothing can
|
|
202
|
+
// take. A differing entry never reaches the writer at all; it STOPs in the preflight.
|
|
203
|
+
throw Object.assign(err, { message: `${err.message}\n${HIDDEN_MODE_LINE}` });
|
|
204
|
+
}
|
|
192
205
|
}
|
|
193
206
|
return { ...base, wrote: plan.writeMcpJson || plan.writeSettings };
|
|
194
207
|
};
|
|
195
208
|
|
|
196
209
|
// ── the report ─────────────────────────────────────────────────────────────────────────
|
|
197
210
|
|
|
211
|
+
// A registration is an AI-tool footprint: `/.mcp.json` is in the known-footprint registry, so a
|
|
212
|
+
// HIDDEN deployment needs the reconcile before `git status` is clean again. Stated conditionally —
|
|
213
|
+
// this mode never detects visibility, and detecting it would widen what it reads.
|
|
214
|
+
const HIDDEN_MODE_LINE =
|
|
215
|
+
'hidden-mode note: if this deployment is hidden, run the hide-footprint reconcile so the registration stays out of `git status` (the registry carries /.mcp.json).';
|
|
216
|
+
|
|
198
217
|
const POSTURE_LINE =
|
|
199
218
|
'trust posture: the registered server is a READ-ONLY child of your MCP client (path/type/size/line facts and literal search over this project root) — it runs OUTSIDE the Bash sandbox, as the client itself does, and exposes no write or exec API. The two allow rules make its tool calls promptless; nothing else in this project changes.';
|
|
200
219
|
|
|
@@ -229,6 +248,7 @@ const maskedReport = (result) => {
|
|
|
229
248
|
` merge into ${SETTINGS_REL} (that file was observable — and read where present — so this body already carries what is in it):`,
|
|
230
249
|
indented(result.fragments.settings),
|
|
231
250
|
POSTURE_LINE,
|
|
251
|
+
HIDDEN_MODE_LINE,
|
|
232
252
|
].join(LF);
|
|
233
253
|
};
|
|
234
254
|
|
|
@@ -236,7 +256,7 @@ export const formatResult = (result) => {
|
|
|
236
256
|
if (result.masked) return maskedReport(result);
|
|
237
257
|
const nothingToDo = !result.plan.writeMcpJson && !result.plan.writeSettings;
|
|
238
258
|
if (nothingToDo) {
|
|
239
|
-
return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE].join(LF);
|
|
259
|
+
return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE, HIDDEN_MODE_LINE].join(LF);
|
|
240
260
|
}
|
|
241
261
|
const lines = [
|
|
242
262
|
result.dryRun
|
|
@@ -247,6 +267,7 @@ export const formatResult = (result) => {
|
|
|
247
267
|
' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
|
|
248
268
|
indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
|
|
249
269
|
POSTURE_LINE,
|
|
270
|
+
HIDDEN_MODE_LINE,
|
|
250
271
|
];
|
|
251
272
|
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
|
|
252
273
|
return lines.join(LF);
|
package/tools/procedures.mjs
CHANGED
|
@@ -31,6 +31,9 @@ import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from '.
|
|
|
31
31
|
// The plan-in-flight detector (AD-038) — imported from the plan-files.mjs LEAF (read-only fs by
|
|
32
32
|
// construction); the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported.
|
|
33
33
|
import { plansInFlight, PLANS_REL } from './plan-files.mjs';
|
|
34
|
+
// The family's ONE shell quoter for a RENDERED command operand (bare when the value is already safe,
|
|
35
|
+
// single-quoted otherwise) — the same leaf eight other command renderers here read through.
|
|
36
|
+
import { shellQuoteArg } from './repo-lex.mjs';
|
|
34
37
|
// The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
|
|
35
38
|
// is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
|
|
36
39
|
// (orchestration-write.mjs) DIRECTLY — the import-split test pins the direct-import rule.
|
|
@@ -298,6 +301,24 @@ const autonomyAdvice = (activity, facts) => {
|
|
|
298
301
|
];
|
|
299
302
|
};
|
|
300
303
|
|
|
304
|
+
// The finding-scope block (procedures.md plan-execution step 5) — plan-execution ONLY and
|
|
305
|
+
// UNCONDITIONAL: the rule routes EVERY finding, review-backed or not, so gating it on REVIEW_RECIPES
|
|
306
|
+
// (which gates only the loop economics above) would hide it from every Solo project. The canon
|
|
307
|
+
// section is printed VERBATIM above, so this block never re-states the rule — it carries only what
|
|
308
|
+
// the canon cannot: the POPULATED checker command, and which of the two registers `--queue` names.
|
|
309
|
+
export const FOLD_SCOPE_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'fold-scope-cli.mjs');
|
|
310
|
+
const foldScopeAdvice = (activity, config, plans) => {
|
|
311
|
+
if (activity !== 'plan-execution') return [];
|
|
312
|
+
const declared = config?.flow?.debtQueue ?? null;
|
|
313
|
+
const queue = declared ?? `${PLANS_REL}/queue.md`;
|
|
314
|
+
const plan = plans.length === 1 ? `${PLANS_REL}/${plans[0]}` : '<plan-file>';
|
|
315
|
+
return [
|
|
316
|
+
'Finding scope (procedures.md plan-execution step 5) — the rule is the section above; this is the checker it names:',
|
|
317
|
+
` • node ${shellQuoteArg(FOLD_SCOPE_TOOL)} --class '<in-scope|new-invariant|blocking>' --claim '<the invariant>' --plan ${shellQuoteArg(plan)} --queue ${shellQuoteArg(queue)}`,
|
|
318
|
+
` • --queue is ${declared ? `${declared}, the declared flow.debtQueue` : `${queue}, the planning lifecycle queue (no flow.debtQueue is declared)`}. Advisory: nothing records that it ran, so a skipped or late call is indistinguishable from a pre-edit declaration.`,
|
|
319
|
+
];
|
|
320
|
+
};
|
|
321
|
+
|
|
301
322
|
// The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
|
|
302
323
|
// at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
|
|
303
324
|
// every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
|
|
@@ -445,7 +466,7 @@ const contractLines = ({ cmd, contract, settings }) => {
|
|
|
445
466
|
return lines;
|
|
446
467
|
};
|
|
447
468
|
|
|
448
|
-
const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice }) => {
|
|
469
|
+
const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => {
|
|
449
470
|
const lines = [
|
|
450
471
|
section,
|
|
451
472
|
'',
|
|
@@ -464,6 +485,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
|
|
|
464
485
|
if (grounding.length) lines.push('', ...grounding);
|
|
465
486
|
const advice = reviewLoopAdvice(slots, activity);
|
|
466
487
|
if (advice.length) lines.push('', ...advice);
|
|
488
|
+
if (foldScope.length) lines.push('', ...foldScope);
|
|
467
489
|
lines.push('', ...costLanesAdvice());
|
|
468
490
|
if (declaredPractice.length) lines.push('', ...declaredPractice);
|
|
469
491
|
if (warnings.length) {
|
|
@@ -473,7 +495,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
|
|
|
473
495
|
return lines.join('\n');
|
|
474
496
|
};
|
|
475
497
|
|
|
476
|
-
const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }) => ({
|
|
498
|
+
const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }) => ({
|
|
477
499
|
activity,
|
|
478
500
|
section,
|
|
479
501
|
slots: Object.fromEntries(
|
|
@@ -486,6 +508,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
|
|
|
486
508
|
groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
|
|
487
509
|
// ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
|
|
488
510
|
costLanes: costLanesAdvice(),
|
|
511
|
+
// ADDITIVE (the fold channel): the finding-scope block, structured (empty outside plan-execution).
|
|
512
|
+
foldScope,
|
|
489
513
|
// ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
|
|
490
514
|
autonomy: autonomyAdvice(activity, autonomy),
|
|
491
515
|
// ADDITIVE (D-17 U1): the SAME composed lines the human render prints — one array, two renders, so
|
|
@@ -574,9 +598,10 @@ export const main = (argv, ctx = {}) => {
|
|
|
574
598
|
const flowProbe = ctx.flowProbe ?? defaultFlowProbe;
|
|
575
599
|
const flowHalves = config?.flow == null ? null : flowHalvesAdvice(config.flow, flowProbe(cwd));
|
|
576
600
|
const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
|
|
601
|
+
const foldScope = foldScopeAdvice(activity, config, plans);
|
|
577
602
|
const stdout = json
|
|
578
|
-
? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice }), null, 2)
|
|
579
|
-
: formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice });
|
|
603
|
+
? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope }), null, 2)
|
|
604
|
+
: formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, declaredPractice, foldScope });
|
|
580
605
|
if (autonomy?.error) {
|
|
581
606
|
return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
|
|
582
607
|
}
|
package/tools/worktrees.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'nod
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { MCP_JSON_REL, SERVER_NAME, SETTINGS_REL, withoutRegistration } from './mcp-registration.mjs';
|
|
19
20
|
import {
|
|
20
21
|
KIT_OWN_PATHS, KNOWN_FOOTPRINT, expandGlob, normalizeSlashes, isDirPattern, isGlobPattern,
|
|
21
22
|
patternToProbe,
|
|
@@ -65,6 +66,9 @@ const EXCLUDED_BASENAMES = new Set([
|
|
|
65
66
|
]);
|
|
66
67
|
// Registry entries provision seeds by its own rules instead of copying wholesale.
|
|
67
68
|
const SEEDED_SEPARATELY = new Set([`/${PLANS_REL}/`]);
|
|
69
|
+
// Never copied AND never owned — unlike SEEDED_SEPARATELY, whose members cleanup may still remove.
|
|
70
|
+
// A satellite's own `.mcp.json` must stay FOREIGN or an ordinary cleanup deletes what we never placed.
|
|
71
|
+
const NEVER_PROVISIONED = new Set(['/.mcp.json']);
|
|
68
72
|
// Copied files whose absolute main-root pins are rebased onto the worktree root.
|
|
69
73
|
const REBASE_TARGETS = Object.freeze(['docs/ai/gates.json', '.claude/settings.json', '.claude/settings.local.json']);
|
|
70
74
|
const TRACKED_PIN_DECLARATION =
|
|
@@ -192,6 +196,12 @@ const readFileNoFollow = (fs, abs) => {
|
|
|
192
196
|
}
|
|
193
197
|
};
|
|
194
198
|
|
|
199
|
+
// Fatal UTF-8: a lossy decode would let invalid bytes become U+FFFD and pass for text we may rewrite.
|
|
200
|
+
// `ignoreBOM: true` KEEPS a leading BOM in the string — the default STRIPS it, which would make a
|
|
201
|
+
// BOM-carrying file and a BOM-less one decode identically and defeat a byte-faithful comparison.
|
|
202
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
203
|
+
const decodeUtf8Strict = (buf) => { try { return UTF8_STRICT.decode(buf); } catch { return null; } };
|
|
204
|
+
|
|
195
205
|
const NOFOLLOW_WRITE = fsC.O_WRONLY | fsC.O_CREAT | fsC.O_EXCL | (fsC.O_NOFOLLOW ?? 0);
|
|
196
206
|
const COPY_BUFFER_BYTES = 64 * 1024;
|
|
197
207
|
|
|
@@ -650,7 +660,7 @@ export const provisionCopySet = (root, deps = {}) => {
|
|
|
650
660
|
const fs = fsOf(deps);
|
|
651
661
|
const out = [];
|
|
652
662
|
for (const pattern of KIT_OWN_PATHS) {
|
|
653
|
-
if (SEEDED_SEPARATELY.has(pattern)) continue;
|
|
663
|
+
if (SEEDED_SEPARATELY.has(pattern) || NEVER_PROVISIONED.has(pattern)) continue;
|
|
654
664
|
if (isPresent(root, pattern, fs)) out.push(pattern);
|
|
655
665
|
}
|
|
656
666
|
for (const entry of KNOWN_FOOTPRINT) {
|
|
@@ -902,7 +912,7 @@ export const rebaseAbsolutePins = (text, mainRoot, wtRoot) => {
|
|
|
902
912
|
const assertProvisionSourcesContained = ({ root, rootReal, fs, statFollow }) => {
|
|
903
913
|
const rels = [];
|
|
904
914
|
for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((e) => e.pattern)]) {
|
|
905
|
-
if (SEEDED_SEPARATELY.has(pattern)) continue;
|
|
915
|
+
if (SEEDED_SEPARATELY.has(pattern) || NEVER_PROVISIONED.has(pattern)) continue;
|
|
906
916
|
if (isGlobPattern(pattern)) {
|
|
907
917
|
rels.push(...expandGlob(pattern, { dir: root, readdir: fs.readdir, stat: statFollow }).map((p) => patternToProbe(p)));
|
|
908
918
|
continue;
|
|
@@ -2057,7 +2067,13 @@ const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) =>
|
|
|
2057
2067
|
}
|
|
2058
2068
|
const tracked = git(['ls-files', '--', target], wtRoot);
|
|
2059
2069
|
if (tracked.status !== 0) throw stop(`git ls-files failed for ${target}: ${(tracked.stderr || tracked.stdout).trim()}`);
|
|
2060
|
-
|
|
2070
|
+
// Fatal decode BEFORE any rewrite: `String(buffer)` turns invalid bytes into U+FFFD, so a
|
|
2071
|
+
// rebase would silently destroy them in a file this lane only means to re-point.
|
|
2072
|
+
const text = decodeUtf8Strict(cur.bytes);
|
|
2073
|
+
if (text === null) {
|
|
2074
|
+
report.push(` ${target}: not valid UTF-8 — left untouched, pins not rebased`);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2061
2077
|
const { text: rebased, changes } = rebaseAbsolutePins(text, root, wtRoot);
|
|
2062
2078
|
if (tracked.stdout.trim() !== '') {
|
|
2063
2079
|
if (changes.length > 0) report.push(` ${target}: ${TRACKED_PIN_DECLARATION}`);
|
|
@@ -2065,7 +2081,7 @@ const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) =>
|
|
|
2065
2081
|
}
|
|
2066
2082
|
if (changes.length === 0) continue;
|
|
2067
2083
|
const main = readFileNoFollow(fs, join(root, target));
|
|
2068
|
-
const mainText = main.bytes ?
|
|
2084
|
+
const mainText = main.bytes ? decodeUtf8Strict(main.bytes) : null;
|
|
2069
2085
|
const rebasedMain = mainText === null ? null : rebaseAbsolutePins(mainText, root, wtRoot).text;
|
|
2070
2086
|
if (mainText !== null && (text === mainText || text === rebasedMain)) {
|
|
2071
2087
|
writeContainedFileAtomic(wtRoot, wtAbs, rebasedMain, fs, { stop: (m) => stop(m) });
|
|
@@ -2163,6 +2179,13 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2163
2179
|
// Overlap refusal (pre-mutation): an include rel that another provision lane also populates
|
|
2164
2180
|
// (the frozen registry footprint, the seeded plan, the handoff) — or another include root —
|
|
2165
2181
|
// would meet the copy-if-missing kept-exit and skip the identity door entirely.
|
|
2182
|
+
// A NEVER_PROVISIONED path is absent from reservedRels precisely because provision skips it, so
|
|
2183
|
+
// the overlap arm below cannot see it — and an --include would copy it AND record it as owned,
|
|
2184
|
+
// which is the never-owned half of the rule defeated through the one door that bypasses the set.
|
|
2185
|
+
const neverProvisioned = [...NEVER_PROVISIONED].find((p) => includeRelsOverlap(rel, patternToProbe(p).replace(/\/$/, '')));
|
|
2186
|
+
if (neverProvisioned !== undefined) {
|
|
2187
|
+
throw stop(`--include names a path a worktree never gets and never owns (${neverProvisioned}): ${inc} — an MCP registration is machine-local and its consent is per checkout; register the satellite itself instead`);
|
|
2188
|
+
}
|
|
2166
2189
|
const reserved = reservedRels.find((r) => includeRelsOverlap(rel, r));
|
|
2167
2190
|
if (reserved !== undefined) {
|
|
2168
2191
|
throw stop(`--include overlaps a path provision itself populates (${reserved}): ${inc} — the footprint, the seeded plan, and the handoff are copied by provision; drop the --include`);
|
|
@@ -2239,6 +2262,69 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2239
2262
|
}
|
|
2240
2263
|
};
|
|
2241
2264
|
|
|
2265
|
+
// The worktree gets no `.mcp.json`, so a settings enable carried over would name a server it does
|
|
2266
|
+
// not declare. Eligibility is decided LIVE by the same two questions rebasePins asks — is the file
|
|
2267
|
+
// tracked, and is it still MAIN's bytes (before or after the pin rebase)? — never by a run-local
|
|
2268
|
+
// copy log, which a --resume turns into `kept` and silently skips. Anything else is user work: the
|
|
2269
|
+
// registration TOKENS stay — the bytes may still move, since rebasePins rewrites absolute pins on
|
|
2270
|
+
// its own lane — and the tokens are REPORTED rather than quietly left. Scope note: this is the
|
|
2271
|
+
// untracked provision lane; a TRACKED `.mcp.json` arrives with `git worktree add`, not with us.
|
|
2272
|
+
//
|
|
2273
|
+
// SCOPE, stated: this speaks only about a settings file that is readable, well-formed and actually
|
|
2274
|
+
// carries tokens of ours. An absent, unreadable or non-JSON one is not this lane's business and
|
|
2275
|
+
// returns silently — `rebasePins` above already reports the unreadable case in its own words.
|
|
2276
|
+
const dropMcpRegistration = ({ root, wtRoot, git, fs, report }) => {
|
|
2277
|
+
const wtAbs = join(wtRoot, SETTINGS_REL);
|
|
2278
|
+
const cur = readFileNoFollow(fs, wtAbs);
|
|
2279
|
+
if (!cur.bytes) return;
|
|
2280
|
+
// STRICT decode, and the MAIN comparison below is on BYTES: `String(buffer)` replaces invalid
|
|
2281
|
+
// UTF-8 with U+FFFD, so two DIFFERENT byte sequences can decode to the same string — which would
|
|
2282
|
+
// let a file that is not MAIN's satisfy the "matches MAIN" proof and be rewritten.
|
|
2283
|
+
const text = decodeUtf8Strict(cur.bytes);
|
|
2284
|
+
if (text === null) {
|
|
2285
|
+
report.push(` ${SETTINGS_REL}: not valid UTF-8 — left untouched, and no registration claim is made about it`);
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const removal = withoutRegistration(text);
|
|
2289
|
+
if (!removal.hasTokens) return; // nothing of ours in it, or not readable as ours
|
|
2290
|
+
if (removal.reason !== null) {
|
|
2291
|
+
// Tokens ARE here and the rewrite was refused to protect foreign data — never a silent skip.
|
|
2292
|
+
report.push(` ${SETTINGS_REL}: carries registration tokens for "${SERVER_NAME}" but ${removal.reason}; left untouched`);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
// ONE conjunction of PROVEN facts admits the write; every other state — including every state a
|
|
2296
|
+
// read cannot settle — takes the single report branch below. Stated as a positive proof rather
|
|
2297
|
+
// than a list of exclusions on purpose: three review rounds each found one more launcher/copy
|
|
2298
|
+
// sub-state an exclusion list had not enumerated, and an unenumerated state failed OPEN.
|
|
2299
|
+
const tracked = git(['ls-files', '--', SETTINGS_REL], wtRoot);
|
|
2300
|
+
if (tracked.status !== 0) throw stop(`git ls-files failed for ${SETTINGS_REL}: ${(tracked.stderr || tracked.stdout).trim()}`);
|
|
2301
|
+
const main = readFileNoFollow(fs, join(root, SETTINGS_REL));
|
|
2302
|
+
const mainText = main.bytes ? decodeUtf8Strict(main.bytes) : null;
|
|
2303
|
+
// Byte equality, and it is NOT interchangeable with string equality: a decoder that strips the BOM
|
|
2304
|
+
// maps a BOM-carrying file and a BOM-less one to the same string. Only the DIRECT branch is
|
|
2305
|
+
// independent of that policy — the rebased branch rebuilds the expected bytes FROM `mainText`, so
|
|
2306
|
+
// it holds only because `ignoreBOM: true` kept the BOM in the decode.
|
|
2307
|
+
const isMainCopy = mainText !== null
|
|
2308
|
+
&& (cur.bytes.equals(main.bytes)
|
|
2309
|
+
|| cur.bytes.equals(Buffer.from(rebaseAbsolutePins(mainText, root, wtRoot).text, 'utf8')));
|
|
2310
|
+
// ABSENT is the only launcher state that proves the tokens are orphaned. Unreadable, symlinked,
|
|
2311
|
+
// device-masked and present-with-an-entry all mean "a declaration may be live" — never strip.
|
|
2312
|
+
const launcherAbsent = readFileNoFollow(fs, join(wtRoot, MCP_JSON_REL)).absent === true;
|
|
2313
|
+
// The admission is ONE positive conjunction; the chain below only NAMES which proof was missing.
|
|
2314
|
+
const canStrip = tracked.stdout.trim() === '' && isMainCopy && launcherAbsent;
|
|
2315
|
+
const why = canStrip ? null
|
|
2316
|
+
: tracked.stdout.trim() !== '' ? 'tracked'
|
|
2317
|
+
// NOT "user-modified": the same arm covers a MAIN settings file that is absent or unreadable.
|
|
2318
|
+
: !isMainCopy ? 'not proven to match MAIN'
|
|
2319
|
+
: `a ${MCP_JSON_REL} is present or unreadable`;
|
|
2320
|
+
if (!canStrip) {
|
|
2321
|
+
report.push(` ${SETTINGS_REL}: ${why} — carries registration tokens for "${SERVER_NAME}"; left untouched, remove them by hand if this worktree declares no server`);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
writeContainedFileAtomic(wtRoot, wtAbs, removal.text, fs, { stop: (m) => stop(m) });
|
|
2325
|
+
report.push(` ${SETTINGS_REL}: dropped our registration tokens (this worktree declares no ${MCP_JSON_REL})`);
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2242
2328
|
const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
|
|
2243
2329
|
// THIS run's proof set: every lane journals the leaf it placed or kept, and nothing else is ever
|
|
2244
2330
|
// examined by the resume verify — the session's own work is out of scope by construction.
|
|
@@ -2261,6 +2347,9 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2261
2347
|
const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report, journal });
|
|
2262
2348
|
|
|
2263
2349
|
rebasePins({ root, wtRoot: targetPath, git, fs, report, journal });
|
|
2350
|
+
// AFTER the rebase: stripping first makes the copy differ from MAIN, and rebasePins would then
|
|
2351
|
+
// read it as user work and skip the pins it exists to fix.
|
|
2352
|
+
dropMcpRegistration({ root, wtRoot: targetPath, git, fs, report });
|
|
2264
2353
|
|
|
2265
2354
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
2266
2355
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
@@ -2877,6 +2966,7 @@ const headEntry = (git, cwd, head, path) => {
|
|
|
2877
2966
|
const registryRoots = () => {
|
|
2878
2967
|
const roots = [];
|
|
2879
2968
|
for (const pattern of [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((entry) => entry.pattern)]) {
|
|
2969
|
+
if (NEVER_PROVISIONED.has(pattern)) continue; // on the RAW pattern — normalizing first never matches
|
|
2880
2970
|
const normalized = normalizeSlashes(pattern).replace(/^\//, '').replace(/\/$/, '');
|
|
2881
2971
|
if (normalized) roots.push(normalized);
|
|
2882
2972
|
}
|
|
@@ -2891,10 +2981,20 @@ const safeRecordedPath = (path) => {
|
|
|
2891
2981
|
return normalized;
|
|
2892
2982
|
};
|
|
2893
2983
|
|
|
2984
|
+
// Ownership has TWO sources — the registry and the record's own includes — and BOTH must honour
|
|
2985
|
+
// NEVER_PROVISIONED. A record written before the --include refusal existed still names the path, and
|
|
2986
|
+
// without this filter that legacy record hands cleanup a registration it may delete. The comparison
|
|
2987
|
+
// is includeRelsOverlap, the SAME predicate the refusal uses. Stated honestly: on POSIX that is
|
|
2988
|
+
// indistinguishable from an exact match here (a recorded DESCENDANT covers nothing at this path, and
|
|
2989
|
+
// case is significant); what it buys is the backslash-platform case fold, already pinned on the
|
|
2990
|
+
// predicate itself (worktrees-include-identity.test.mjs) rather than claimed by a test run here.
|
|
2991
|
+
const NEVER_PROVISIONED_RELS = [...NEVER_PROVISIONED].map((p) => patternToProbe(p).replace(/\/$/, ''));
|
|
2992
|
+
const isNeverProvisionedRel = (rel) => NEVER_PROVISIONED_RELS.some((p) => includeRelsOverlap(rel, p));
|
|
2993
|
+
|
|
2894
2994
|
const provisionKnownRoots = (identity) => {
|
|
2895
2995
|
const roots = [
|
|
2896
2996
|
...registryRoots(),
|
|
2897
|
-
...identity.record.includes.map(safeRecordedPath),
|
|
2997
|
+
...identity.record.includes.map(safeRecordedPath).filter((rel) => !isNeverProvisionedRel(rel)),
|
|
2898
2998
|
PLANS_REL,
|
|
2899
2999
|
];
|
|
2900
3000
|
if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');
|