@sabaiway/agent-workflow-kit 14.36.0 → 14.38.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 +53 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +4 -2
- package/references/modes/upgrade.md +10 -8
- package/references/rules/project-layout.md +41 -0
- package/tools/ensure-configs.mjs +12 -6
- package/tools/ensure-vocabulary.mjs +2 -1
- package/tools/project-rules/index.mjs +4 -0
- package/tools/project-rules/rules-check.mjs +115 -0
- package/tools/project-rules/rules-cli.mjs +78 -0
- package/tools/project-rules/rules-ensure.mjs +216 -0
- package/tools/project-rules/rules-priors.mjs +12 -0
- package/tools/project-rules/rules-record.mjs +78 -0
- package/tools/project-rules/themes.mjs +19 -0
- package/tools/spec-adoption.mjs +1 -1
- package/tools/spec-coverage.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ 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
|
+
## 14.38.0 — the upgrade and bootstrap lay the project-layout rules into your project and keep the file agents read current; with your own local rules the agent does the merge (AD-190)
|
|
8
|
+
|
|
9
|
+
**`/agent-workflow-kit upgrade` and bootstrap now write the project-layout rules into `docs/ai/rules/`, keep
|
|
10
|
+
`docs/ai/rules/effective/project-layout.md` a copy of them, and record what it was built from, so
|
|
11
|
+
`rules-cli.mjs --check` answers `current`.** Where you keep your own rules in `docs/ai/rules/local/`, no code merges
|
|
12
|
+
them: the upgrade prints the merge the agent does and the command that records it.
|
|
13
|
+
|
|
14
|
+
- **A ninth upgrade ensure, `rules`,** runs after the spec layer and before the navigator, so the navigator lists the
|
|
15
|
+
files it lays. Its outcome is one of `seeded`, `refreshed`, `regenerated`, `pending`, `customized-preserved` or
|
|
16
|
+
`already-current`, or `failed` with its cause; `--dry-run` answers the `would-` form and writes nothing. Bootstrap
|
|
17
|
+
runs it as `ensure-configs.mjs --reconcile --only rules` in step 10, before the navigator re-run.
|
|
18
|
+
- **The kit file `docs/ai/rules/<theme>.md`** is created when absent, kept when it is the bundled template, refreshed
|
|
19
|
+
when it holds a body an earlier release shipped, and kept byte for byte when you edited it: the line then says your
|
|
20
|
+
own rules belong in `docs/ai/rules/local/<theme>.md`, where they win. The catalog of earlier bodies ships empty, so
|
|
21
|
+
nothing is refreshed yet.
|
|
22
|
+
- **With no local file** the effective file is rebuilt as the kit file's copy and its record entry is written; an
|
|
23
|
+
edited kit file with no frontmatter is kept and nothing else is written for that theme.
|
|
24
|
+
- **With a local file** nothing is written to the effective file or the record; until the merge is recorded, the line
|
|
25
|
+
starts `merge needed — `, states how to merge (by meaning, the local file winning a conflict, the frontmatter kept,
|
|
26
|
+
the user shown what changed) and prints the exact `rules-cli.mjs --record` command to run after it.
|
|
27
|
+
- **A `docs/ai/rules/effective/sources.json` that reads as UTF-8 text but is not JSON of the record's schema** is now
|
|
28
|
+
replaced by the record writer instead of stopping it, in `--record` and in the upgrade alike; a directory or a
|
|
29
|
+
symlink on its path still stops it, writing nothing.
|
|
30
|
+
|
|
31
|
+
**Upgrade note.** No step: the next upgrade lays the theme. If it prints `merge needed`, the agent merges, shows you
|
|
32
|
+
what changed and runs the printed command.
|
|
33
|
+
|
|
34
|
+
## 14.37.0 — the first project rules theme, project layout, ships with the writer of a record of what each effective rules file was built from and a read-only check; nothing is laid into a project yet (AD-189)
|
|
35
|
+
|
|
36
|
+
**The kit now carries default project rules as thematic prose files, starting with project layout, and a command
|
|
37
|
+
line that checks the file agents read still stands for its sources.** This release ships the files' shape, the theme
|
|
38
|
+
registry, the record's writer and the check; the upgrade op that lays the theme into your project comes in a later release,
|
|
39
|
+
so this one writes no rules file into your project.
|
|
40
|
+
|
|
41
|
+
- **Four files per theme.** `docs/ai/rules/<theme>.md` is the kit's rules, `docs/ai/rules/local/<theme>.md` is
|
|
42
|
+
yours and wins, `docs/ai/rules/effective/<theme>.md` is what agents read, and
|
|
43
|
+
`docs/ai/rules/effective/sources.json` records, per theme, the sha256 of the kit, local (or `absent`) and effective
|
|
44
|
+
files. Rules are plain prose with no keys; with no local file the effective file equals the kit file, with one an
|
|
45
|
+
agent merges the two by meaning once. No code merges two texts.
|
|
46
|
+
- **The project-layout template** (`references/rules/project-layout.md`): a module is a directory holding its code,
|
|
47
|
+
tests, mocks, fixtures and command-line entry; its index exports only what is used outside; no README repeats its
|
|
48
|
+
spec; modules nest by meaning, never by technical layer; a spec's path mirrors its module's; old code keeps its
|
|
49
|
+
place as a recorded exception.
|
|
50
|
+
- **`node tools/project-rules/rules-cli.mjs --check [--cwd <project>]`** prints one line per theme — `absent`,
|
|
51
|
+
`current`, `drift` naming every moved file, or `record` with its state — and writes nothing. It exits 0 when every
|
|
52
|
+
theme is `current` or `absent`, 1 otherwise, every stop line saying `run /agent-workflow-kit upgrade`; 2 is usage.
|
|
53
|
+
It judges only against the record, never against the bundled template.
|
|
54
|
+
- **`--record --theme <name> --kit <sha256> --local <sha256|absent> --effective <sha256>`** is the merging agent's
|
|
55
|
+
call: it writes the theme's entry only when the files still hash to the passed digests and the effective file fits
|
|
56
|
+
the merge, and otherwise exits 1 naming the failed condition, writing nothing.
|
|
57
|
+
|
|
58
|
+
**Upgrade note.** No step: the module ships, and no rules file reaches a project until the later upgrade op.
|
|
59
|
+
|
|
7
60
|
## 14.36.0 — the review lens runs on opus/high by default and its model is a setting; the task tier offer proposes the executor, not codex (AD-188)
|
|
8
61
|
|
|
9
62
|
**The bundled review lens now takes its model and effort from `docs/ai/vehicles.json`, opus and high unless you set
|
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". New to the epic, story and task tier? Run `/agent-workflow-kit tier`. A typed decision with a confidence from Jev? Run `/agent-workflow-kit jev`.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '14.
|
|
6
|
+
version: '14.38.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": "14.
|
|
3
|
+
"version": "14.38.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",
|
|
@@ -34,8 +34,10 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
34
34
|
- A **leftover machine-wide ignore block** from an older deployment is kept and reported (harmless — the project-local rules win); the advisor's `footprint-global` item of step 11 removes it on the user's yes after its check, which prints each line it removes — another of the user's repos may rely on the same machine-wide rules.
|
|
35
35
|
- Report the result plainly (what is now hidden). **Windows is supported.**
|
|
36
36
|
- **Do not edit `package.json`** in hidden mode — a tracked change leaks the whole system.
|
|
37
|
-
10. **Stamp the deployment lineage.**
|
|
38
|
-
|
|
37
|
+
10. **Stamp the deployment lineage.** In both paths, delegated and fallback, **first lay down the
|
|
38
|
+
project rules** — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only rules --cwd <project>`,
|
|
39
|
+
relaying its line — **then re-run the step-7 navigator finalizer**
|
|
40
|
+
(`--ensure-index`) whenever a later step touched `docs/ai` — the rules run and the reconciles that follow the
|
|
39
41
|
template copy do (the agent-rules lens,
|
|
40
42
|
`${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md`); it is idempotent, so an
|
|
41
43
|
untouched tree simply reports `already-current`. Then write the **deployment-lineage head** into
|
|
@@ -33,21 +33,23 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
33
33
|
|
|
34
34
|
**`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; **a leftover machine-wide ignore block is kept + reported — the advisor's `footprint-global` item removes it**). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
|
|
35
35
|
|
|
36
|
-
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all
|
|
36
|
+
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all nine** ensures — orchestration config · gate declaration · autonomy declaration · vehicles declaration · executor check · enforcement scripts · spec layer · project rules · 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); a refresh-class op rewrites only what it owns — the outcome tokens by ensure, below, name each arm. 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, autonomy and vehicles → `seeded` / `already-present`; executor → `not-placed` / `already-current` / `pending`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node-evidence`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node-evidence`; rules → `seeded` / `refreshed` / `regenerated` / `pending` / `customized-preserved` / `already-current`; index → `regenerated` / `already-current` / `customized-preserved`; 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`, `vehicle-settings-unreadable`, `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 — once row 3's replace ask is answered when a specs replace line printed, then as that ask says** — 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 nine reach an equal-head deployment **without a lineage-head bump or a migration file**.
|
|
37
37
|
|
|
38
|
-
**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 —
|
|
38
|
+
**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 — `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` decide it (CRLF/whitespace normalized first; 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.)
|
|
39
39
|
|
|
40
|
-
**What the gate-declaration ensure does.** `docs/ai/gates.json` must exist: created from `${CLAUDE_SKILL_DIR}/references/templates/gates.json` if missing — the kit's OWN template twin, so a memory substrate predating the gates feature never silently loses it; **an existing file is preserved byte-for-byte** (a
|
|
40
|
+
**What the gate-declaration ensure does.** `docs/ai/gates.json` must exist: created from `${CLAUDE_SKILL_DIR}/references/templates/gates.json` if missing — the kit's OWN template twin, so a memory substrate predating the gates feature never silently loses it; **an existing file is preserved byte-for-byte** (a declared gate matrix is authored content; no note-refresh here).
|
|
41
41
|
|
|
42
42
|
**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).
|
|
43
43
|
|
|
44
44
|
**What the executor ensure does.** It never writes: a placed `.claude/agents/executor.md` whose bytes differ from the body derived from `docs/ai/vehicles.json` is `pending` — the advisor's `executor-vehicle.differing` item re-derives it on the user's yes — and an absent one is `not-placed` (the agents mode places it on consent).
|
|
45
45
|
|
|
46
|
-
**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`,
|
|
46
|
+
**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`, writing nothing. 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; only when every probe answers absent does it report `skipped-no-node-evidence`, a line naming every probe it checked, while the 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 deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook is next refreshed (the `enforcement` check line and apply, by the consent route, copy nothing and re-run the installer, which refuses a non-marker hook as always); an OLD hook without the line is safe — the decisions gate is simply not enforced yet.
|
|
47
47
|
|
|
48
|
-
**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
|
|
48
|
+
**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 a Node project's hook fails its 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); under a hook module that is not the bundled one, a write the project's own checker rejects is put back → `customized-preserved`. 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: an untouched tree changes nothing.
|
|
49
49
|
|
|
50
|
-
**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`; edited files are replaced only on row 3's yes. 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`)
|
|
50
|
+
**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`; edited files are replaced only on row 3's yes. 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`). 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 the agent records), `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.
|
|
51
|
+
|
|
52
|
+
**What the project-rules ensure does.** Each theme's kit file `docs/ai/rules/<theme>.md` is seeded, refreshed only from a body a release shipped (an edit is kept → `customized-preserved`), and with no local file its effective copy and record are rebuilt. With a local file it writes neither: `pending` prints the merge to do and its record command — do the merge, show the user what changed, then run that command.
|
|
51
53
|
|
|
52
54
|
**`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. Of step 3's run-list, only this migration and `tools/checker-gates.mjs` under the `tier` ask change an existing gates.json (the FILL preview runs at init).
|
|
53
55
|
|
|
@@ -111,7 +113,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
111
113
|
**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 changes nothing, 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.
|
|
112
114
|
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:
|
|
113
115
|
- **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.
|
|
114
|
-
- **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 **
|
|
116
|
+
- **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 **nine project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (the ensures and 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) and the **story-sessions section** outcome (its own set: refreshed / already current / custom edit preserved + note / section absent — noted / over the file's line cap — refused); the **full-flow offer** (`tier`) lines; 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). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
115
117
|
- **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*.
|
|
116
118
|
- **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 route: the user picks items in plain language; surface each picked item's posture note and get the explicit confirm; run its lane's rendered lines — chat: the one-liner; harness: the check line, then on the yes to its result the apply line; user: the user's own step — per the full consent route 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).
|
|
117
119
|
- **Recommendations lanes — both exits.** The three lanes (`chat`, `harness`, `user`) and the consent route live in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`; `init` stays a route the user may take.
|
|
@@ -121,4 +123,4 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
121
123
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
122
124
|
6. **Collect the migration answers FIRST, then apply.** Run `node ${CLAUDE_SKILL_DIR}/tools/migration-notes.mjs --cwd <project>` and relay its stdout lines verbatim, in order; a non-zero exit relays its one named line and STOPs, except `stamp-absent`, which routes back to step 1. Then run the preview `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`. If its lines say both blocks are missing (pre-1.1.0), ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks; a single missing block keeps its single ask (step 7). Then apply exactly the notes the tool listed, in its order ("only those newer than the project's stamp" is why, never a second selection). Migrations are **idempotent** — safe to re-run.
|
|
123
125
|
7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. For a block the preview names as needing its answer, **ask the user their conversational language** (`--language`) or **whether the agent may attribute work to itself / AI** (`--attribution`, defaulting to `off`), never re-asking a step-6 answer; then re-run the preview with `--apply` and the answers, relaying its lines. Exit 1 `answer-missing`: ask exactly what its lines name and re-run `--apply` once; a second one STOPs. Any other exit 1 is a named refusal: relay it with its recovery (`cap`: trim `AGENTS.md`, re-run) and STOP before the finalizer and the stamp. **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree changes nothing; a failure STOPs this upgrade like any other ensure).
|
|
124
|
-
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **
|
|
126
|
+
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **nine project-configuration ensure** (`configs`) lines (enumerated in step 3) — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** + **story-sessions section** outcomes (same outcome sets as step 4), the **full-flow offer** (`tier`) outcome, and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: 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, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent route (per picked item: posture note → explicit confirm → chat: the one-liner; harness: the check line, then on the yes to its result the apply line; user: the user's own step — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: rules
|
|
3
|
+
lastUpdated: 2026-10-18
|
|
4
|
+
scope: permanent
|
|
5
|
+
staleAfter: never
|
|
6
|
+
owner: kit
|
|
7
|
+
maxLines: 80
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Project layout
|
|
11
|
+
|
|
12
|
+
Where a file goes is decided by what it means, never by where there is room.
|
|
13
|
+
|
|
14
|
+
## A module is a directory
|
|
15
|
+
|
|
16
|
+
A module is a directory holding everything that belongs to it: its code, its tests, its mocks, its
|
|
17
|
+
fixtures and its command-line entry. Tests live inside the module, next to the code they check, never
|
|
18
|
+
in a separate tree.
|
|
19
|
+
|
|
20
|
+
## The index is the one door
|
|
21
|
+
|
|
22
|
+
The module's own index file, the one its language uses for that purpose, exports only what is used
|
|
23
|
+
outside the module. Nothing outside reaches past the index into a file of the module.
|
|
24
|
+
|
|
25
|
+
## The spec is the description
|
|
26
|
+
|
|
27
|
+
No README repeats the module's spec. The spec describes the module; a README that restates it drifts
|
|
28
|
+
from it.
|
|
29
|
+
|
|
30
|
+
## Modules nest by meaning
|
|
31
|
+
|
|
32
|
+
Modules nest by meaning: a slice holds its modules, and a shared layer holds what several slices use.
|
|
33
|
+
Modules never nest by technical layer, so there is no directory of all controllers or all helpers.
|
|
34
|
+
|
|
35
|
+
## The spec mirrors the module
|
|
36
|
+
|
|
37
|
+
A spec's path mirrors its module's path, so the name of one gives the place of the other.
|
|
38
|
+
|
|
39
|
+
## Old code
|
|
40
|
+
|
|
41
|
+
Old code keeps its place as a recorded exception. New and touched code follows these rules.
|
package/tools/ensure-configs.mjs
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
|
|
11
11
|
// + docs/ai/specs/index.md a shipped prior body, an edited one replaced only on the
|
|
12
12
|
// user's yes; store root seeded behind a current checker
|
|
13
|
+
// rules docs/ai/rules/<theme>.md the kit file seeded, or refreshed on a shipped prior body; with
|
|
14
|
+
// + effective/ + sources.json no local file the effective copy and its record rebuilt; with
|
|
15
|
+
// one the merge an agent does is named, never done
|
|
13
16
|
// index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact); under
|
|
14
17
|
// a project hook module that is not the bundled one, a write
|
|
15
18
|
// the project's own checker rejects is put back
|
|
@@ -40,12 +43,13 @@ import { isDirectRun } from './direct-run.mjs';
|
|
|
40
43
|
import { ensureExecutor } from './ensure-executor.mjs';
|
|
41
44
|
import { ENSURE_OPS, OWN_IMPLEMENTATIONS, failedOutcome } from './ensure-ops.mjs';
|
|
42
45
|
import { ensureSpecs } from './ensure-specs.mjs';
|
|
46
|
+
import { ensureRules } from './project-rules/index.mjs';
|
|
43
47
|
|
|
44
48
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
45
49
|
|
|
46
50
|
// The op table this CLI walks — name → implementation, in ENSURE_OPS order. Composed HERE (the one
|
|
47
|
-
// consumer) because the executor
|
|
48
|
-
export const ENSURE_IMPLEMENTATIONS = Object.freeze({ ...OWN_IMPLEMENTATIONS, executor: ensureExecutor, specs: ensureSpecs });
|
|
51
|
+
// consumer) because the executor, spec-layer and rules ensures import ensure-ops.mjs for their outcome door and probes.
|
|
52
|
+
export const ENSURE_IMPLEMENTATIONS = Object.freeze({ ...OWN_IMPLEMENTATIONS, executor: ensureExecutor, specs: ensureSpecs, rules: ensureRules });
|
|
49
53
|
|
|
50
54
|
const EXIT_OK = 0;
|
|
51
55
|
const EXIT_FAILED = 1;
|
|
@@ -63,13 +67,13 @@ const REPLACE_WITH_ONLY = '--replace-custom runs the spec layer and then the nav
|
|
|
63
67
|
const REPLACE_WITH_DRY_RUN = "--replace-custom writes on the user's yes, so it cannot be combined with --dry-run — nothing was run";
|
|
64
68
|
const INDEX_NOT_RUN = 'the navigator step did not run: a replace run runs it only after the spec layer succeeds';
|
|
65
69
|
|
|
66
|
-
const HELP = `ensure-configs — the
|
|
70
|
+
const HELP = `ensure-configs — the nine stamp-independent upgrade ensures, as ONE command.
|
|
67
71
|
|
|
68
72
|
Usage:
|
|
69
73
|
node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
|
|
70
74
|
node ensure-configs.mjs --reconcile --replace-custom <digest> [--cwd <project>]
|
|
71
75
|
|
|
72
|
-
--reconcile required — run the
|
|
76
|
+
--reconcile required — run the nine ensures (${ENSURE_OPS.join(', ')})
|
|
73
77
|
--dry-run report what each ensure WOULD do; write nothing
|
|
74
78
|
--only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
|
|
75
79
|
--replace-custom <digest>
|
|
@@ -80,11 +84,13 @@ Usage:
|
|
|
80
84
|
--help, -h this help
|
|
81
85
|
|
|
82
86
|
Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
|
|
83
|
-
refreshed in place.
|
|
87
|
+
refreshed in place. Four ops refresh instead: the orchestration onboarding note, only while it still
|
|
84
88
|
matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
|
|
85
89
|
and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
|
|
86
90
|
preserved, the writes that depend on it — the spec store root included — are withheld behind it,
|
|
87
|
-
and ONE printed replace line replaces every edited file only on the user's yes);
|
|
91
|
+
and ONE printed replace line replaces every edited file only on the user's yes); the project rules
|
|
92
|
+
kit file, only while its bytes are a body a release shipped (an edited one is kept), with the effective
|
|
93
|
+
file rebuilt as its copy and recorded where no local file exists; and the navigator index — a GENERATED artifact, regenerated
|
|
88
94
|
whenever it is missing or stale; where a module the project's hook loads is not the bundled one,
|
|
89
95
|
the project's own checker judges the navigator and a write it rejects is put back byte for byte.
|
|
90
96
|
The executor ensure writes nothing: a placed .claude/agents/executor.md whose bytes differ from the
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// `specs` sits after `scripts` (its reader pair is a scripts/ seed too) and BEFORE `index`: the store
|
|
12
12
|
// root it seeds is a docs/ai file the navigator must count.
|
|
13
13
|
// `executor` follows `vehicles`: its body is derived from the settings file that op seeds.
|
|
14
|
-
|
|
14
|
+
// `rules` follows `specs` and precedes `index`: the rules files it lays are docs/ai files the navigator counts.
|
|
15
|
+
export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'vehicles', 'executor', 'scripts', 'specs', 'rules', 'index']);
|
|
15
16
|
|
|
16
17
|
// Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
|
|
17
18
|
// walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The read-only check: one verdict per registry theme, judged only against the record. It imports no
|
|
2
|
+
// writer and reads every file through readUnder, so no symlink inside the root is followed. Contract:
|
|
3
|
+
// docs/ai/specs/kit/project-rules/rules-check.md.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { lstatSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { readFileBytesNoFollow } from '../fs-read-nofollow.mjs';
|
|
9
|
+
import { RECORD_PATH, THEMES, themePaths } from './themes.mjs';
|
|
10
|
+
|
|
11
|
+
const UPGRADE = 'run /agent-workflow-kit upgrade';
|
|
12
|
+
const EDIT_NOTE = 'move your edit to local/, a rebuild drops it';
|
|
13
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
14
|
+
const ROLES = ['kit', 'local', 'effective'];
|
|
15
|
+
|
|
16
|
+
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
|
|
18
|
+
const isDigest = (value) => typeof value === 'string' && DIGEST.test(value);
|
|
19
|
+
|
|
20
|
+
// readUnder(root, rel, deps?) → a readFileBytesNoFollow outcome for root/rel, or foreign or error
|
|
21
|
+
// without a read when a directory on rel's path inside root is a symlink, not a directory, or cannot be
|
|
22
|
+
// inspected; a missing directory ends the walk and the leaf then reads absent.
|
|
23
|
+
export const readUnder = (root, rel, deps = {}) => {
|
|
24
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
25
|
+
const parts = rel.split('/');
|
|
26
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
27
|
+
const prefix = parts.slice(0, i).join('/');
|
|
28
|
+
let st;
|
|
29
|
+
try {
|
|
30
|
+
st = lstat(join(root, prefix));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err && err.code === 'ENOENT') break;
|
|
33
|
+
return { outcome: 'error', code: `${prefix} cannot be inspected (${err?.code ?? err})` };
|
|
34
|
+
}
|
|
35
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return { outcome: 'foreign', code: `${prefix} is a symlink or not a directory` };
|
|
36
|
+
}
|
|
37
|
+
return (deps.read ?? readFileBytesNoFollow)(join(root, rel));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
const sameKeys = (value, keys) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
|
42
|
+
|
|
43
|
+
// parseRecord(text, themes?) → { ok: true, record } | { ok: false, reason }: the record's one schema reader.
|
|
44
|
+
export const parseRecord = (text, themes = THEMES) => {
|
|
45
|
+
const bad = (reason) => ({ ok: false, reason });
|
|
46
|
+
let record;
|
|
47
|
+
try {
|
|
48
|
+
record = JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
return bad('not JSON');
|
|
51
|
+
}
|
|
52
|
+
if (!isObject(record) || !sameKeys(record, ['schema', 'themes'])) return bad('the keys are not exactly schema and themes');
|
|
53
|
+
if (record.schema !== 1) return bad(`schema ${JSON.stringify(record.schema)} is not 1`);
|
|
54
|
+
if (!isObject(record.themes)) return bad('themes is not an object');
|
|
55
|
+
const names = new Set(themes.map((theme) => theme.name));
|
|
56
|
+
for (const [name, entry] of Object.entries(record.themes)) {
|
|
57
|
+
if (!names.has(name)) return bad(`theme ${name} is not in the registry`);
|
|
58
|
+
if (!isObject(entry) || !sameKeys(entry, ROLES)) return bad(`the entry of ${name} does not hold exactly kit, local and effective`);
|
|
59
|
+
if (!isDigest(entry.kit) || !isDigest(entry.effective)) return bad(`the entry of ${name} holds a value that is not a sha256`);
|
|
60
|
+
if (entry.local !== 'absent' && !isDigest(entry.local)) return bad(`the local of ${name} is neither a sha256 nor absent`);
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, record };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// readRecord(outcome, themes) → { ok: true, themes } | { ok: false, reason }: an absent record holds no entry.
|
|
66
|
+
export const readRecord = (outcome, themes = THEMES) => {
|
|
67
|
+
if (outcome.outcome === 'absent') return { ok: true, themes: {} };
|
|
68
|
+
if (outcome.outcome !== 'ok') return { ok: false, reason: 'it is not a regular file this check can read' };
|
|
69
|
+
const parsed = parseRecord(outcome.bytes.toString('utf8'), themes);
|
|
70
|
+
return parsed.ok ? { ok: true, themes: parsed.record.themes } : parsed;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const recordVerdict = (theme, state, detail) =>
|
|
74
|
+
({ theme, verdict: 'record', state, line: `${theme}: record (${state}) — ${detail} — ${UPGRADE}` });
|
|
75
|
+
|
|
76
|
+
const judgeTheme = (theme, paths, files, record) => {
|
|
77
|
+
if (!record.ok) return recordVerdict(theme, 'unreadable-record', `${paths.record} is unreadable: ${record.reason}`);
|
|
78
|
+
const unreadable = ROLES.find((role) => !['ok', 'absent'].includes(files[role].outcome));
|
|
79
|
+
if (unreadable) return recordVerdict(theme, 'unreadable-file', `${paths[unreadable]} is not a regular file this check can read`);
|
|
80
|
+
const present = (role) => files[role].outcome === 'ok';
|
|
81
|
+
const entry = record.themes[theme];
|
|
82
|
+
if (entry === undefined) {
|
|
83
|
+
const existing = ROLES.find(present);
|
|
84
|
+
if (existing === undefined) return { theme, verdict: 'absent', line: `${theme}: absent` };
|
|
85
|
+
return recordVerdict(theme, 'no-entry', `no record entry while ${paths[existing]} exists`);
|
|
86
|
+
}
|
|
87
|
+
const missing = ['kit', 'effective'].find((role) => !present(role));
|
|
88
|
+
if (missing) return recordVerdict(theme, 'missing-file', `${paths[missing]} is missing under its record entry`);
|
|
89
|
+
const differs = {
|
|
90
|
+
kit: digestOf(files.kit.bytes) !== entry.kit,
|
|
91
|
+
local: present('local') ? digestOf(files.local.bytes) !== entry.local : entry.local !== 'absent',
|
|
92
|
+
effective: digestOf(files.effective.bytes) !== entry.effective,
|
|
93
|
+
};
|
|
94
|
+
const moved = ROLES.filter((role) => differs[role])
|
|
95
|
+
.map((role) => (role === 'effective' ? { role, file: paths[role], note: EDIT_NOTE } : { role, file: paths[role] }));
|
|
96
|
+
if (moved.length > 0) {
|
|
97
|
+
const named = moved.map(({ file, note }) => (note ? `${file} (${note})` : file)).join('; ');
|
|
98
|
+
return { theme, verdict: 'drift', moved, line: `${theme}: drift — moved ${named} — ${UPGRADE}` };
|
|
99
|
+
}
|
|
100
|
+
if (!present('local') && !files.kit.bytes.equals(files.effective.bytes)) {
|
|
101
|
+
return recordVerdict(theme, 'kit-effective-differ', `no local file, yet ${paths.effective} differs from ${paths.kit}`);
|
|
102
|
+
}
|
|
103
|
+
return { theme, verdict: 'current', line: `${theme}: current` };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// checkRules(root, deps?) → [{ theme, verdict, line, moved?, state? }] in registry order; writes nothing.
|
|
107
|
+
export const checkRules = (root, deps = {}) => {
|
|
108
|
+
const themes = deps.themes ?? THEMES;
|
|
109
|
+
const record = readRecord(readUnder(root, RECORD_PATH, deps), themes);
|
|
110
|
+
return themes.map(({ name }) => {
|
|
111
|
+
const paths = themePaths(name);
|
|
112
|
+
const files = Object.fromEntries(ROLES.map((role) => [role, readUnder(root, paths[role], deps)]));
|
|
113
|
+
return judgeTheme(name, paths, files, record);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The command line over the read-only check and the record writer. Contract:
|
|
3
|
+
// docs/ai/specs/kit/project-rules/rules-cli.md. Exit codes: 0 accept; 1 a stop or a refused record; 2 usage.
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { isDirectRun } from '../direct-run.mjs';
|
|
7
|
+
import { checkRules } from './rules-check.mjs';
|
|
8
|
+
import { writeRecord } from './rules-record.mjs';
|
|
9
|
+
|
|
10
|
+
const USAGE = `project-rules — the effective rule files still stand for their sources.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
node rules-cli.mjs --check [--cwd <project>]
|
|
14
|
+
node rules-cli.mjs --record --theme <name> --kit <sha256> --local <sha256|absent> --effective <sha256> [--cwd <project>]
|
|
15
|
+
|
|
16
|
+
--check one line per theme: current, absent, drift or record; writes nothing.
|
|
17
|
+
--record the merging agent's call: records the digests of the inputs it read and of the file it wrote.
|
|
18
|
+
|
|
19
|
+
Exit codes: 0 accept; 1 a stop or a refused record; 2 usage.`;
|
|
20
|
+
|
|
21
|
+
const MODES = { '--check': [], '--record': ['--theme', '--kit', '--local', '--effective'] };
|
|
22
|
+
const VALUED = new Set(['--cwd', '--theme', '--kit', '--local', '--effective']);
|
|
23
|
+
|
|
24
|
+
// parseArgs(argv) → { mode, options } | { help: true } | { error }; reads nothing.
|
|
25
|
+
const parseArgs = (argv) => {
|
|
26
|
+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { help: true };
|
|
27
|
+
let mode = null;
|
|
28
|
+
const options = {};
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
const arg = argv[i];
|
|
31
|
+
if (Object.hasOwn(MODES, arg)) {
|
|
32
|
+
if (mode !== null) return { error: `${mode} and ${arg} are two modes; one run takes exactly one` };
|
|
33
|
+
mode = arg;
|
|
34
|
+
} else if (VALUED.has(arg)) {
|
|
35
|
+
const value = argv[i + 1];
|
|
36
|
+
if (value === undefined || value.startsWith('--')) return { error: `${arg} needs a value` };
|
|
37
|
+
if (arg in options) return { error: `${arg} is given twice` };
|
|
38
|
+
options[arg] = value;
|
|
39
|
+
i += 1;
|
|
40
|
+
} else {
|
|
41
|
+
return { error: `unknown argument: ${arg}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (mode === null) return { error: 'no mode: give --check or --record' };
|
|
45
|
+
const allowed = new Set(['--cwd', ...MODES[mode]]);
|
|
46
|
+
const extra = Object.keys(options).find((name) => !allowed.has(name));
|
|
47
|
+
if (extra) return { error: `${mode} does not take ${extra}` };
|
|
48
|
+
const absent = MODES[mode].find((name) => !(name in options));
|
|
49
|
+
if (absent) return { error: `${mode} needs ${absent}` };
|
|
50
|
+
return { mode, options };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const lines = (list) => (list.length === 0 ? '' : `${list.join('\n')}\n`);
|
|
54
|
+
|
|
55
|
+
// main(argv, ctx?) → { code, stdout, stderr }; never exits the process.
|
|
56
|
+
export const main = (argv, ctx = {}) => {
|
|
57
|
+
const parsed = parseArgs(argv);
|
|
58
|
+
if (parsed.help) return { code: 0, stdout: `${USAGE}\n`, stderr: '' };
|
|
59
|
+
if (parsed.error) return { code: 2, stdout: '', stderr: `project-rules: ${parsed.error}\n\n${USAGE}\n` };
|
|
60
|
+
const root = resolve(ctx.cwd ?? process.cwd(), parsed.options['--cwd'] ?? '.');
|
|
61
|
+
const deps = ctx.deps ?? {};
|
|
62
|
+
if (parsed.mode === '--check') {
|
|
63
|
+
const verdicts = checkRules(root, deps);
|
|
64
|
+
const stops = verdicts.filter(({ verdict }) => verdict !== 'current' && verdict !== 'absent');
|
|
65
|
+
return { code: stops.length === 0 ? 0 : 1, stdout: lines(verdicts.map(({ line }) => line)), stderr: '' };
|
|
66
|
+
}
|
|
67
|
+
const { '--theme': theme, '--kit': kit, '--local': local, '--effective': effective } = parsed.options;
|
|
68
|
+
const result = writeRecord(root, theme, { kit, local, effective }, deps);
|
|
69
|
+
if (!result.ok) return { code: 1, stdout: '', stderr: `project-rules: --record refused (${result.condition}) — ${result.message}; nothing written\n` };
|
|
70
|
+
return { code: 0, stdout: `project-rules: recorded ${theme} in ${result.path}\n`, stderr: '' };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (isDirectRun(import.meta.url)) {
|
|
74
|
+
const { code, stdout, stderr } = main(process.argv.slice(2));
|
|
75
|
+
process.stdout.write(stdout);
|
|
76
|
+
process.stderr.write(stderr);
|
|
77
|
+
process.exitCode = code;
|
|
78
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// The upgrade op `rules`: it lays every registry theme's kit file, keeps the effective file a copy of
|
|
2
|
+
// the kit file where no local file exists, and names the merge an agent does where one does. It never
|
|
3
|
+
// writes a local file and never merges two texts. Contract: docs/ai/specs/kit/project-rules/rules-ensure.md.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { writeDocsAiFileAtomic, writeProjectFileCreateOnly } from '../atomic-write.mjs';
|
|
8
|
+
import { composeFailure, composeOutcome, tmpNote } from '../ensure-ops.mjs';
|
|
9
|
+
import { shellQuoteArg } from '../repo-lex.mjs';
|
|
10
|
+
import { parseFrontmatter } from '../../references/scripts/check-docs-size.mjs';
|
|
11
|
+
import { digestOf, readRecord, readUnder } from './rules-check.mjs';
|
|
12
|
+
import { writeRecord } from './rules-record.mjs';
|
|
13
|
+
import { RULES_PRIORS, isKnownPrior } from './rules-priors.mjs';
|
|
14
|
+
import { RECORD_PATH, THEMES, themePaths } from './themes.mjs';
|
|
15
|
+
|
|
16
|
+
const OP = 'rules';
|
|
17
|
+
const FILE_ROLES = ['kit', 'local', 'effective'];
|
|
18
|
+
const causeOf = (err) => String((err && err.message) || err);
|
|
19
|
+
|
|
20
|
+
// ── the survey: the template and every path read once, nothing written ───────────────────────────
|
|
21
|
+
|
|
22
|
+
const readTemplate = (readFile, path) => {
|
|
23
|
+
try {
|
|
24
|
+
return { bytes: Buffer.from(readFile(path)) };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return { error: causeOf(err) };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const unreadable = (rel, read) => {
|
|
31
|
+
if (read.outcome === 'foreign') {
|
|
32
|
+
return composeFailure(OP, 'wrong-node-kind', `${rel}: ${read.code ?? `it is a ${read.className}, not a regular file`} — nothing was read or written; resolve it by hand, then re-run`);
|
|
33
|
+
}
|
|
34
|
+
if (read.outcome === 'ok' || read.outcome === 'absent') return null;
|
|
35
|
+
return composeFailure(OP, 'unexpected-error', `${rel}: could not be read (${read.code ?? read.outcome}) — nothing was written`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const survey = ({ themes, cwd, kitRoot, readFile, deps }) => {
|
|
39
|
+
const surveyed = [];
|
|
40
|
+
for (const theme of themes) {
|
|
41
|
+
const paths = themePaths(theme.name);
|
|
42
|
+
const template = readTemplate(readFile, join(kitRoot, theme.template));
|
|
43
|
+
if (template.error !== undefined) {
|
|
44
|
+
return { failure: composeFailure(OP, 'template-unreadable', `${paths.kit}: the bundled template ${theme.template} could not be read, so nothing was written — reinstall the kit. ${template.error}`) };
|
|
45
|
+
}
|
|
46
|
+
const reads = Object.fromEntries(FILE_ROLES.map((role) => [role, readUnder(cwd, paths[role], deps)]));
|
|
47
|
+
const failure = FILE_ROLES.map((role) => unreadable(paths[role], reads[role])).find(Boolean);
|
|
48
|
+
if (failure) return { failure };
|
|
49
|
+
surveyed.push({ theme, paths, template: template.bytes, reads });
|
|
50
|
+
}
|
|
51
|
+
const recordRead = readUnder(cwd, RECORD_PATH, deps);
|
|
52
|
+
const failure = unreadable(RECORD_PATH, recordRead);
|
|
53
|
+
return failure ? { failure } : { surveyed, record: readRecord(recordRead, themes) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── the decision per theme (pure over the survey) ────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const classifyKit = (name, kit, template, priors) => {
|
|
59
|
+
if (kit.outcome === 'absent') return 'absent';
|
|
60
|
+
if (kit.bytes.equals(template)) return 'current';
|
|
61
|
+
return isKnownPrior(name, kit.bytes, priors) ? 'prior' : 'custom';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const planTheme = ({ theme, paths, template, reads }, record, priors) => {
|
|
65
|
+
const { kit, local, effective } = reads;
|
|
66
|
+
const kitState = classifyKit(theme.name, kit, template, priors);
|
|
67
|
+
const kitBytes = kitState === 'absent' || kitState === 'prior' ? template : kit.bytes;
|
|
68
|
+
const kitWrite = { absent: 'seed', prior: 'refresh' }[kitState] ?? null;
|
|
69
|
+
const entry = record.ok ? record.themes[theme.name] : undefined;
|
|
70
|
+
const base = { name: theme.name, paths, kitState, kitBytes, kitWrite, effectiveWrite: null, recordWrite: false };
|
|
71
|
+
if (local.outcome === 'ok') {
|
|
72
|
+
const merged = entry !== undefined && effective.outcome === 'ok' && entry.kit === digestOf(kitBytes)
|
|
73
|
+
&& entry.local === digestOf(local.bytes) && entry.effective === digestOf(effective.bytes);
|
|
74
|
+
return { ...base, mode: merged ? 'merged' : 'pending', localDigest: digestOf(local.bytes) };
|
|
75
|
+
}
|
|
76
|
+
if (kitState === 'custom' && parseFrontmatter(kitBytes.toString('utf8')) === null) return { ...base, mode: 'unvouched' };
|
|
77
|
+
const effectiveWrite = effective.outcome === 'absent' ? 'seed' : effective.bytes.equals(kitBytes) ? null : 'rebuild';
|
|
78
|
+
const passed = { kit: digestOf(kitBytes), local: 'absent', effective: digestOf(kitBytes) };
|
|
79
|
+
const recorded = entry !== undefined && entry.kit === passed.kit && entry.local === 'absent' && entry.effective === passed.effective;
|
|
80
|
+
const droppedEdit = effectiveWrite === 'rebuild' && entry !== undefined && entry.effective !== digestOf(effective.bytes);
|
|
81
|
+
return { ...base, mode: 'copy', effectiveWrite, recordWrite: effectiveWrite !== null || !recorded, passed, droppedEdit };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── the lines, composed from what happened ───────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const KIT_LINES = {
|
|
87
|
+
absent: { written: 'created from the bundled template', stood: 'appeared while this run was creating it and holds the bundled template — it stands', would: 'absent — would be created from the bundled template' },
|
|
88
|
+
prior: { written: 'held the body an earlier release shipped — refreshed to the bundled template', would: 'holds the body an earlier release shipped — would be refreshed to the bundled template' },
|
|
89
|
+
};
|
|
90
|
+
const kitLine = (plan, fate) => {
|
|
91
|
+
const { kit, local } = plan.paths;
|
|
92
|
+
if (plan.kitState === 'current') return `${kit}: already the bundled template — nothing written`;
|
|
93
|
+
if (plan.mode === 'unvouched') return `${kit}: carries your edit with no frontmatter — kept byte for byte, and nothing else was written for this theme, because the record writer cannot vouch for a file without frontmatter; your own rules belong in ${local}, where they win`;
|
|
94
|
+
if (plan.kitState === 'custom') return `${kit}: carries your edit — kept byte for byte; your own rules belong in ${local}, where they win, and the kit's updates to this file wait while it carries the edit`;
|
|
95
|
+
return `${kit}: ${KIT_LINES[plan.kitState][fate]}`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const effectiveLine = (plan, fate) => {
|
|
99
|
+
const { kit, local, effective } = plan.paths;
|
|
100
|
+
const dropped = plan.droppedEdit ? `, dropping an edit made to it; edits belong in ${local}` : '';
|
|
101
|
+
if (plan.effectiveWrite === null) return `${effective}: already the copy of ${kit} — nothing written`;
|
|
102
|
+
if (plan.effectiveWrite === 'seed') {
|
|
103
|
+
return `${effective}: ${{ written: `created as a copy of ${kit}`, stood: `appeared while this run was creating it and holds the copy of ${kit} — it stands`, would: `absent — would be created as a copy of ${kit}` }[fate]}`;
|
|
104
|
+
}
|
|
105
|
+
if (fate === 'would') return `${effective}: differs from ${kit} — would be rebuilt as its copy${dropped}`;
|
|
106
|
+
return `${effective}: ${plan.droppedEdit ? `rebuilt as a copy of ${kit}${dropped}` : `rebuilt from ${kit}`}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const recordLine = (plan, fate, replacing) => {
|
|
110
|
+
const { effective } = plan.paths;
|
|
111
|
+
if (!plan.recordWrite) return `${RECORD_PATH}: already records what ${effective} was built from — nothing written`;
|
|
112
|
+
const would = fate === 'would';
|
|
113
|
+
if (replacing !== null) {
|
|
114
|
+
return `${RECORD_PATH}: ${would ? 'is' : 'was'} unreadable (${replacing}) — ${would ? 'would be replaced' : 'replaced'} by a record of what ${effective} ${would ? 'is' : 'was'} built from`;
|
|
115
|
+
}
|
|
116
|
+
return `${RECORD_PATH}: ${would ? `would record what ${effective} is built from` : `records what ${effective} was built from`}`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const mergeLine = ({ plan, kitRoot, cwd, dryRun }) => {
|
|
120
|
+
const { kit, local, effective } = plan.paths;
|
|
121
|
+
const cli = shellQuoteArg(join(kitRoot, 'tools', 'project-rules', 'rules-cli.mjs'));
|
|
122
|
+
const command = `node ${cli} --record --theme ${plan.name} --kit ${digestOf(plan.kitBytes)} --local ${plan.localDigest} --effective <the sha256 of the merged file> --cwd ${shellQuoteArg(cwd)}`;
|
|
123
|
+
const holds = dryRun && plan.kitWrite !== null ? ` — this command holds once the real run has written ${kit}` : '';
|
|
124
|
+
return `merge needed — merge ${kit} and ${local} into ${effective}: by meaning, the local file wins a conflict, each rule appears once, only the final meaning is written, with no mark of where a rule came from, the frontmatter is kept, and the user is shown what changed; then record it: ${command}${holds}`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const themeLines = ({ plan, fateOf, kitRoot, cwd, dryRun, replacing }) => {
|
|
128
|
+
const lines = [kitLine(plan, fateOf(plan, 'kit'))];
|
|
129
|
+
if (plan.mode === 'pending') lines.push(mergeLine({ plan, kitRoot, cwd, dryRun }));
|
|
130
|
+
if (plan.mode === 'merged') lines.push(`${plan.paths.effective}: already the recorded merge of ${plan.paths.kit} and ${plan.paths.local} — nothing written`);
|
|
131
|
+
if (plan.mode === 'copy') lines.push(effectiveLine(plan, fateOf(plan, 'effective')), recordLine(plan, fateOf(plan, 'record'), replacing));
|
|
132
|
+
return lines;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── the token: one per run, the first that holds ─────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const DRY = { seeded: 'would-seed', refreshed: 'would-refresh', regenerated: 'would-regenerate' };
|
|
138
|
+
const tokenOf = (plans, wrote, dryRun) => {
|
|
139
|
+
if (plans.some((plan) => plan.mode === 'pending')) return 'pending';
|
|
140
|
+
const token = (word) => (dryRun ? DRY[word] : word);
|
|
141
|
+
if (plans.some((plan) => plan.kitWrite === 'seed' && wrote(plan, 'kit'))) return token('seeded');
|
|
142
|
+
if (plans.some((plan) => plan.kitWrite === 'refresh' && wrote(plan, 'kit'))) return token('refreshed');
|
|
143
|
+
if (plans.some((plan) => wrote(plan, 'effective') || wrote(plan, 'record'))) return token('regenerated');
|
|
144
|
+
return plans.some((plan) => plan.kitState === 'custom') ? 'customized-preserved' : 'already-current';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ── the writes, in order; the first that fails stops the run ─────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const writtenNote = (written) => (written.length > 0 ? `already written by this run and not rolled back: ${written.join(', ')}` : 'nothing was written before it');
|
|
150
|
+
const RACE = 'appeared while this run was creating it and does not hold the bytes this run carried — something is writing there underneath this run; nothing further written, re-run when the tree is settled';
|
|
151
|
+
const NOUN = { noun: 'a project rules file' };
|
|
152
|
+
|
|
153
|
+
const writeOne = ({ cwd, rel, body, kind, deps }) => (kind === 'seed'
|
|
154
|
+
? writeProjectFileCreateOnly(cwd, rel, body, deps, NOUN)
|
|
155
|
+
: writeDocsAiFileAtomic(cwd, rel, body, deps, NOUN));
|
|
156
|
+
|
|
157
|
+
const runWrites = ({ plans, cwd, deps, fates, notes }) => {
|
|
158
|
+
const written = [];
|
|
159
|
+
const stop = (cause, line) => ({ failure: composeFailure(OP, cause, `${line} — ${writtenNote(written)}`, ...notes) });
|
|
160
|
+
for (const plan of plans) {
|
|
161
|
+
const steps = [
|
|
162
|
+
plan.kitWrite && { role: 'kit', kind: plan.kitWrite },
|
|
163
|
+
plan.effectiveWrite && { role: 'effective', kind: plan.effectiveWrite },
|
|
164
|
+
].filter(Boolean);
|
|
165
|
+
for (const { role, kind } of steps) {
|
|
166
|
+
const rel = plan.paths[role];
|
|
167
|
+
const result = (() => {
|
|
168
|
+
try {
|
|
169
|
+
return { ok: writeOne({ cwd, rel, body: plan.kitBytes, kind, deps }) };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { error: causeOf(err) };
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
if (result.error !== undefined) return stop('write-refused', `${rel}: ${result.error}`);
|
|
175
|
+
notes.push(...tmpNote(rel, result.ok.tmpLeftBehind));
|
|
176
|
+
if (!result.ok.created) {
|
|
177
|
+
const now = readUnder(cwd, rel, deps);
|
|
178
|
+
if (now.outcome !== 'ok' || !now.bytes.equals(plan.kitBytes)) return stop('race-unresolved', `${rel}: ${RACE}`);
|
|
179
|
+
}
|
|
180
|
+
if (result.ok.created) written.push(rel);
|
|
181
|
+
fates.set(`${plan.name}:${role}`, result.ok.created ? 'written' : 'stood');
|
|
182
|
+
}
|
|
183
|
+
if (plan.recordWrite) {
|
|
184
|
+
const recorded = writeRecord(cwd, plan.name, plan.passed, deps);
|
|
185
|
+
if (!recorded.ok && recorded.condition === 'write') return stop('write-refused', `${RECORD_PATH}: ${recorded.message}`);
|
|
186
|
+
if (!recorded.ok) {
|
|
187
|
+
return stop('race-unresolved', `${RECORD_PATH}: the record could not vouch for the files (${recorded.condition}: ${recorded.message}), so they changed underneath this run; nothing further written, re-run when the tree is settled`);
|
|
188
|
+
}
|
|
189
|
+
written.push(RECORD_PATH);
|
|
190
|
+
fates.set(`${plan.name}:record`, 'written');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ensureRules({ cwd, kitRoot, dryRun?, deps? }) → one outcome of the closed vocabulary for every theme.
|
|
197
|
+
export const ensureRules = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
198
|
+
const themes = deps.themes ?? THEMES;
|
|
199
|
+
const surveyed = survey({ themes, cwd, kitRoot, readFile: deps.readFile ?? readFileSync, deps });
|
|
200
|
+
if (surveyed.failure) return surveyed.failure;
|
|
201
|
+
const plans = surveyed.surveyed.map((one) => planTheme(one, surveyed.record, deps.priors ?? RULES_PRIORS));
|
|
202
|
+
const firstRecordWrite = plans.findIndex((plan) => plan.recordWrite);
|
|
203
|
+
const replacing = (index) => (index === firstRecordWrite && !surveyed.record.ok ? surveyed.record.reason : null);
|
|
204
|
+
const fates = new Map();
|
|
205
|
+
const notes = [];
|
|
206
|
+
const fateOf = (plan, role) => fates.get(`${plan.name}:${role}`) ?? 'would';
|
|
207
|
+
const lines = () => plans.flatMap((plan, index) => themeLines({ plan, fateOf, kitRoot, cwd, dryRun, replacing: replacing(index) }));
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
const wouldWrite = (plan, role) => (role === 'kit' ? plan.kitWrite !== null : role === 'effective' ? plan.effectiveWrite !== null : plan.recordWrite);
|
|
210
|
+
return composeOutcome(OP, tokenOf(plans, wouldWrite, true), lines(), false);
|
|
211
|
+
}
|
|
212
|
+
const stopped = runWrites({ plans, cwd, deps, fates, notes });
|
|
213
|
+
if (stopped.failure) return stopped.failure;
|
|
214
|
+
const wrote = (plan, role) => fates.get(`${plan.name}:${role}`) === 'written';
|
|
215
|
+
return composeOutcome(OP, tokenOf(plans, wrote, false), [...lines(), ...notes], false);
|
|
216
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The append-only catalog of the kit-file bodies earlier releases shipped, and its one question.
|
|
2
|
+
// Contract: docs/ai/specs/kit/project-rules/rules-priors.md.
|
|
3
|
+
|
|
4
|
+
import { digestOf } from './rules-check.mjs';
|
|
5
|
+
|
|
6
|
+
export const RULES_PRIORS = Object.freeze([]);
|
|
7
|
+
|
|
8
|
+
// isKnownPrior(theme, bytes, catalog?) → true exactly when the bytes hash to an entry of that theme.
|
|
9
|
+
export const isKnownPrior = (theme, bytes, catalog = RULES_PRIORS) => {
|
|
10
|
+
const digest = digestOf(bytes);
|
|
11
|
+
return catalog.some((entry) => entry.theme === theme && entry.sha256 === digest);
|
|
12
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The record's one writer: docs/ai/rules/effective/sources.json states what every effective file was
|
|
2
|
+
// built from, and a merge it cannot vouch for is refused. Contract:
|
|
3
|
+
// docs/ai/specs/kit/project-rules/rules-record.md.
|
|
4
|
+
|
|
5
|
+
import { lstatSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { writeDocsAiFileAtomic } from '../atomic-write.mjs';
|
|
8
|
+
import { parseFrontmatter } from '../../references/scripts/check-docs-size.mjs';
|
|
9
|
+
import { digestOf, readRecord, readUnder } from './rules-check.mjs';
|
|
10
|
+
import { RECORD_PATH, THEMES, themePaths } from './themes.mjs';
|
|
11
|
+
|
|
12
|
+
const ROLES = ['kit', 'local', 'effective'];
|
|
13
|
+
|
|
14
|
+
// Why the first node on the record's path that lstat cannot inspect, is a symlink or is of the wrong
|
|
15
|
+
// kind fails, or null when the path holds or ends in a missing node.
|
|
16
|
+
const wrongNode = (root, lstat) => {
|
|
17
|
+
const parts = RECORD_PATH.split('/');
|
|
18
|
+
for (let i = 1; i <= parts.length; i += 1) {
|
|
19
|
+
const rel = parts.slice(0, i).join('/');
|
|
20
|
+
let st;
|
|
21
|
+
try {
|
|
22
|
+
st = lstat(join(root, rel));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err && err.code === 'ENOENT') return null;
|
|
25
|
+
return `${rel} cannot be inspected (${err?.code ?? err})`;
|
|
26
|
+
}
|
|
27
|
+
const isLeaf = i === parts.length;
|
|
28
|
+
if (st.isSymbolicLink() || (isLeaf ? !st.isFile() : !st.isDirectory())) return `${rel} is a symlink or not of its kind`;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The conditions in the order they are judged; each returns null when it holds, else the refusal.
|
|
34
|
+
const judge = (root, theme, passed, deps) => {
|
|
35
|
+
const themes = deps.themes ?? THEMES;
|
|
36
|
+
const fail = (condition, message) => ({ ok: false, condition, message });
|
|
37
|
+
if (!themes.some(({ name }) => name === theme)) return fail('theme', `${theme} is not a registry theme`);
|
|
38
|
+
const node = wrongNode(root, deps.lstat ?? lstatSync);
|
|
39
|
+
if (node !== null) return fail('record-path', node);
|
|
40
|
+
const recordRead = readUnder(root, RECORD_PATH, deps);
|
|
41
|
+
if (!['ok', 'absent'].includes(recordRead.outcome)) return fail('record-path', `${RECORD_PATH} is not a regular file`);
|
|
42
|
+
const record = readRecord(recordRead, themes);
|
|
43
|
+
const kept = record.ok ? record.themes : {};
|
|
44
|
+
const paths = themePaths(theme);
|
|
45
|
+
const files = Object.fromEntries(ROLES.map((role) => [role, readUnder(root, paths[role], deps)]));
|
|
46
|
+
const unreadable = ROLES.find((role) => !['ok', 'absent'].includes(files[role].outcome));
|
|
47
|
+
if (unreadable) return fail('unreadable-file', `${paths[unreadable]} is not a regular file this writer can read`);
|
|
48
|
+
const missing = ['kit', 'effective'].find((role) => files[role].outcome !== 'ok');
|
|
49
|
+
if (missing) return fail('missing-file', `${paths[missing]} is missing`);
|
|
50
|
+
const { kit, local, effective } = { kit: files.kit.bytes, local: files.local.bytes, effective: files.effective.bytes };
|
|
51
|
+
if (parseFrontmatter(effective.toString('utf8')) === null) return fail('frontmatter', `${paths.effective} carries no frontmatter`);
|
|
52
|
+
if (digestOf(kit) !== passed.kit) return fail('kit-moved', `${paths.kit} no longer hashes to the passed kit digest`);
|
|
53
|
+
if (digestOf(effective) !== passed.effective) return fail('effective-moved', `${paths.effective} no longer hashes to the passed effective digest`);
|
|
54
|
+
if ((local !== undefined) !== (passed.local !== 'absent')) {
|
|
55
|
+
return fail('local-presence', `${paths.local} ${local === undefined ? 'is absent' : 'exists'} while the passed local is ${passed.local}`);
|
|
56
|
+
}
|
|
57
|
+
if (local !== undefined) {
|
|
58
|
+
if (digestOf(local) !== passed.local) return fail('local-moved', `${paths.local} no longer hashes to the passed local digest`);
|
|
59
|
+
if (effective.equals(kit) || effective.equals(local)) return fail('effective-equals-input', `${paths.effective} equals one of its inputs, so no merge was written`);
|
|
60
|
+
} else if (!effective.equals(kit)) {
|
|
61
|
+
return fail('effective-differs-from-kit', `with no local file ${paths.effective} must equal ${paths.kit}`);
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, themes: kept };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// writeRecord(root, theme, passed, deps?) → { ok: true, path, entry } | { ok: false, condition, message }.
|
|
67
|
+
export const writeRecord = (root, theme, passed, deps = {}) => {
|
|
68
|
+
const judged = judge(root, theme, passed, deps);
|
|
69
|
+
if (!judged.ok) return judged;
|
|
70
|
+
const entry = { kit: passed.kit, local: passed.local, effective: passed.effective };
|
|
71
|
+
const body = `${JSON.stringify({ schema: 1, themes: { ...judged.themes, [theme]: entry } }, null, 2)}\n`;
|
|
72
|
+
try {
|
|
73
|
+
(deps.write ?? writeDocsAiFileAtomic)(root, RECORD_PATH, body);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { ok: false, condition: 'write', message: String(err?.message ?? err) };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, path: RECORD_PATH, entry };
|
|
78
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The theme registry: pure data and pure derivation. Every surface that names a theme or its files
|
|
2
|
+
// reads this list, never a second copy. Contract: docs/ai/specs/kit/project-rules/themes.md.
|
|
3
|
+
|
|
4
|
+
export const THEMES = Object.freeze([
|
|
5
|
+
Object.freeze({
|
|
6
|
+
name: 'project-layout',
|
|
7
|
+
template: 'references/rules/project-layout.md',
|
|
8
|
+
readWhen: 'Creating, moving or naming a file, a module or a spec',
|
|
9
|
+
}),
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const RECORD_PATH = 'docs/ai/rules/effective/sources.json';
|
|
13
|
+
|
|
14
|
+
export const themePaths = (name) => Object.freeze({
|
|
15
|
+
kit: `docs/ai/rules/${name}.md`,
|
|
16
|
+
local: `docs/ai/rules/local/${name}.md`,
|
|
17
|
+
effective: `docs/ai/rules/effective/${name}.md`,
|
|
18
|
+
record: RECORD_PATH,
|
|
19
|
+
});
|
package/tools/spec-adoption.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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
|
|
2
|
+
// was declined. Contract: docs/ai/specs/kit/spec-layer/spec-adoption.md. The census and the per-document read are
|
|
3
3
|
// spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path';
|
package/tools/spec-coverage.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// contract a REQUIREMENT with a ratchet — a shipped tool no contract governs is a REFUSAL, and the
|
|
8
8
|
// debt of today's uncovered tools may only shrink.
|
|
9
9
|
//
|
|
10
|
-
// The contract this module is built to is `docs/ai/specs/kit/spec-coverage.md`, and it was written
|
|
10
|
+
// The contract this module is built to is `docs/ai/specs/kit/spec-layer/spec-coverage.md`, and it was written
|
|
11
11
|
// BEFORE this file. That order is the point: a contract amended after the code describes whatever
|
|
12
12
|
// the last review happened to find, which makes review an open question with no bounded answer.
|
|
13
13
|
//
|