@sabaiway/agent-workflow-kit 10.2.0 → 10.3.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 +42 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/status.md +1 -1
- package/references/modes/upgrade.md +6 -4
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/agent_rules.md +3 -2
- package/tools/ack-store.mjs +57 -0
- package/tools/ack-write.mjs +1 -1
- package/tools/doc-parity.mjs +8 -0
- package/tools/ensure-ops.mjs +18 -9
- package/tools/ensure-specs.mjs +3 -4
- package/tools/ensure-vocabulary.mjs +5 -2
- package/tools/family-registry.mjs +32 -3
- package/tools/lens-region.mjs +4 -1
- package/tools/node-evidence.mjs +77 -0
- package/tools/recommendations.mjs +68 -67
- package/tools/renderers.mjs +9 -0
- package/tools/spec-adoption.mjs +71 -0
- package/tools/spec-check.mjs +2 -2
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/view-model.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ 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
|
+
## 10.3.0 — a deployed project is never in an unnamed spec-adoption state, and a skip names a fact the tree can check (AD-123)
|
|
8
|
+
|
|
9
|
+
A consumer's `upgrade` printed `specs: skipped-no-node — no package.json at the project root` beside
|
|
10
|
+
its own deployed `scripts/check-docs-size.mjs`; the same project's advisor said `no recommendations —
|
|
11
|
+
flow optimal.` over a tree with no `docs/ai/specs/` at all. Both lines were false about the tree they
|
|
12
|
+
described, and nothing in the kit could say so.
|
|
13
|
+
|
|
14
|
+
**Node is PROVEN, never proxied.** `tools/node-evidence.mjs` (contract `docs/ai/specs/kit/node-evidence.md`)
|
|
15
|
+
answers `package-json` · `deployed-node-scripts` (any kit-seeded `scripts/*.mjs`, the list pinned
|
|
16
|
+
against the bundle) · `none` · `unreadable`. The enforcement-script and spec-layer ensures seed on either
|
|
17
|
+
evidence, skip ONLY on `none` with the new token `skipped-no-node-evidence` — a line naming every probe
|
|
18
|
+
and every wrong-kind path — and fail closed on `unreadable` with the new cause
|
|
19
|
+
`node-evidence-unverifiable`. `skipped-no-node` is retired: a skip whose reason the tool could itself
|
|
20
|
+
disprove may not exist. `upgrade.md` and `deploy-tail.md` add the agent-facing half — a skip line that
|
|
21
|
+
contradicts the observed tree is raised as a finding in the report, never pasted as neutral.
|
|
22
|
+
|
|
23
|
+
**The adoption state is a surface.** `tools/spec-adoption.mjs` (contract `kit/spec-adoption`) reads the
|
|
24
|
+
store through `spec-check`'s now-exported census and read (`kit/spec-check` rev 2) and answers
|
|
25
|
+
`not-adopted` · `adopting` · `adopted` · `unreadable`. The advisor's 18th probe renders `spec-adoption`:
|
|
26
|
+
the seed on an absent store (apply: the spec-layer ensure one-liner; recipe line: the decline
|
|
27
|
+
preview as a named hand-apply alternative), the decline while a store holds no live contract — both
|
|
28
|
+
OPTIONAL, the frozen registry keeping attention for a broken configured declaration — and a stated
|
|
29
|
+
skip over a store it cannot read;
|
|
30
|
+
so `flow optimal` cannot render over a store nobody observed. `status` prints one `specs` line in every
|
|
31
|
+
state, with ` — declined` once the `spec-adoption` ack lane (`specAdoptionAck`) records the choice, and
|
|
32
|
+
its own `(decline ack unreadable: …)` when that read fails.
|
|
33
|
+
|
|
34
|
+
**The ack store has one reader.** `tools/ack-store.mjs` (contract `kit/ack-store`) now owns the store
|
|
35
|
+
path, the keys, the closed lane registry, `factFingerprint` and `readAckValue`; the advisor re-exports
|
|
36
|
+
them and `recipeFingerprint` derives through the same leaf. The leaf read moved from a path-based
|
|
37
|
+
`readFile` after `lstat` to the descriptor-bound no-follow door.
|
|
38
|
+
|
|
39
|
+
Docs: `status.md` documents the line and doc-parity binds the four state tokens into it; the
|
|
40
|
+
`recommendations.md` posture note names both arms and the lane; both `agent_rules.md` templates carry
|
|
41
|
+
the re-rendered Spec-first lens, the zero-state wording in §1.2 and the contradicted-skip bullet in
|
|
42
|
+
§2.5 (`COMMS_PRIORS` gains the outgoing state-block canon so a deployed §2.5 refreshes instead of
|
|
43
|
+
reading as a custom edit). Tarball pin 259 → 262.
|
|
44
|
+
|
|
45
|
+
Not shipped, with reason: the brief's mechanical refusal of a bare "zero governing specs" once the
|
|
46
|
+
store exists — no plan-shape checker exists and the citation has no literal grammar; queued with its
|
|
47
|
+
entry condition.
|
|
48
|
+
|
|
7
49
|
## 10.2.0 — a review can no longer pass a change it called broken (AD-122)
|
|
8
50
|
|
|
9
51
|
Ships antigravity-cli-bridge **5.4.0** in the bundled bridge tree. A consumer recorded a clean
|
package/README.md
CHANGED
|
@@ -231,7 +231,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
231
231
|
| `/agent-workflow-kit backends` | any time | **read-only** check of the optional execution-backends (the `codex` / `agy` bridges): what's set up vs missing and the next step. Never writes, never commits, never runs a subscription CLI (credentials = marker-file presence, not a live login). |
|
|
232
232
|
| `/agent-workflow-kit gates` | any time | **project gate runner** — runs the verification commands **your project itself declares** in `docs/ai/gates.json` (seeded at deploy; hand-editable `{ id, title, cmd }` entries plus the optional boolean `lcovProducer`, each `cmd` one bash line) as one batch: a per-gate **PASS/FAIL table** + one machine-readable summary line, exit 0 iff all green; a failing gate's own output is shown verbatim; `--only <id>` re-runs one. Honest distinct outcomes for a missing / empty / malformed declaration — never a silent green. The runner writes nothing **by default** and never commits — opt-in `--final` runs the FULL declared matrix and mints ONE final-run receipt into the git-dir core-evidence store (status green/red, pre/post tree fingerprints, the declaration, the consumed lcov's sha; the receipt the commit-guard binds); it executes only your own declared commands (a batching convenience, not a sandbox). The velocity tier auto-approves only the exact no-`--final` form — the recording run stays explicit. |
|
|
233
233
|
| `/agent-workflow-kit setup [backend]` | opt-in, any time | **link-only** auto-setup of a bridge: places the bundled bridge skill (only into an absent / empty / managed dir — never overwrites an unmanaged one) + links its wrappers onto `PATH` via managed symlinks (idempotent; refuses to clobber a non-symlink; try `--dry-run` to preview). The binary install + the one-time subscription login stay **manual**: it prints the exact **login** command and points the binary install at each bridge's `setup/README.md`. POSIX wrappers — on Windows use WSL. Never commits, never runs a subscription CLI. |
|
|
234
|
-
| `/agent-workflow-kit status` | any time | **read-only** single view of **versions + deployment + settings + bridges**: which members (kit / memory / engine / the two bridges) are installed and at what version (with an honest "installed on this machine" note when one is behind) and — in a project — what's deployed (`docs/ai`, the version stamps, and the **visibility**: visible / hidden / unclear), plus your settings (orchestration recipes, attribution, velocity) and the bridges' readiness. The two version axes (package number vs deployment-structure head) stay decoupled. Never writes, never commits, never runs a subscription CLI. |
|
|
234
|
+
| `/agent-workflow-kit status` | any time | **read-only** single view of **versions + deployment + settings + bridges**: which members (kit / memory / engine / the two bridges) are installed and at what version (with an honest "installed on this machine" note when one is behind) and — in a project — what's deployed (`docs/ai`, the version stamps, and the **visibility**: visible / hidden / unclear), and the **feature-spec adoption state** (`not adopted` / `adopting (N draft)` / `adopted (N live, M draft)` / `could not be read`, plus `declined` when recorded), plus your settings (orchestration recipes, attribution, velocity) and the bridges' readiness. The two version axes (package number vs deployment-structure head) stay decoupled. Never writes, never commits, never runs a subscription CLI. |
|
|
235
235
|
| `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents four named recipes for composing the bridges into plan → execute → review — **Solo / Reviewed / Council / Delegated** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready), and offers the choice. The orchestrator runs it via the bridge skills and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
|
|
236
236
|
| `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + backend readiness (default Reviewed when a backend is ready, Council on request, slot-aware incl. Delegated) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same). `--override <slot>=<recipe>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
|
|
237
237
|
| `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<recipe>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend, never commits**; hand-editing stays fully supported. |
|
|
@@ -242,7 +242,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
242
242
|
| `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
243
243
|
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — **certifies coverage ONLY inside the `--final` run that owns the lcov** (ownership is exclusive by CONVENTION over the fixed path, not enforced — a concurrent writer to it is a stated residual, queued as LCOV-EXCLUSIVE-OWNERSHIP): an artifact on disk proves nothing about the tree it came from, so a standalone run prints its findings and states `attested=no` / `NO VERDICT` rather than a PASS (an lcov that predates an edit would otherwise certify a line the suite never executed). `attested=` claims a verdict was ISSUED, pass or fail — a run over uncovered lines still reads `attested=yes` and still exits 1, and a run whose coverage arm never executed (no lcov) reads `attested=no` even inside `--final`, because nothing was read and nothing is certified. The runner passes a nonce whose one-way commitment over `{nonce, fingerprint, base}` is the `final-start.attempt` it recorded; a context describing another tree, or matching no recorded attempt, is a REFUSAL, never a verdict. Findings are unchanged — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
244
244
|
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
245
|
-
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice,
|
|
245
|
+
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe, a feature-spec layer never adopted) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice, the agents hidden-mode reconcile follow-up, or the spec-adoption decline preview) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
246
246
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
247
247
|
| `/agent-workflow-kit dispatch` | any time | **the delegation engine** — makes "how much does delegating a sub-task actually buy?" a measured number instead of a feeling. `check <file>` validates a sub-task brief's contract block and exits 0/1 naming the first violated field — **form only**: whether the task is genuinely bounded, its design decided and its acceptance adequate stays your judgment, and a well-formed absurdity passes here by construction. `advise --step-class <c>` answers the question that comes BEFORE that one — which vehicle should carry this kind of sub-task, whether it is even present on this machine, and what the ledger has already recorded for threads of that class (finished, failed, closed without a fold, and still open — counted separately) — and it decides nothing: it refuses no dispatch, gates no verb, and a choice that diverges from the advice is noted rather than blocked. It reads the filesystem only: nothing is launched to find out what is installed, an absent ledger prints "no recorded history", an unreadable one prints the store's own words, and either way the advice still prints. The same block appears as a footer under a form-valid `check` — never under a refusal, so it can never hide one. `register` pre-registers an acceptance wave (step classes, pairing key, minimum observations per class, the mean and first-pass thresholds) so the bar can never be chosen after the results it judges — immutable per wave. `observe` records ONE hand-written observation (`solo-construction`, the baseline whose ratio is 1 by construction — except over a scope measuring zero bytes, which has no ratio at all and is recorded ineligible by name — or `self-reported`) — delegated numbers are never hand-written, they are derived. The writer verbs are where that derivation happens: `open` puts a delegated thread on the record with every mint-time field copied from the brief's own header, refusing a deadline the wrapper's cap plus its kill grace would not fit inside, and recording whether the tree it started from was clean (a dirty one makes the result honestly unmeasurable rather than quietly counted); `await` waits for that one dispatch to answer and writes nothing — only the finished receipt satisfies it, a run still holding the nonce means keep waiting, and a wait that runs out says so with its own exit code, names whether the deadline or your own timeout ended it, and authorizes nothing: a wait that ended without an answer is a question for you, never permission to dispatch again; `return` absorbs the receipt the wrapper minted — only a finished one, checked against the contract it actually ran, its deadline and its own report, and refused outright when the tree hides changes from git or moves while the return is being computed — and derives the bytes from git rather than from anyone's claim; `fold` is the acceptance, and it refuses if the tree moved since the return — precisely, it re-confirms **equality of the visible canonical payload**, which is a change detector rather than a cryptographic identity of the tree: that payload is unframed and carries no file mode, so a content or symlink target that imitates the marker opening the next entry can alias two trees, and an executable-bit flip moves nothing (both named as residuals in the mode doc, with the fix queued). Where the payload cannot follow an object's bytes at all — a binary, a non-regular path, a submodule, a symlink whose target is not valid UTF-8 — `return` and `fold` **refuse** rather than promise what they cannot check. `degrade` closes a thread that never earned a fold, on the record and with its reason. `aggregate` prints one wave: the registered thresholds, every observation as context, and per step class the finished threads — a folded success with git-provable bytes contributes its ratio, one whose bytes are unprovable is excluded from the mean but still counts in the first-pass rate, and a failed thread, a degrade-closed one, or one folded after failing acceptance each count as a real zero. Below the registered minimum nothing is computed (insufficient); at or above it is computed and labeled PILOT evidence. It REFUSES rather than guessing: no pre-registration, an unfinished thread in scope, a recorded refusal-to-delegate that never opened a thread, or an ambiguous wave each stop the computation by name. `handoff-return --slug <s>` closes the worktree loop after `land --prepare`: it re-attests that main still holds exactly the prepared tree under the same HEAD (both recorded in the handoff at prepare time), prints the satellite handoff's user-owned content byte for byte with its boundaries, byte lengths and the main-owned destinations each part folds into, prints the handoff digest and both OIDs as its proof, states the after-the-fold order (a fold landed after the gates leaves them stale), and records ONE self-reported worktree-stream observation only when the whole prepared change set is measurable — a deletion, a rename, a symlink, a submodule or a mode-only change ends with a named NOT RECORDED instead, never a partial number. Writes only its own append-only ledger inside the git dir (never committable); never commits, never runs a subscription CLI. |
|
|
248
248
|
| `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first — printed only for a link whose RAW TARGET is MAIN's `node_modules`, since a foreign or unreadable link is reported as such with no removal advised); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `prompt <slug>` is read-only too and re-prints the satellite's cold-start prompt — the same text `provision` ends its report with: where the worktree is, its ONE seeded plan, the handoff as the one channel back, MAIN's orientation (shared series index, landing) and, under its own heading because it is probed on the satellite, that checkout's install posture — every value derived LIVE rather than replayed from the frozen provision record, a recorded value that no longer matches NAMED as a divergence (with the cause its source makes likely) instead of printed as the runnable one, every offered command marked with WHO runs it (`MAIN $ …` for the landing, `HERE $ …` for this checkout's own install, a posture with nothing to run staying prose), and a control character in any rendered value a typed STOP rather than a forged prompt line; `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '10.
|
|
6
|
+
version: '10.3.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": "10.
|
|
3
|
+
"version": "10.3.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -33,6 +33,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
|
|
|
33
33
|
- `gate-hook` — the base arm is the ordinary opt-in wiring offer. The **`marker-stale`** arm is not: the placed hook validates your declaration through its OWN baked copy and goes dark on any key it does not know, so a declaration carrying the `lcovProducer` key under a hook that predates it silently turns auto-approval OFF, and every gate prompts again with no error anywhere. The condition is the key's PRESENCE, not its value — an older hook rejects a key it does not know whatever that key says, so `"lcovProducer": false` darkens it exactly as `true` does. The arm is deliberately marker-scoped — a stale hook is otherwise harmless — and its recovery is the writer's own: `gate-hook --apply` places only an ABSENT target, so converging means deleting the placed hook and re-placing it. That makes it a destructive **HAND-APPLY** (`rm` + `--apply`, absolute path so it can only delete this project's hook), never something the consent flow runs for you. When the read-lane is also enabled, this arm and `read-lane.stale` would report the same file with the same recovery, so exactly one renders — **this one**, because its cause is the true one: a hook that postdates the read-lane and merely predates the marker key reads `lanes.json` perfectly well, and the read-lane arm's wording would be false over it. Risk profile: deleting one placed hook file, then re-placing it from the bundle; the declaration is never touched.
|
|
34
34
|
- `mcp-channel` — the offer is to REGISTER the kit's read-only stdio MCP server in this project, which is a different kind of consent from the other velocity items: a registration is a command your MCP client will RUN, so the apply here is the mode's own **flagless preview**, which prints the exact entry and writes nothing — the `--apply` that follows is a SEPARATE step you run after reading that entry, and the consent flow never carries it. What the registration buys: path questions and literal searches become typed tool calls whose arguments are named JSON fields instead of a string handed to a shell, so a pipe, a redirect or a quote inside a pattern or a path stays DATA and is never interpreted — they are perfectly legal bytes to search for, there is simply no shell to read them as operators. Posture: the server is a **read-only child of your client** (path/type/size/line facts and literal search over this project root; no write and no exec API), and like the client itself it runs **outside the Bash sandbox** — the sandbox is not what bounds it, the server's own root containment is. The two allow rules it adds make those two tools promptless and nothing else. Two arms are **HAND-APPLY** and never run for you: **`.differing`** — an `agent-workflow` entry already stands in `.mcp.json` and **structurally differs** from what this kit copy would write (another kit copy, a hand-edited path, an added `env`; the comparison ignores key order, so re-serialized identical bytes are the same registration), and silently changing what an MCP server launches is exactly what consent must not slide past, so the remedy is your edit; and the **masked** arm, where an OS sandbox hides `.mcp.json` behind a device node — the kit cannot write there, so it hands you the text to paste from outside the sandbox. When the file is masked but the settings half is already complete, the item **does not render at all**: what is unobservable becomes a stated SKIP, so optimality is withheld rather than a registration you already made being offered again. Stated limit: this item does **not** detect a `disabledMcpjsonServers` veto, so a converged `mcp-channel` means *what the mode writes is in place*, not *the client will load it* — see `${CLAUDE_SKILL_DIR}/references/modes/mcp.md` for why that check was subtracted rather than half-built. Risk profile: a new read-only channel your client will launch; no write or exec exposure, and no existing declaration is touched.
|
|
35
35
|
- ADDITIONAL `gates-inert` arms (the third outcomes) — two further arms, and they differ in whether anything is BROKEN. **`producer-unrecognized`** — a checker with no producer anywhere in the declaration, on a tracked tree the changed-line coverage domain cannot reach (`.ts`/`.tsx`/`.jsx`/`.mts`/`.cts` strictly outnumber `.mjs`/`.cjs`/`.js`): the dead pair is real, so this arm is **HAND-APPLY** and the two remedies are marking the real producer with `"lcovProducer": true` or dropping the checker. Never a `node --test` prescription over a project that has no such suite, never the fill preview, and never an acknowledgement — a dead pair is broken, not narrow, and removing a producer after an acknowledgement lands right back in this arm. **`coverage-domain-narrow`** — the producer/checker pair IS live and the tree is still dominated by what the domain excludes: nothing is broken, and the honest sentence is that certification covers the assessable minority. Its apply is the consent-gated **ack writer** preview (a NEUTRAL fingerprint into `docs/ai/acks.json` as `coverageDomainAck`, never a security key); after the SAME confirmation you run the `--apply` it prints. The fingerprint binds the FACT — the verdict plus the unsupported extensions present, never the file counts — so an acknowledged project stays quiet as it grows and re-fires when a new unsupported language arrives or the verdict flips. The census reads the TRACKED tree with a read-only `git ls-files`; a tree it cannot read (a non-git deployment) becomes a stated skip, so optimality is withheld rather than assumed. Risk profile: no enforcement change of any kind — one acknowledgement recorded in a family-owned file, and one hand edit that stays the maintainer's.
|
|
36
|
+
- `spec-adoption` — the feature-spec layer's ADOPTION STATE, judged from `docs/ai/specs/` alone through the same census `spec-check --all` runs, so a store the checker would refuse to observe is never counted here either. Both arms are OFFERS (`optional`) — the layer is opt-in, and `attention` stays reserved for a configured declaration that is broken. **`not-adopted`** (no store): every plan then cites zero governing specs by default and nothing says adoption never started; its apply is the spec-layer ensure one-liner (`ensure-configs --reconcile --only specs`, the same seed `upgrade` runs — it writes the reader/checker pairs and the store root, create-only, and skips with a stated reason where no Node evidence exists), and its `recipe:` line carries a NAMED **HAND-APPLY alternative** — the DECLINE preview (`ack-write --lane spec-adoption`, a dry-run that prints the exact `--apply`), which records `specAdoptionAck` in the family-owned `docs/ai/acks.json`. **The two are exclusive, and the order is part of the contract:** ask which the maintainer wants BEFORE the confirmation; on "seed" run the apply; on "decline" run NOTHING — hand over the preview line and let them run it and its printed `--apply`; never run the seed after a decline was chosen, and never run the decline preview for them. **`adopting`** (a store with no live contract): an optional item naming the draft count; its apply is that same decline preview (a dry-run; after the SAME confirmation you run the `--apply` it prints), and the honest remedy is authoring a contract from the skill's `SPEC_TEMPLATE.md`. A recorded decline silences BOTH arms and shows on `status` as ` — declined`; one live contract makes the decline moot. An unreadable store is a stated skip, never `flow optimal`. Risk profile: the seed writes only create-only files under `scripts/` and `docs/ai/specs/`; the decline writes one key into the ack store; nothing here authors a spec or blocks a plan.
|
|
36
37
|
- `adr-store-migration` — other items write project files too; what is unique here is that the crossing **overwrites and deletes files the project already has**: it replaces the deployed enforcement scripts in `scripts/` (the directional subset — only basenames the project already has; a locally-edited copy is snapshotted first, never silently clobbered) and, where a retired archive file exists, DELETES it once conservation has been proven. That is why it is **HAND-APPLY** and why the command shown in the apply slot is a **`--dry-run`** — it writes nothing and prints the whole plan. `--apply` is a SEPARATE step, run only after that plan has been shown and **fresh consent** obtained for it; the consent flow executes only the apply slot, so an item that needs consent AFTER its preview cannot use that lane at all. Every write is idempotent and the run is re-runnable to completion after any interruption, so a re-run repairs rather than double-applies. It never commits. Risk profile: overwrite + delete of existing project files, gated on a preview you have actually read.
|
|
37
38
|
|
|
38
39
|
**Sandbox lanes (what to DO with the `sandbox-lane` recipe, per host class):**
|
|
@@ -11,7 +11,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json [--dir <project>]
|
|
|
11
11
|
1. **Versions — a status-only render from `installed[]` + `deploymentHead`** (this is **NOT** the shared notes-based version block — see the separation note below): the **`docs/ai` structure version** (named as such, never "lineage head"), then each member by its `display` showing its `version` (or, when there is no version, the plain phrase for its `state`, mapped above), plus the two-axes disambiguation. **Freshness comes from `installed[].refresh`, not from `notes`:** for each member whose **`refresh.behind`** is `true`, show a **localized "needs refresh"** label and the **verbatim `refresh.recommend`** command **exactly once** (the command/package name stays source-language; **do not also paste the English `notes` caveats** — `refresh.recommend` is the single source of the recovery step, so the command is never duplicated on this surface). A member whose **`refresh.freshness`** is **`unknown`** is surfaced too — a localized *"couldn't be checked"* label; it is **never counted as current and never as behind** (its `notes` caveat carries the detail on the notes-based surfaces; here the label is enough). Lead with a one-line **headline count** derived from `installed[].state` + `refresh.behind` + `refresh.freshness` (e.g. *"5 members installed · 1 needs a refresh · 1 couldn't be checked"* — omit a zero count).
|
|
12
12
|
|
|
13
13
|
> **Status reads `refresh`; the shared version block + the bootstrap/upgrade footers stay `notes`-based (unchanged this release).** `${CLAUDE_SKILL_DIR}/references/modes/status.md` has its OWN status-only render (above), keyed on `installed[].refresh.behind` / `refresh.recommend`. The shared **version block** (under *The version block + welcome mat* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and the bootstrap (step 11) + every upgrade (steps 4 / 8) report footer still consume `installed[].notes` verbatim — that wiring is deliberately **untouched** here (their migration onto `refresh` is deferred). Do not rewrite those footers onto `refresh`.
|
|
14
|
-
2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly. When `project.adrLayout` is **`old`** or **`old-unrotated`**, add a plain-language note that the project still uses an **older ADR layout** and should run the opt-in **`/agent-workflow-kit migrate-adr-store`** to move to the one-file-per-ADR store (preview first; it never commits) — the note is the SAME for both, they differ only in how the older layout was detected (`old` = a retired archive file is still on disk; `old-unrotated` = the project's deployed rotation script predates the store); `migrated` / `none` need no note.
|
|
14
|
+
2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly. When `project.adrLayout` is **`old`** or **`old-unrotated`**, add a plain-language note that the project still uses an **older ADR layout** and should run the opt-in **`/agent-workflow-kit migrate-adr-store`** to move to the one-file-per-ADR store (preview first; it never commits) — the note is the SAME for both, they differ only in how the older layout was detected (`old` = a retired archive file is still on disk; `old-unrotated` = the project's deployed rotation script predates the store); `migrated` / `none` need no note. Then **one `specs` line, always** (from `project.specs` — the feature-spec layer's adoption state, judged from the store alone): `not adopted` (no `docs/ai/specs/` — the layer was never seeded), `adopting (N draft)` (a store with no live contract yet), `adopted (N live, M draft)`, or `could not be read — <reason>` (the store or a document in it could not be observed; never counted as any of the other three). A recorded decline (`project.specs.declined`) appends ` — declined` to the first two. The internal tokens are `not-adopted` / `adopting` / `adopted` / `unreadable`; render the plain phrases above, never the tokens. An envelope without the field says the installed kit predates it — say so rather than inventing a state.
|
|
15
15
|
3. **Settings (`--dir`, one line each)** (from `project.settings`):
|
|
16
16
|
- **recipes** — the effective recipe per slot (detail → `/agent-workflow-kit procedures` / `recipes`); a `recipes.detectError` → say the backends couldn't be checked, so recipes floored at solo.
|
|
17
17
|
- **attribution** — `includeCoAuthoredBy` effective; call out a **local override** only when `local` is non-null **and** differs from `project` (a `null` `local` means the key is absent there, so the project value stands — that is not an override).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
### Mode: upgrade
|
|
2
2
|
|
|
3
3
|
<!-- opt-in-capability: family-freshness -->
|
|
4
|
+
<!-- opt-in-capability: spec-adoption -->
|
|
4
5
|
|
|
5
6
|
Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md · ${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md · ${CLAUDE_SKILL_DIR}/references/shared/command-shapes.md
|
|
6
7
|
|
|
@@ -10,7 +11,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
10
11
|
|
|
11
12
|
1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
|
|
12
13
|
2. `footprint` — `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile --dry-run` → visibility: visible · ambiguous · hidden — consent (conditional): ambiguous → ask which it is BEFORE anything; hidden → the conditional re-run without `--dry-run` (its surfaced paths ask per bootstrap step 9).
|
|
13
|
-
3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
|
|
14
|
+
3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node-evidence` / `old-adr-layout-migration-instructed` / `failed`.
|
|
14
15
|
4. `gates-migration` — `node ${CLAUDE_SKILL_DIR}/references/scripts/migrate-gates.mjs --kit-tools ${CLAUDE_SKILL_DIR}/tools --cwd <project>` → the preview plan · INERT checker · CUSTOMIZED entries, each named — consent: apply only on an explicit yes, re-run with `--apply`.
|
|
15
16
|
5. `bridges` — `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` → per-bridge lines: refreshed · already current · skipped — with its stated reason (not placed / newer than the bundle / unsupported host) · `skipped-readonly` · could not refresh.
|
|
16
17
|
6. `lens` — `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md` → per section: refreshed · already current · custom edit preserved · file absent / engine too old — skipped · over the line cap — refused · section absent — noted · a fully absent/invalid engine → hard STOP.
|
|
@@ -30,7 +31,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
30
31
|
|
|
31
32
|
**`footprint` — hidden-mode footprint reconcile (D9 / AD-014).** A deployment does not record whether it chose `hidden`, so first **infer visibility** — the dry-run writes **zero bytes** and reports one of — **visible** (the entry point is tracked) → nothing to do; **ambiguous** (untracked but not ignored — could be a fresh uncommitted repo, or a hide that broke) → **ASK** the user which it is, never guess; **hidden** → re-run without `--dry-run` to migrate any older **machine-global** hide to the **project-local** `.git/info/exclude` (one managed block; folds in the legacy `.claude/skills/` line), idempotently (a clean re-run is zero-diff). Handle its surfaced paths exactly as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) (already-committed → show `git rm --cached`, ask before `--include`; generic-name present file → ask; **leftover machine-wide ignore block → ASK before `--remove-global`**, default keep + report). No Node on the agent host / Windows → as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
|
|
32
33
|
|
|
33
|
-
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer reader and checker pairs (below) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all six reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact / a seeded store root, inherently outside the docs cap-validator).
|
|
34
|
+
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer reader and checker pairs (below) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node-evidence`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node-evidence`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `node-evidence-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all six reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact / a seeded store root, inherently outside the docs cap-validator).
|
|
34
35
|
|
|
35
36
|
**What the orchestration-config ensure does.** `docs/ai/orchestration.json` must exist **and its onboarding note must be current**: created from the canonical seed if missing; if it already exists, **every activity/slot the user set is preserved** and ONLY the `_README` note is refreshed, and only when the existing one still matches a known prior canonical — the tested `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` is the source of truth for that decision (it normalizes CRLF/whitespace before comparing; a *customized* `_README` is preserved verbatim → `customized-preserved`; a *malformed* existing config is **preserved untouched + LOUD** → `malformed-preserved`, never clobbered and never silently skipped). The current note points at `/agent-workflow-kit set-recipe`. **Kit-owned:** in the **delegated** path memory only seeds/preserves the file (memory upgrade step 2) and this ensure applies the `_README` refresh; in the **fallback** path it does both. (Memory stays standalone.)
|
|
36
37
|
|
|
@@ -38,11 +39,11 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
38
39
|
|
|
39
40
|
**What the autonomy-declaration ensure does.** `docs/ai/autonomy.json` must exist: created from `${CLAUDE_SKILL_DIR}/references/templates/autonomy.json` if missing (the kit's own template twin — a stale memory never silently loses the seed); **an existing file is preserved byte-for-byte** (a declared policy is authored content). The seed is SPARSE (the onboarding note only) and **defaults-equivalent** — deploying it never changes behavior (the computed defaults stay the policy until the user declares levels with `/agent-workflow-kit set-autonomy` or by hand).
|
|
40
41
|
|
|
41
|
-
**What the enforcement-script ensure does.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. The pairs must exist in the project's `scripts/`: `archive-decisions.mjs` + `archive-decisions.test.mjs` and `markdown-blocks.mjs` + `markdown-blocks.test.mjs`, copied from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing; **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration). Nothing else is seeded — the other tokenizer-era tests red beside OLD archivers. **OLD ADR-store layout — DETECTED FIRST, never auto-migrated (AD-051, Decision 13):** on a `docs/ai/history/decisions-archive*.md` monolith (`old`), or no monolith but a deployed rotator predating the store (`old-unrotated`), the project is on the RETIRED 3-tier cascade, so the ensure writes NOTHING and reports `old-adr-layout-migration-instructed` (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Relay it as the LOUD instruct it is: the fix is the opt-in **`/agent-workflow-kit migrate-adr-store`** (consent-gated; previews first, never commits), and the seed lands on the next upgrade. A layout the ensure cannot READ is `failed`, not a seed — it never writes on an unverifiable tree. The seed applies ONLY to a clean layout (neither signal),
|
|
42
|
+
**What the enforcement-script ensure does.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. The pairs must exist in the project's `scripts/`: `archive-decisions.mjs` + `archive-decisions.test.mjs` and `markdown-blocks.mjs` + `markdown-blocks.test.mjs`, copied from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing; **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration). Nothing else is seeded — the other tokenizer-era tests red beside OLD archivers. **OLD ADR-store layout — DETECTED FIRST, never auto-migrated (AD-051, Decision 13):** on a `docs/ai/history/decisions-archive*.md` monolith (`old`), or no monolith but a deployed rotator predating the store (`old-unrotated`), the project is on the RETIRED 3-tier cascade, so the ensure writes NOTHING and reports `old-adr-layout-migration-instructed` (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Relay it as the LOUD instruct it is: the fix is the opt-in **`/agent-workflow-kit migrate-adr-store`** (consent-gated; previews first, never commits), and the seed lands on the next upgrade. A layout the ensure cannot READ is `failed`, not a seed — it never writes on an unverifiable tree. The seed applies ONLY to a clean layout (neither signal). **Whether Node runs here is PROVEN, never proxied:** the ensure seeds on a regular `package.json` at the root OR on any kit-seeded `scripts/*.mjs` already deployed (a bootstrap places them into projects that never carry a `package.json`); only when every probe answers absent does it report `skipped-no-node-evidence`, a line naming every probe it checked, while the three config ensures still run; a probe that cannot be read is `failed` with the cause `node-evidence-unverifiable`, nothing written. **A `skipped-*` outcome whose stated reason this tool could itself disprove may not exist** — the retired `skipped-no-node` was exactly that, printed beside twenty deployed Node scripts. The deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook itself is next refreshed (re-run `node scripts/install-git-hooks.mjs` after the ensure and it will refuse a non-marker hook as always); an OLD hook without the line stays consistent-safe — the decisions gate is simply not enforced yet, never a broken hook.
|
|
42
43
|
|
|
43
44
|
**What the navigator ensure does.** `docs/ai/index.md` is the always-loaded navigator the entry point declares, and it is GENERATED — no template ships it, so a deployment that never ran the generator boots from a broken entry point (and, on a Node project, carries a pre-commit hook that fails its own index check). The ensure runs the bundled generator's finalizer and reports `regenerated` (it was missing or stale — it was written) or `already-current` (nothing written). It never skips a No-Node project: the generator runs from `${CLAUDE_SKILL_DIR}/references/scripts/` on the agent host, not from the project's `scripts/`. **Its position in the run-list is EARLY and therefore NOT authoritative** — `lens` (and, on the migrated path, steps 6–7) still change `docs/ai` afterwards — so the authoritative run is the LATE `--only index` rung documented at both exits; the early one is idempotent and costs at most an `already-current` line.
|
|
44
45
|
|
|
45
|
-
**What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node`
|
|
46
|
+
**What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node-evidence` only when neither a root `package.json` nor any kit-seeded `scripts/*.mjs` is present (the same Node-evidence probe the enforcement-script ensure uses; an unreadable probe is `failed` / `node-evidence-unverifiable`); every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it. **The adoption state this seed leaves behind is reported, never assumed:** the Recommendations section (step 4 / 8) carries the `spec-adoption` item — `not adopted` (the store is absent: the seed offered, the decline a named hand-apply alternative), `adopting` (a store with no live contract: the decline offered), silenced by a recorded decline (`ack-write --lane spec-adoption`), and a store the probe cannot read is a stated skip — and `status` prints the same state on its own line.
|
|
46
47
|
|
|
47
48
|
**`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
|
|
48
49
|
|
|
@@ -88,6 +89,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
88
89
|
flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
|
|
89
90
|
**The LATE navigator finalizer — the AUTHORITATIVE run, after the last `docs/ai` mutation.** `lens` above rewrites `docs/ai/agent_rules.md`, so the step-3 index ensure is already behind by the time the run-list ends. Re-run that ONE op here, before the step-4 report: `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>`. Relay **this** line in the report (it supersedes the early one; an untouched tree reports `already-current`, a failure STOPs the upgrade like any other ensure). On the migrated path the same rung runs again at the END of step 7 — after the migrations, before the step-8 re-stamp.
|
|
90
91
|
4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
|
|
92
|
+
- **A skip line that contradicts the observed tree is a FINDING, never pasted as neutral.** Every ensure skip names the fact it proved (the probes that answered absent, the layout it read); if the tree you can see disproves that reason — a "no Node" skip beside deployed Node scripts, a "not deployed" skip beside a stamped `docs/ai` — do not relay the line as an outcome: raise it in the report as a defect of the tool, with the contradicting fact named, and stop short of any step that would build on the skip.
|
|
91
93
|
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **six project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, spec layer, navigator index — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`footprint`) was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
92
94
|
- **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
|
|
93
95
|
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
|
|
@@ -9,7 +9,7 @@ The non-obvious traps — scan these before bootstrapping or upgrading. Each is
|
|
|
9
9
|
- **`CLAUDE.md` is a symlink, not a copy.** `ln -s AGENTS.md CLAUDE.md` — single source, no duplication. A copy drifts; a symlink can't.
|
|
10
10
|
- **Never overwrite an existing entry point or hook.** If `AGENTS.md` / `CLAUDE.md` already exist, or the installer reports a pre-existing non-marker git hook, **stop and ask** the user to merge vs replace — don't clobber.
|
|
11
11
|
- **Unrecognized invocations are read-only.** Only a **known** subcommand reaches its mode; the **bare** invocation bootstraps (and an existing `docs/ai/` makes it ask upgrade-vs-bootstrap first, never overwrite); **any other / ambiguous** token routes to `help` (read-only). A garbage invocation never writes. The mapping is pinned by `tools/commands.mjs` `routeInvocation` (unit-tested) — don't hand-route around it.
|
|
12
|
-
- **No Node runtime → skip enforcement.** If the project has no Node (recon step 1), skip bootstrap steps 8–9 (scripts + hook) and follow the cap/archive/index policy manually, or port the scripts to the project's language.
|
|
12
|
+
- **No Node runtime → skip enforcement.** If the project has no Node (recon step 1), skip bootstrap steps 8–9 (scripts + hook) and follow the cap/archive/index policy manually, or port the scripts to the project's language. **A skip names a PROVEN fact, never a proxy:** the upgrade ensures judge Node by EVIDENCE — a root `package.json` OR any kit-seeded `scripts/*.mjs` already deployed — and their `skipped-no-node-evidence` line lists every probe that answered absent; a `skipped-*` outcome whose reason the tool could itself disprove is a tool defect, not an outcome. **A skip line that contradicts the observed tree is raised as a finding in the report, never pasted as neutral** — when the tree you can see disproves a skip's stated reason, report the contradiction with the fact named and do not build on the skip.
|
|
13
13
|
- **Conversational language never translates artifacts.** It governs *dialogue only*. Code, identifiers, paths, commands, log output, abbreviations, and every deployed `docs/ai/` / `AGENTS.md` file stay in their source language. See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract).
|
|
14
14
|
- **Never auto-commit.** Report quality-gate results and wait for explicit approval — in both modes.
|
|
15
15
|
- **Never leak kit internals to the user — and a tool-COMPOSED user-facing line holds the same bar at the source.** No ADR ids, tool / function / operation names (`reconcile`, `inject`, `ensureSlot`), marker / slot / fragment / anchor terminology, or verbatim tool stderr **inside the human sentence** of anything the user reads. Translate every tool outcome into plain language a third-party user — who has never read this `SKILL.md` — can understand and act on (e.g. the cap-refusal report in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3). The composed lines themselves are **user-grade** language: machine tokens and tool self-labels belong to the **machine-line channel** — the `[run-gates] status=…` grammar (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`), a line's leading self-label/prefix, or a runnable command/path the user can act on — never mid-sentence; alarm words (`PARTIALLY`, `incomplete`, `failed`, `broken`, `persists`) render only in outcomes gated on a **detected abnormal condition**. ONE designed exception, stated not implied: the configuration ensures' LEADING outcome token — one closed-vocabulary token, a failure's closed cause word opening its detail line — is that contract's own machine slot, not a leak. The **verbatim**-paste contract stays: the agent pastes tool-composed outcome lines as written and never re-composes their facts — the lines are user-grade at the source, so pasting them verbatim IS the plain language.
|
|
@@ -23,7 +23,7 @@ Read in order, then confirm before starting:
|
|
|
23
23
|
4. Confirm with the user: *"I'm taking task X. Confirm?"*
|
|
24
24
|
|
|
25
25
|
### 1.2. During Work
|
|
26
|
-
**Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero
|
|
26
|
+
**Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (a zero names the adoption state it relies on — not adopted, adopting, or nothing spec-covered touched; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
|
|
27
27
|
|
|
28
28
|
**For every code change:**
|
|
29
29
|
1. Grep for similar implementations — reuse existing patterns.
|
|
@@ -72,13 +72,14 @@ Apply this as part of §2 before any user-facing summary:
|
|
|
72
72
|
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
|
|
73
73
|
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.
|
|
74
74
|
- **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
|
|
75
|
+
- **A skip that contradicts the tree is a finding.** A tool-composed `skipped-*` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.
|
|
75
76
|
|
|
76
77
|
### 2.6. Planning, review & process-fidelity invariants
|
|
77
78
|
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
79
|
- **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
80
|
- **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.
|
|
80
81
|
- **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
|
|
81
|
-
- **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
|
|
82
|
+
- **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). A ZERO names the adoption state it relies on — `not adopted` (no store) or `adopting` (a store with no live contract), either with a recorded decline, or `nothing spec-covered touched` (a store with live contracts) — a bare zero is never a licence; the store's own state is what `status` and the upgrade advisor report. Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
|
|
82
83
|
- **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.
|
|
83
84
|
- **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.
|
|
84
85
|
- **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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ack-store.mjs — the family-owned neutral acknowledgement store: its path, its closed lane->key registry,
|
|
2
|
+
// the one fact fingerprint and the one guarded reader. Contract: docs/ai/specs/kit/ack-store.md.
|
|
3
|
+
// A READ-ONLY leaf (the writer is ack-write.mjs). Dependency-free, Node >= 22; no side effects on import.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { lstatSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
9
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
10
|
+
|
|
11
|
+
export const ACKS_FILE = 'docs/ai/acks.json';
|
|
12
|
+
export const ACKS_LANE_KEY = 'sandboxLaneAck';
|
|
13
|
+
export const ACKS_WORKTREES_DIR_KEY = 'worktreesDirAck';
|
|
14
|
+
export const ACKS_COVERAGE_DOMAIN_KEY = 'coverageDomainAck';
|
|
15
|
+
export const ACKS_SOURCE_SIZE_COPY_KEY = 'sourceSizeCopyAck';
|
|
16
|
+
export const ACKS_SPEC_ADOPTION_KEY = 'specAdoptionAck';
|
|
17
|
+
|
|
18
|
+
// The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's command line ->
|
|
19
|
+
// the store key that writer sets. A lane the registry does not name is a usage refusal at the writer.
|
|
20
|
+
export const ACK_LANES = Object.freeze({
|
|
21
|
+
'sandbox-lane': ACKS_LANE_KEY,
|
|
22
|
+
'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
|
|
23
|
+
'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
|
|
24
|
+
'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
|
|
25
|
+
'spec-adoption': ACKS_SPEC_ADOPTION_KEY,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const FINGERPRINT_LENGTH = 16;
|
|
29
|
+
|
|
30
|
+
// The one fingerprint over an acknowledged FACT (a canonical string the caller composed).
|
|
31
|
+
export const factFingerprint = (fact) => createHash('sha256').update(fact).digest('hex').slice(0, FINGERPRINT_LENGTH);
|
|
32
|
+
|
|
33
|
+
// readAckValue(root, deps, key) -> the recorded string at `key`, or null for the not-yet-acked states
|
|
34
|
+
// (an absent file or docs/ai, a non-string value). The path chain is guarded no-follow and the leaf is
|
|
35
|
+
// read descriptor-bound (`deps.nofollow` injects that door), so a leaf swapped after the guard cannot
|
|
36
|
+
// change the bytes read. A symlinked ancestor/leaf, an escape, a non-regular target, an IO error, a
|
|
37
|
+
// malformed or non-object store all THROW (the caller's stated-skip lane).
|
|
38
|
+
export const readAckValue = (root, deps = {}, ackKey) => {
|
|
39
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
40
|
+
const absPath = join(root, ACKS_FILE);
|
|
41
|
+
try {
|
|
42
|
+
assertContainedRealPath(root, absPath, { lstat });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err?.code === 'ENOENT') return null;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
const read = readRegularFileNoFollow(absPath, deps.nofollow ?? {});
|
|
48
|
+
if (read.outcome === 'absent') return null;
|
|
49
|
+
if (read.outcome === 'foreign') throw new Error(`${ACKS_FILE} is a ${read.className}, not a regular file — refusing to read it`);
|
|
50
|
+
if (read.outcome !== 'ok') throw new Error(`${ACKS_FILE} cannot be read (${read.code})`);
|
|
51
|
+
const parsed = JSON.parse(read.content);
|
|
52
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
53
|
+
throw new Error(`${ACKS_FILE}: expected a JSON object`);
|
|
54
|
+
}
|
|
55
|
+
const value = parsed[ackKey];
|
|
56
|
+
return typeof value === 'string' ? value : null;
|
|
57
|
+
};
|
package/tools/ack-write.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { lstatSync, readFileSync } from 'node:fs';
|
|
|
29
29
|
import { dirname, join, resolve } from 'node:path';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { isDirectRun } from './direct-run.mjs';
|
|
32
|
-
import { ACKS_FILE, ACK_LANES } from './
|
|
32
|
+
import { ACKS_FILE, ACK_LANES } from './ack-store.mjs';
|
|
33
33
|
import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
34
34
|
import { shellQuoteArg } from './review-state.mjs';
|
|
35
35
|
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -72,6 +72,8 @@ import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabula
|
|
|
72
72
|
// The MCP registration's four public strings. Imported from the READ-ONLY leaf, never from the
|
|
73
73
|
// writer: a read-only lint must not pull the atomic-write core into its import graph.
|
|
74
74
|
import { ENABLED_KEY as MCP_ENABLED_KEY, MCP_JSON_REL, SERVER_NAME as MCP_SERVER_NAME, allowRulesFor } from './mcp-registration.mjs';
|
|
75
|
+
// The spec-adoption state tokens the status mode doc must name (contract: kit/spec-adoption).
|
|
76
|
+
import { ADOPTION_STATES, SPEC_ADOPTION_LANE } from './spec-adoption.mjs';
|
|
75
77
|
|
|
76
78
|
const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
|
|
77
79
|
const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
|
|
@@ -86,6 +88,7 @@ const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
|
|
|
86
88
|
const GATES_DOC = 'references/modes/gates.md';
|
|
87
89
|
const MCP_DOC = 'references/modes/mcp.md';
|
|
88
90
|
const UNINSTALL_DOC = 'references/modes/uninstall.md';
|
|
91
|
+
const STATUS_DOC = 'references/modes/status.md';
|
|
89
92
|
// One literal for the dispatch mode doc: the structure leaf already names it as the file it anchors
|
|
90
93
|
// its table in, and a second copy here is exactly the drift this lint exists to catch.
|
|
91
94
|
const DISPATCH_DOC = ADVISOR_MATRIX_DOC;
|
|
@@ -236,6 +239,11 @@ export const BINDINGS = Object.freeze([
|
|
|
236
239
|
valueBinding('mcp-enabled-key', MCP_ENABLED_KEY, `\`${MCP_ENABLED_KEY}\``, [MCP_DOC, UNINSTALL_DOC]),
|
|
237
240
|
valueBinding('mcp-server-name', MCP_SERVER_NAME, `\`"${MCP_SERVER_NAME}"\``, [MCP_DOC, UNINSTALL_DOC]),
|
|
238
241
|
...allowRulesFor().map((rule) => valueBinding(`mcp-allow-rule:${rule}`, rule, `\`${rule}\``, [MCP_DOC, UNINSTALL_DOC])),
|
|
242
|
+
// The spec-adoption state tokens: status.md renders a plain phrase per token, so the doc must name
|
|
243
|
+
// every token the survey can answer — a fifth state added to the leaf with no phrase fails here.
|
|
244
|
+
// The decline lane rides both the status line and the advisor item, so both docs name it.
|
|
245
|
+
...ADOPTION_STATES.map((state) => valueBinding(`spec-adoption:${state}`, state, `\`${state}\``, [STATUS_DOC])),
|
|
246
|
+
valueBinding('spec-adoption-lane', SPEC_ADOPTION_LANE, `--lane ${SPEC_ADOPTION_LANE}`, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
|
|
239
247
|
].map((b) => Object.freeze(b)));
|
|
240
248
|
|
|
241
249
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
package/tools/ensure-ops.mjs
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// • The DECISION lives where it already lived. The orchestration `_README` refresh asks
|
|
17
17
|
// orchestration-config.mjs (refreshReadme / the known-prior canonical set) and writes through
|
|
18
18
|
// orchestration-write.mjs — the file's one writer. Nothing here re-derives either.
|
|
19
|
-
// • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node`
|
|
20
|
-
// names
|
|
19
|
+
// • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node-evidence`
|
|
20
|
+
// names every Node probe that answered absent; an ADR-layout read that fails is `adr-layout-unverifiable` and
|
|
21
21
|
// writes NOTHING (the STRICT survey, fail-closed — the lenient status wrapper reads an unreadable
|
|
22
22
|
// tree as `none`, which here would mean seeding a rotator beside a store nobody could inspect).
|
|
23
23
|
// • A failed op is a non-zero signal, never a line that reads like success.
|
|
@@ -34,6 +34,7 @@ import { GATES_REL } from './gates-declaration.mjs';
|
|
|
34
34
|
import { AUTONOMY_REL } from './autonomy-config.mjs';
|
|
35
35
|
import { surveyAdrLayoutStrict } from './family-registry.mjs';
|
|
36
36
|
import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
|
|
37
|
+
import { NODE_EVIDENCE, describeNodeProbes, probeNodeEvidence } from './node-evidence.mjs';
|
|
37
38
|
|
|
38
39
|
// The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
|
|
39
40
|
// relayed token set without importing this module's writer graph. Re-exported here because every
|
|
@@ -49,7 +50,6 @@ export {
|
|
|
49
50
|
WRITE_TOKENS,
|
|
50
51
|
} from './ensure-vocabulary.mjs';
|
|
51
52
|
|
|
52
|
-
const PACKAGE_JSON = 'package.json';
|
|
53
53
|
const SCRIPTS_DIR = 'scripts';
|
|
54
54
|
|
|
55
55
|
const outcome = (op, token, lines, failed = false) => {
|
|
@@ -204,9 +204,19 @@ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
|
|
|
204
204
|
|
|
205
205
|
// ── 4. scripts/ — the ADR-cascade enforcement pairs, detect-first ──────────────────────────────────
|
|
206
206
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
207
|
+
// The Node-evidence refusal every ensure that places Node scripts shares (contract: kit/node-evidence):
|
|
208
|
+
// null when Node provably runs here, else the ONE outcome the caller returns — a stated skip naming the
|
|
209
|
+
// probes that answered absent, or a fail-closed failure when a probe could not be read.
|
|
210
|
+
export const nodeEvidenceRefusal = (op, cwd, lstat) => {
|
|
211
|
+
const evidence = probeNodeEvidence(cwd, lstat);
|
|
212
|
+
if (evidence.state === NODE_EVIDENCE.UNREADABLE) {
|
|
213
|
+
return loud(op, 'node-evidence-unverifiable', `${SCRIPTS_DIR}/: whether Node runs here could not be read (${evidence.error}), so nothing was written — resolve it by hand, then re-run`);
|
|
214
|
+
}
|
|
215
|
+
if (evidence.state === NODE_EVIDENCE.NONE) {
|
|
216
|
+
return ok(op, 'skipped-no-node-evidence', `${SCRIPTS_DIR}/: no Node evidence in this tree — probed ${describeNodeProbes(evidence)}, no regular file present; the seeded pairs are Node enforcement, so nothing written`);
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
210
220
|
|
|
211
221
|
const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
|
|
212
222
|
|
|
@@ -221,9 +231,8 @@ const partialNote = (lines) => {
|
|
|
221
231
|
export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
222
232
|
const lstat = deps.lstat ?? lstatSync;
|
|
223
233
|
const read = deps.readFile ?? readFileSync;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
234
|
+
const refusal = nodeEvidenceRefusal('scripts', cwd, lstat);
|
|
235
|
+
if (refusal) return refusal;
|
|
227
236
|
let layout;
|
|
228
237
|
try {
|
|
229
238
|
layout = surveyAdrLayoutStrict(cwd, deps);
|
package/tools/ensure-specs.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { readFileSync, lstatSync } from 'node:fs';
|
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
28
28
|
import { classifyDeployedScript } from './script-priors.mjs';
|
|
29
|
-
import { composeFailure, composeOutcome,
|
|
29
|
+
import { composeFailure, composeOutcome, nodeEvidenceRefusal, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
|
|
30
30
|
|
|
31
31
|
const OP = 'specs';
|
|
32
32
|
const SCRIPTS_DIR = 'scripts';
|
|
@@ -159,9 +159,8 @@ const renderStoreRoot = (kitRoot, read, today) => {
|
|
|
159
159
|
export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
160
160
|
const lstat = deps.lstat ?? lstatSync;
|
|
161
161
|
const read = deps.readFile ?? readFileSync;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
162
|
+
const refusal = nodeEvidenceRefusal(OP, cwd, lstat);
|
|
163
|
+
if (refusal) return refusal;
|
|
165
164
|
const survey = (name) => surveyScript({ cwd, kitRoot, name, read, lstat });
|
|
166
165
|
const reader = READER_PAIR.map(survey);
|
|
167
166
|
const checker = CHECKER_PAIR.map(survey);
|
|
@@ -31,7 +31,7 @@ export const ENSURE_TOKENS = Object.freeze([
|
|
|
31
31
|
'already-present',
|
|
32
32
|
'customized-preserved',
|
|
33
33
|
'malformed-preserved',
|
|
34
|
-
'skipped-no-node',
|
|
34
|
+
'skipped-no-node-evidence',
|
|
35
35
|
'old-adr-layout-migration-instructed',
|
|
36
36
|
'failed',
|
|
37
37
|
]);
|
|
@@ -44,6 +44,9 @@ export const FAILURE_CAUSES = Object.freeze([
|
|
|
44
44
|
'template-unreadable',
|
|
45
45
|
'bundle-unreadable',
|
|
46
46
|
'adr-layout-unverifiable',
|
|
47
|
+
// A Node probe (package.json, a kit-seeded script) failed with anything but ENOENT: whether Node runs
|
|
48
|
+
// here is unproven, so the ensures that place Node scripts write nothing (contract: kit/node-evidence).
|
|
49
|
+
'node-evidence-unverifiable',
|
|
47
50
|
'wrong-node-kind',
|
|
48
51
|
'write-refused',
|
|
49
52
|
'unexpected-error',
|
|
@@ -75,7 +78,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
|
|
|
75
78
|
'customized-preserved',
|
|
76
79
|
'malformed-preserved',
|
|
77
80
|
'already-present',
|
|
78
|
-
'skipped-no-node',
|
|
81
|
+
'skipped-no-node-evidence',
|
|
79
82
|
'old-adr-layout-migration-instructed',
|
|
80
83
|
'failed',
|
|
81
84
|
]);
|
|
@@ -82,6 +82,8 @@ import {
|
|
|
82
82
|
import { detectSurface } from './surface.mjs';
|
|
83
83
|
import { toViewModel } from './view-model.mjs';
|
|
84
84
|
import { render } from './renderers.mjs';
|
|
85
|
+
// The feature-spec adoption state (contract: kit/spec-adoption) — read-only leaves, no cycle.
|
|
86
|
+
import { ADOPTION, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
|
|
85
87
|
|
|
86
88
|
// ── manifestState values — re-export the EXACT public subset family-registry exported before B1 ─────
|
|
87
89
|
// (the 7 state constants + DISPLAY_NAMES) so every existing importer (uninstall.mjs, the test suites)
|
|
@@ -428,9 +430,33 @@ const surveyAdrLayout = (dir, deps) => {
|
|
|
428
430
|
}
|
|
429
431
|
};
|
|
430
432
|
|
|
433
|
+
// The spec-adoption survey, LENIENT for the read-only view — two facts, each failing on its own: a
|
|
434
|
+
// survey that throws becomes the `unreadable` state with its reason, and an ack read that throws
|
|
435
|
+
// keeps the store state and carries `declineError` beside `declined: false` — the status line still
|
|
436
|
+
// renders, never a crash, never a silent "not adopted" and never a decline it could not read.
|
|
437
|
+
const surveySpecs = (dir, deps) => {
|
|
438
|
+
const survey = (() => {
|
|
439
|
+
try {
|
|
440
|
+
return surveySpecAdoption(dir, deps);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return { state: ADOPTION.UNREADABLE, live: 0, draft: 0, reason: localizeError(err) };
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
445
|
+
const decline = (() => {
|
|
446
|
+
if (survey.state === ADOPTION.ADOPTED) return { declined: false, declineError: null };
|
|
447
|
+
try {
|
|
448
|
+
return { declined: readDeclineAck(dir, deps), declineError: null };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return { declined: false, declineError: localizeError(err) };
|
|
451
|
+
}
|
|
452
|
+
})();
|
|
453
|
+
return { state: survey.state, live: survey.live, draft: survey.draft, reason: survey.reason, ...decline };
|
|
454
|
+
};
|
|
455
|
+
|
|
431
456
|
// surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
|
|
432
|
-
// docs/ai/ exists, the ADR-store layout, and whether the hidden-mode fence is
|
|
433
|
-
// only, all injectable), no git subprocess — the read-only `status` view must
|
|
457
|
+
// docs/ai/ exists, the ADR-store layout, the spec-adoption state, and whether the hidden-mode fence is
|
|
458
|
+
// present. Pure (fs reads only, all injectable), no git subprocess — the read-only `status` view must
|
|
459
|
+
// never mutate or spawn.
|
|
434
460
|
export const surveyProject = (projectDir, deps = {}) => {
|
|
435
461
|
const exists = deps.exists ?? existsSync;
|
|
436
462
|
const dir = resolve(projectDir);
|
|
@@ -445,7 +471,7 @@ export const surveyProject = (projectDir, deps = {}) => {
|
|
|
445
471
|
}
|
|
446
472
|
})();
|
|
447
473
|
const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
|
|
448
|
-
return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
|
|
474
|
+
return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), specs: surveySpecs(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
|
|
449
475
|
};
|
|
450
476
|
|
|
451
477
|
// ── report ───────────────────────────────────────────────────────────────────────
|
|
@@ -683,6 +709,9 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
|
|
|
683
709
|
deployed: project.deployed,
|
|
684
710
|
docsAi: project.docsAiPresent,
|
|
685
711
|
adrLayout: project.adrLayout, // 'old' | 'old-unrotated' | 'migrated' | 'none' — a user-safe token, never a raw path
|
|
712
|
+
// { state: not-adopted | adopting | adopted | unreadable, live, draft, reason, declined, declineError }
|
|
713
|
+
// — an envelope predating the field omits it (the view-model reads that as unknown, never as a state).
|
|
714
|
+
...(project.specs ? { specs: project.specs } : {}),
|
|
686
715
|
// member + display + version only — never the internal stamp FILENAME (s.file).
|
|
687
716
|
deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
|
|
688
717
|
};
|
package/tools/lens-region.mjs
CHANGED
|
@@ -62,7 +62,10 @@ Apply this as part of §2 before any user-facing summary:
|
|
|
62
62
|
- **No condescension, no filler.** Own a miss plainly and fix it in the same message.
|
|
63
63
|
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
|
|
64
64
|
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
|
|
65
|
-
|
|
65
|
+
// The canon that shipped between the closing-state-block contract and the contradicted-skip bullet.
|
|
66
|
+
const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
|
|
67
|
+
- **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
|
|
68
|
+
export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
|
|
66
69
|
|
|
67
70
|
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
68
71
|
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// node-evidence.mjs — does Node PROVABLY run in this project tree? Contract: docs/ai/specs/kit/node-evidence.md.
|
|
2
|
+
// Pure over an injectable lstat; no writes, no side effects on import. Dependency-free, Node >= 22.
|
|
3
|
+
|
|
4
|
+
import { lstatSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const NODE_EVIDENCE = Object.freeze({
|
|
8
|
+
PACKAGE_JSON: 'package-json',
|
|
9
|
+
DEPLOYED_SCRIPTS: 'deployed-node-scripts',
|
|
10
|
+
NONE: 'none',
|
|
11
|
+
UNREADABLE: 'unreadable',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const PACKAGE_JSON_REL = 'package.json';
|
|
15
|
+
export const SCRIPTS_DIR = 'scripts';
|
|
16
|
+
|
|
17
|
+
// The runnable scripts the bootstrap copies from references/scripts/ — pinned against the bundle by the suite.
|
|
18
|
+
export const NODE_EVIDENCE_SCRIPTS = Object.freeze([
|
|
19
|
+
'archive-caps.mjs',
|
|
20
|
+
'archive-changelog.mjs',
|
|
21
|
+
'archive-decisions.mjs',
|
|
22
|
+
'archive-issues.mjs',
|
|
23
|
+
'check-docs-size.mjs',
|
|
24
|
+
'install-git-hooks.mjs',
|
|
25
|
+
'markdown-blocks.mjs',
|
|
26
|
+
'migrate-gates.mjs',
|
|
27
|
+
'spec-schema.mjs',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const NODE_EVIDENCE_PROBES = Object.freeze([PACKAGE_JSON_REL, ...NODE_EVIDENCE_SCRIPTS.map((name) => `${SCRIPTS_DIR}/${name}`)]);
|
|
31
|
+
|
|
32
|
+
const ENOENT = 'ENOENT';
|
|
33
|
+
const kindOf = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : st.isFile() ? 'a regular file' : 'not a regular file');
|
|
34
|
+
|
|
35
|
+
const answer = (state, evidence, wrongKind, extra = {}) =>
|
|
36
|
+
Object.freeze({ state, evidence, probed: NODE_EVIDENCE_PROBES, wrongKind: Object.freeze(wrongKind), ...extra });
|
|
37
|
+
|
|
38
|
+
// probeNodeEvidence(cwd, lstat) -> { state, evidence, probed, wrongKind, error? }: the first regular file
|
|
39
|
+
// among the probes answers; a probe failing with anything but ENOENT answers unreadable at once; a path of
|
|
40
|
+
// the wrong node kind is not evidence — it is recorded in `wrongKind` and the walk continues. lstat does
|
|
41
|
+
// not follow the LEAF but walks THROUGH a symlinked scripts/, whose files are not this tree's — so the
|
|
42
|
+
// directory is proven plain before any script inside it counts.
|
|
43
|
+
export const probeNodeEvidence = (cwd, lstat = lstatSync) => {
|
|
44
|
+
const wrongKind = [];
|
|
45
|
+
const probeKind = (rel, wanted) => {
|
|
46
|
+
let st;
|
|
47
|
+
try {
|
|
48
|
+
st = lstat(join(cwd, rel));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err && err.code === ENOENT) return 'absent';
|
|
51
|
+
throw Object.assign(err, { probedRel: rel });
|
|
52
|
+
}
|
|
53
|
+
if (wanted === 'dir' ? st.isDirectory() && !st.isSymbolicLink() : st.isFile()) return wanted;
|
|
54
|
+
wrongKind.push(`${rel} is ${kindOf(st)}`);
|
|
55
|
+
return 'wrong-kind';
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
if (probeKind(PACKAGE_JSON_REL, 'file') === 'file') return answer(NODE_EVIDENCE.PACKAGE_JSON, PACKAGE_JSON_REL, wrongKind);
|
|
59
|
+
if (probeKind(SCRIPTS_DIR, 'dir') === 'dir') {
|
|
60
|
+
for (const rel of NODE_EVIDENCE_PROBES.slice(1)) {
|
|
61
|
+
if (probeKind(rel, 'file') === 'file') return answer(NODE_EVIDENCE.DEPLOYED_SCRIPTS, rel, wrongKind);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return answer(NODE_EVIDENCE.UNREADABLE, null, wrongKind, { error: `${err.code || err.message || 'lstat failed'} on ${err.probedRel}` });
|
|
66
|
+
}
|
|
67
|
+
return answer(NODE_EVIDENCE.NONE, null, wrongKind);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const hasNodeEvidence = (probe) => probe.state === NODE_EVIDENCE.PACKAGE_JSON || probe.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS;
|
|
71
|
+
|
|
72
|
+
// The sentence a skip line carries: every probe checked, and what of the wrong kind sat at any of them.
|
|
73
|
+
export const describeNodeProbes = (probe = null) => {
|
|
74
|
+
const probes = `${PACKAGE_JSON_REL} and the kit-seeded ${SCRIPTS_DIR}/ files (${NODE_EVIDENCE_SCRIPTS.join(', ')})`;
|
|
75
|
+
const wrong = probe?.wrongKind?.length ? ` — not evidence: ${probe.wrongKind.join('; ')}` : '';
|
|
76
|
+
return `${probes}${wrong}`;
|
|
77
|
+
};
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
// idiom).
|
|
33
33
|
|
|
34
34
|
import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
|
|
35
|
-
import { createHash } from 'node:crypto';
|
|
36
35
|
import { homedir } from 'node:os';
|
|
37
36
|
import { dirname, join, resolve } from 'node:path';
|
|
38
37
|
import { fileURLToPath } from 'node:url';
|
|
@@ -84,6 +83,25 @@ import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
|
|
|
84
83
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
85
84
|
import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
|
|
86
85
|
import { preflightCheapAgents } from './cheap-agents.mjs';
|
|
86
|
+
// The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
|
|
87
|
+
// (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
|
|
88
|
+
import {
|
|
89
|
+
ACKS_FILE,
|
|
90
|
+
ACKS_LANE_KEY,
|
|
91
|
+
ACKS_WORKTREES_DIR_KEY,
|
|
92
|
+
ACKS_COVERAGE_DOMAIN_KEY,
|
|
93
|
+
ACKS_SOURCE_SIZE_COPY_KEY,
|
|
94
|
+
ACK_LANES,
|
|
95
|
+
factFingerprint,
|
|
96
|
+
readAckValue,
|
|
97
|
+
} from './ack-store.mjs';
|
|
98
|
+
import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
|
|
99
|
+
import { ENSURE_OPS } from './ensure-vocabulary.mjs';
|
|
100
|
+
|
|
101
|
+
// The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
|
|
102
|
+
const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
|
|
103
|
+
|
|
104
|
+
export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_KEY, ACKS_SOURCE_SIZE_COPY_KEY, ACK_LANES, factFingerprint };
|
|
87
105
|
|
|
88
106
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
89
107
|
const toolPath = (rel) => join(HERE, rel);
|
|
@@ -163,6 +181,11 @@ export const SEVERITIES = Object.freeze({
|
|
|
163
181
|
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
164
182
|
'sandbox-lane': SEVERITY_OPTIONAL,
|
|
165
183
|
'worktrees-dir': SEVERITY_OPTIONAL,
|
|
184
|
+
// The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
|
|
185
|
+
// declaration that is broken): an absent store offers the seed, a store with no live contract offers
|
|
186
|
+
// the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
|
|
187
|
+
'spec-adoption': SEVERITY_OPTIONAL,
|
|
188
|
+
'spec-adoption.adopting': SEVERITY_OPTIONAL,
|
|
166
189
|
});
|
|
167
190
|
// The per-item render tags (frozen presentation data, same language contract as the templates).
|
|
168
191
|
export const SEVERITY_LABELS = Object.freeze({
|
|
@@ -234,6 +257,8 @@ export const WHATS = Object.freeze({
|
|
|
234
257
|
'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
|
|
235
258
|
'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
|
|
236
259
|
'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
|
|
260
|
+
'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
|
|
261
|
+
'spec-adoption.adopting': 'feature-spec store: {n} draft spec(s), no live contract — nothing governs a plan through it yet; land a live contract, or record the decline',
|
|
237
262
|
});
|
|
238
263
|
|
|
239
264
|
// ── the shape contract (D2): registry strings AND composed items stay one line under the cap ────
|
|
@@ -290,6 +315,7 @@ export const BENEFITS = Object.freeze({
|
|
|
290
315
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
291
316
|
'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
|
|
292
317
|
'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
|
|
318
|
+
'spec-adoption': 'contracts — a plan names the contract it builds to, and a change to a governed slice is visible at review instead of after it',
|
|
293
319
|
});
|
|
294
320
|
|
|
295
321
|
// ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
|
|
@@ -332,6 +358,9 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
|
|
|
332
358
|
{ id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
|
|
333
359
|
{ id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
|
|
334
360
|
{ id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
|
|
361
|
+
// The feature-spec layer is delivered by upgrade's spec-layer ensure (there is no specs mode), so
|
|
362
|
+
// its adoption state is declared where the store is seeded.
|
|
363
|
+
{ id: 'spec-adoption', mode: 'upgrade', advisorKey: 'spec-adoption' },
|
|
335
364
|
{ id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
|
|
336
365
|
{ id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
|
|
337
366
|
// The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
|
|
@@ -1042,41 +1071,13 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
|
|
|
1042
1071
|
if (abs === homeAbs) return '~';
|
|
1043
1072
|
return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
|
|
1044
1073
|
};
|
|
1045
|
-
|
|
1046
|
-
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
1074
|
+
return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
|
|
1047
1075
|
};
|
|
1048
1076
|
|
|
1049
|
-
// The
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// is churn (the census binds the verdict + extension set, never per-file counts).
|
|
1054
|
-
export const factFingerprint = (fact) => createHash('sha256').update(fact).digest('hex').slice(0, 16);
|
|
1055
|
-
|
|
1056
|
-
// The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
|
|
1057
|
-
// validator guards — top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
|
|
1058
|
-
// on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
|
|
1059
|
-
// keys below are read for one deprecation window. The sandbox/permissions security keys are NEVER
|
|
1060
|
-
// consulted as an ack.
|
|
1061
|
-
export const ACKS_FILE = 'docs/ai/acks.json';
|
|
1062
|
-
export const ACKS_LANE_KEY = 'sandboxLaneAck';
|
|
1063
|
-
export const ACKS_WORKTREES_DIR_KEY = 'worktreesDirAck';
|
|
1064
|
-
export const ACKS_COVERAGE_DOMAIN_KEY = 'coverageDomainAck';
|
|
1065
|
-
export const ACKS_SOURCE_SIZE_COPY_KEY = 'sourceSizeCopyAck';
|
|
1066
|
-
// The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's
|
|
1067
|
-
// command line → the store key that writer sets. A lane the registry does not name is a usage
|
|
1068
|
-
// refusal at the writer, never a newly-invented key in the shared store.
|
|
1069
|
-
//
|
|
1070
|
-
// An ack lane exists for a state the maintainer can only ANSWER, never converge: a tracked tree the
|
|
1071
|
-
// coverage domain cannot reach, a checker deliberately vendored elsewhere. It is deliberately NOT
|
|
1072
|
-
// available to a state that is simply BROKEN — a dead checker/producer pair is fixed, not
|
|
1073
|
-
// acknowledged, so no lane names it.
|
|
1074
|
-
export const ACK_LANES = Object.freeze({
|
|
1075
|
-
'sandbox-lane': ACKS_LANE_KEY,
|
|
1076
|
-
'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
|
|
1077
|
-
'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
|
|
1078
|
-
'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
|
|
1079
|
-
});
|
|
1077
|
+
// The ack store (D4; AD-055 Part I) is the kit-owned PRIMARY ack channel; the legacy settings-scope
|
|
1078
|
+
// keys below are read for one deprecation window. An ack lane exists for a state the maintainer can
|
|
1079
|
+
// only ANSWER, never converge — a dead checker/producer pair is fixed, not acknowledged, so no lane
|
|
1080
|
+
// names it. The store's path, keys, lane registry and reader are ack-store.mjs (re-exported above).
|
|
1080
1081
|
|
|
1081
1082
|
// The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
|
|
1082
1083
|
// placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
|
|
@@ -1146,38 +1147,6 @@ const declarationCarriesMarker = (root, deps) => {
|
|
|
1146
1147
|
export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
|
|
1147
1148
|
export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
|
|
1148
1149
|
|
|
1149
|
-
// Read the family-owned ack store. An ABSENT file (or absent docs/ai) is the NORMAL not-yet-acked
|
|
1150
|
-
// state → null (plain fall-through, never a skip). A parse/IO error on an EXISTING file THROWS — the
|
|
1151
|
-
// probe's catch turns it into a stated skip line (Decisions 2). A non-object root is a malformed
|
|
1152
|
-
// store (fail-closed skip); a non-string value at the key is tolerated → null (the item re-fires).
|
|
1153
|
-
// The WHOLE path chain (root / docs / ai / acks.json) is guarded WITHOUT following symlinks
|
|
1154
|
-
// BEFORE any read: a symlinked ANCESTOR could otherwise read an ack from OUTSIDE the project (the
|
|
1155
|
-
// writer refuses such a deployment — the reader must too), a symlinked/dangling LEAF must not read as
|
|
1156
|
-
// not-yet-acked, and a non-regular target (FIFO/dir/device) is a fail-closed SKIP — never read it (a
|
|
1157
|
-
// FIFO would BLOCK the advisor). ENOENT-safe: an absent file/dir is the NORMAL not-yet-acked null.
|
|
1158
|
-
const readAckValue = (root, deps, ackKey) => {
|
|
1159
|
-
const readFile = deps.readFile ?? readFileSync;
|
|
1160
|
-
const lstat = deps.lstat ?? lstatSync;
|
|
1161
|
-
const absPath = join(root, ACKS_FILE);
|
|
1162
|
-
let st;
|
|
1163
|
-
try {
|
|
1164
|
-
assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
|
|
1165
|
-
st = lstat(absPath);
|
|
1166
|
-
} catch (err) {
|
|
1167
|
-
if (err?.code === 'ENOENT') return null; // genuinely absent (file or docs/ai) — normal not-yet-acked
|
|
1168
|
-
throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
|
|
1169
|
-
}
|
|
1170
|
-
if (!st.isFile()) {
|
|
1171
|
-
throw new Error(`${ACKS_FILE} is not a regular file — refusing to read it`);
|
|
1172
|
-
}
|
|
1173
|
-
const parsed = JSON.parse(readFile(absPath, 'utf8'));
|
|
1174
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1175
|
-
throw new Error(`${ACKS_FILE}: expected a JSON object`);
|
|
1176
|
-
}
|
|
1177
|
-
const value = parsed[ackKey];
|
|
1178
|
-
return typeof value === 'string' ? value : null;
|
|
1179
|
-
};
|
|
1180
|
-
|
|
1181
1150
|
// Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
|
|
1182
1151
|
// false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
|
|
1183
1152
|
// an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
|
|
@@ -1208,7 +1177,38 @@ const readReadLaneToggle = (root, deps) => {
|
|
|
1208
1177
|
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
1209
1178
|
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
1210
1179
|
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
1211
|
-
export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel']);
|
|
1180
|
+
export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption']);
|
|
1181
|
+
|
|
1182
|
+
// The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
|
|
1183
|
+
// zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
|
|
1184
|
+
// started — an owner found the store absent only by asking. The survey reads the store through
|
|
1185
|
+
// spec-check's own census; a recorded decline (the `spec-adoption` ack lane) is the fact that
|
|
1186
|
+
// silences the item, and an unreadable store is a stated skip so the flow-optimal line never renders
|
|
1187
|
+
// over it. The not-adopted apply is the spec-layer ensure; the decline preview rides the recipe line.
|
|
1188
|
+
export const probeSpecAdoption = ({ root, deps, add, skip }) => {
|
|
1189
|
+
try {
|
|
1190
|
+
const survey = surveySpecAdoption(root, deps);
|
|
1191
|
+
if (survey.state === ADOPTION.UNREADABLE) {
|
|
1192
|
+
skip('spec-adoption', new Error(`${survey.reason} — the adoption state under ${SPEC_STORE_DIR_REL} cannot be judged`));
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (survey.state === ADOPTION.ADOPTED || readDeclineAck(root, deps)) return;
|
|
1196
|
+
const decline = `node ${q(toolPath('ack-write.mjs'))} --lane ${SPEC_ADOPTION_LANE} --fingerprint ${declineFingerprint()} --cwd ${q(root)}`;
|
|
1197
|
+
if (survey.state === ADOPTION.NOT_ADOPTED) {
|
|
1198
|
+
add(
|
|
1199
|
+
'spec-adoption',
|
|
1200
|
+
fillTemplate(WHATS['spec-adoption'], {}),
|
|
1201
|
+
`node ${q(toolPath('ensure-configs.mjs'))} --reconcile --only ${SPEC_LAYER_ENSURE} --cwd ${q(root)}`,
|
|
1202
|
+
'spec-adoption',
|
|
1203
|
+
`HAND-APPLY alternative (instead of the apply, never after it): decline the layer by recording it — ${decline}`,
|
|
1204
|
+
);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
add('spec-adoption', fillTemplate(WHATS['spec-adoption.adopting'], { n: survey.draft }), decline, 'spec-adoption.adopting');
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
skip('spec-adoption', err);
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
1212
|
|
|
1213
1213
|
const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
1214
1214
|
try {
|
|
@@ -1491,6 +1491,7 @@ const PROBES = Object.freeze([
|
|
|
1491
1491
|
probeSandboxLane,
|
|
1492
1492
|
probeWorktreesDir,
|
|
1493
1493
|
probeMcpChannel,
|
|
1494
|
+
probeSpecAdoption,
|
|
1494
1495
|
]);
|
|
1495
1496
|
|
|
1496
1497
|
export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
package/tools/renderers.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Pure, no side effects, Node >= 22.
|
|
9
9
|
|
|
10
10
|
import { BLOCK_TITLES, SETTINGS_LABELS, glyphsFor, NO_DEPLOYMENT } from './presentation.mjs';
|
|
11
|
+
import { describeAdoption } from './spec-adoption.mjs';
|
|
11
12
|
|
|
12
13
|
const MEMBER_COL = 20;
|
|
13
14
|
const VERSION_COL = 12;
|
|
@@ -96,6 +97,14 @@ const renderProject = (vm, { color }) => {
|
|
|
96
97
|
if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
|
|
97
98
|
lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
|
|
98
99
|
}
|
|
100
|
+
// Every state renders — an owner opens this surface deliberately, so "not adopted" is the one line
|
|
101
|
+
// that must never be missing; an envelope without the field says so rather than inventing a state.
|
|
102
|
+
if (p.specs) {
|
|
103
|
+
const declineNote = p.specs.declineError ? ` (decline ack unreadable: ${p.specs.declineError})` : '';
|
|
104
|
+
lines.push(` ${pad('specs', STAMP_COL)}${describeAdoption(p.specs, { declined: p.specs.declined })}${declineNote}`);
|
|
105
|
+
} else {
|
|
106
|
+
lines.push(` ${pad('specs', STAMP_COL)}unknown — the installed kit predates the adoption state`);
|
|
107
|
+
}
|
|
99
108
|
if (p.visibility) {
|
|
100
109
|
const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
|
|
101
110
|
lines.push(` ${pad('visibility', STAMP_COL)}${v}`);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// spec-adoption.mjs — which of the four adoption states the feature-spec store is in, and whether the layer
|
|
2
|
+
// was declined. Contract: docs/ai/specs/kit/spec-adoption.md. The census and the per-document read are
|
|
3
|
+
// spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
|
|
4
|
+
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
|
|
7
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
8
|
+
import { probe as probePath, realpath as realpathOf, list as listDir } from './spec-check-cli.mjs';
|
|
9
|
+
import { walkStore, readClosure } from './spec-check.mjs';
|
|
10
|
+
import { ACKS_SPEC_ADOPTION_KEY, factFingerprint, readAckValue } from './ack-store.mjs';
|
|
11
|
+
|
|
12
|
+
export const ADOPTION = Object.freeze({
|
|
13
|
+
NOT_ADOPTED: 'not-adopted',
|
|
14
|
+
ADOPTING: 'adopting',
|
|
15
|
+
ADOPTED: 'adopted',
|
|
16
|
+
UNREADABLE: 'unreadable',
|
|
17
|
+
});
|
|
18
|
+
export const ADOPTION_STATES = Object.freeze(Object.values(ADOPTION));
|
|
19
|
+
|
|
20
|
+
export const STORE_DIR_REL = SPEC_SCHEMA.storePrefix.slice(0, -1);
|
|
21
|
+
export const SPEC_ADOPTION_LANE = 'spec-adoption';
|
|
22
|
+
export const DECLINE_FACT = `spec-adoption:declined:${SPEC_SCHEMA.storePrefix}`;
|
|
23
|
+
|
|
24
|
+
const CONTRACT_KIND = 'spec';
|
|
25
|
+
const LIVE = 'live';
|
|
26
|
+
const DRAFT = 'draft';
|
|
27
|
+
const RETIRED = 'retired';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_IO = Object.freeze({ read: readRegularFileNoFollow, probe: probePath, realpath: realpathOf, list: listDir });
|
|
30
|
+
|
|
31
|
+
const verdict = (state, counts = { live: 0, draft: 0, retired: 0 }, reason = null) => Object.freeze({ state, ...counts, reason });
|
|
32
|
+
|
|
33
|
+
// surveySpecAdoption(root, deps) -> { state, live, draft, retired, reason }. `deps.io` overrides the four
|
|
34
|
+
// IO primitives (tests); every other answer comes from the store bytes through the one reader.
|
|
35
|
+
export const surveySpecAdoption = (root, deps = {}) => {
|
|
36
|
+
const io = { ...DEFAULT_IO, ...(deps.io ?? {}) };
|
|
37
|
+
const dirState = io.probe(join(root, STORE_DIR_REL));
|
|
38
|
+
if (dirState === 'absent') return verdict(ADOPTION.NOT_ADOPTED);
|
|
39
|
+
if (dirState !== 'dir') return verdict(ADOPTION.UNREADABLE, undefined, `${STORE_DIR_REL} is ${dirState === 'file' ? 'a file' : dirState}, not a directory`);
|
|
40
|
+
const rootReal = io.realpath(root);
|
|
41
|
+
if (rootReal === null) return verdict(ADOPTION.UNREADABLE, undefined, 'the project root does not resolve');
|
|
42
|
+
const findings = [];
|
|
43
|
+
const ctx = { io, at: (rel) => (rel === '' ? root : `${root}/${rel}`), rootReal, add: (rule, path, message) => findings.push({ rule, path, message }) };
|
|
44
|
+
const closure = walkStore(ctx).map((path) => ({ path, roles: ['present'] }));
|
|
45
|
+
const docs = findings.length === 0 ? readClosure(closure, ctx) : new Map();
|
|
46
|
+
if (findings.length > 0) return verdict(ADOPTION.UNREADABLE, undefined, `${findings[0].path}: ${findings[0].message}`);
|
|
47
|
+
const counts = { live: 0, draft: 0, retired: 0 };
|
|
48
|
+
for (const doc of docs.values()) {
|
|
49
|
+
if (doc.verdict?.kind !== CONTRACT_KIND) continue;
|
|
50
|
+
if (doc.verdict.status === LIVE) counts.live += 1;
|
|
51
|
+
else if (doc.verdict.status === DRAFT) counts.draft += 1;
|
|
52
|
+
else if (doc.verdict.status === RETIRED) counts.retired += 1;
|
|
53
|
+
}
|
|
54
|
+
return verdict(counts.live > 0 ? ADOPTION.ADOPTED : ADOPTION.ADOPTING, counts);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const declineFingerprint = () => factFingerprint(DECLINE_FACT);
|
|
58
|
+
|
|
59
|
+
// True when the store's decline is recorded; the guarded reader's refusals propagate to the caller.
|
|
60
|
+
export const readDeclineAck = (root, deps = {}) => readAckValue(root, deps, ACKS_SPEC_ADOPTION_KEY) === declineFingerprint();
|
|
61
|
+
|
|
62
|
+
const plural = (n, noun) => `${n} ${noun}`;
|
|
63
|
+
|
|
64
|
+
// The one status line body, per state (the caller prefixes its own label).
|
|
65
|
+
export const describeAdoption = ({ state, live, draft, reason }, { declined = false } = {}) => {
|
|
66
|
+
const suffix = declined && state !== ADOPTION.ADOPTED ? ' — declined' : '';
|
|
67
|
+
if (state === ADOPTION.NOT_ADOPTED) return `not adopted${suffix}`;
|
|
68
|
+
if (state === ADOPTION.ADOPTING) return `adopting (${plural(draft, 'draft')})${suffix}`;
|
|
69
|
+
if (state === ADOPTION.ADOPTED) return `adopted (${plural(live, 'live')}, ${plural(draft, 'draft')})`;
|
|
70
|
+
return `could not be read — ${reason}`;
|
|
71
|
+
};
|
package/tools/spec-check.mjs
CHANGED
|
@@ -87,7 +87,7 @@ const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], docume
|
|
|
87
87
|
// Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
|
|
88
88
|
// bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
|
|
89
89
|
// read, so a directory that resolves outside the root is never opened through.
|
|
90
|
-
const readClosure = (closure, ctx) => {
|
|
90
|
+
export const readClosure = (closure, ctx) => {
|
|
91
91
|
const { io, at, rootReal, add } = ctx;
|
|
92
92
|
const docs = new Map();
|
|
93
93
|
for (const { path, roles } of closure) {
|
|
@@ -247,7 +247,7 @@ const judgeListing = (doc, docs, add) => {
|
|
|
247
247
|
// FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
|
|
248
248
|
// store would be the one answer this lane must never give. A directory is contained BEFORE it is
|
|
249
249
|
// listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
|
|
250
|
-
const walkStore = (ctx) => {
|
|
250
|
+
export const walkStore = (ctx) => {
|
|
251
251
|
const { io, at, rootReal, add } = ctx;
|
|
252
252
|
const found = [];
|
|
253
253
|
const stack = [STORE_DIR];
|
package/tools/view-model.mjs
CHANGED
|
@@ -114,6 +114,8 @@ const projectVm = (p) =>
|
|
|
114
114
|
deployed: p.deployed,
|
|
115
115
|
docsAi: p.docsAi,
|
|
116
116
|
adrLayout: p.adrLayout ?? null,
|
|
117
|
+
// null = an envelope predating the field (unknown), never a state.
|
|
118
|
+
specs: p.specs ?? null,
|
|
117
119
|
deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
|
|
118
120
|
visibility: visibilityVm(p.visibility),
|
|
119
121
|
settings: settingsVm(p.settings),
|