devrites 3.0.3 → 3.0.4

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.0.4](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.3...v3.0.4) (2026-07-20)
6
+
7
+ ### Fixed
8
+
9
+ * **ci:** harden supply chain checks ([d902574](https://github.com/ViktorsBaikers/DevRites/commit/d902574a7d30168fcef94aa4a3dae7707191fe8d))
10
+ * **devrites:** normalize canonical workspace files ([e70221f](https://github.com/ViktorsBaikers/DevRites/commit/e70221f5dd9fbda0f3a73c500a493497bc023a91))
11
+
5
12
  ## [3.0.3](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.2...v3.0.3) (2026-07-20)
6
13
 
7
14
  ### Fixed
package/README.md CHANGED
@@ -115,7 +115,7 @@ Full diagram set (lifecycle, polish orchestrator, review fan-out, debug loop,
115
115
  rules carrier, workspace state, namespace map) →
116
116
  [`docs/flow.md`](docs/flow.md).
117
117
 
118
- **Status:** [`v3.0.3`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.3) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
118
+ **Status:** [`v3.0.4`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.4) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
119
119
 
120
120
  ## Contents
121
121
 
@@ -0,0 +1,43 @@
1
+ # ADR-0001: Go engine as deterministic control plane
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08 (backfilled; decision predates the ADR log)
5
+
6
+ > Network scope was narrowed by [ADR-0008](0008-sanctioned-engine-network-boundary.md):
7
+ > deterministic workspace operations remain network-free, while explicit
8
+ > updater/source-cache I/O is isolated in `internal/iohooks`.
9
+
10
+ ## Context
11
+
12
+ DevRites orchestrates an LLM through a spec-driven lifecycle. Two kinds of work
13
+ are entangled: **judgment** (is this spec good? is this code right?) which only
14
+ a model can do, and **bookkeeping** (which phase are we in? are the required
15
+ sections present? what's the next question id?) which must be exact, fast, and
16
+ identical every run. Letting the model do the bookkeeping makes the process
17
+ non-reproducible and burns context on arithmetic.
18
+
19
+ ## Decision
20
+
21
+ Ship a single statically-linked Go binary (`CGO_ENABLED=0`, stdlib-only, zero
22
+ third-party deps in the hot path) as the **control plane**: it owns all
23
+ deterministic state transitions, gates, and derivations over `.devrites/`. It
24
+ makes **no** model or network calls. The filesystem is the data plane; the LLM
25
+ supplies judgment. Commands are a hand-rolled `switch` dispatch, not a CLI
26
+ framework.
27
+
28
+ ## Alternatives considered
29
+
30
+ | Option | Why not |
31
+ |--------|---------|
32
+ | Bookkeeping inside the agent prompt | Non-deterministic, context-hungry, unauditable — the exact failure this system exists to remove. |
33
+ | Node/TS engine (like the reference system GSD Core) | Drags a package tree + supply-chain surface; the pure-Go single binary has none and cross-compiles to every target from one runner. |
34
+ | Cobra / urfave CLI framework | A dependency and a config surface for a switch statement the stdlib already handles. |
35
+
36
+ ## Consequences
37
+
38
+ - Reproducible process: same state in, same verdict out, no wall-clock or
39
+ network variance (the one remaining wall-clock read is now seamed — ADR-0006).
40
+ - Zero-dependency binary: trivial supply chain, `go install`-able, no runtime.
41
+ - Cost: the engine can express only what's deterministic. Anything needing
42
+ judgment must round-trip to the model — by design.
43
+ - The engine is the trust root, so it carries the strictest CI gates (ADR-0006).
@@ -0,0 +1,34 @@
1
+ # ADR-0002: Dual-host harness (Claude + Codex)
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08 (backfilled)
5
+
6
+ ## Context
7
+
8
+ Each AI coding host (Claude Code, Codex, and — in adjacent tools — Cursor,
9
+ Cline, Copilot) speaks its own hook dialect: different stdin shapes, exit-code
10
+ conventions, and settings surfaces. The lifecycle logic (orient, gate, hook
11
+ decisions) is host-independent; only the edge translation differs.
12
+
13
+ ## Decision
14
+
15
+ Support exactly **two** hosts today — `claude` and `codex` — behind a thin
16
+ `internal/harness` adapter that translates each host's hook stdin/exit
17
+ conventions into the shared `orient` + `gate` core. Host support is
18
+ enumerated in code, not open-ended. `harness-matrix --check` keeps
19
+ `docs/harness-compliance.md` in sync with the adapters (drift is a CI failure).
20
+
21
+ ## Alternatives considered
22
+
23
+ | Option | Why not |
24
+ |--------|---------|
25
+ | A generic declarative capability/adapter registry now (the GSD Core model) | Real ceiling-raiser for N hosts, but a large refactor unjustified at N=2. Recorded as a Proposed follow-up, not built. See the adoption study in `docs/research/gsd-core-adoption.md` §3.1. |
26
+ | Claude-only | Codex users are already real; single-host would strand them. |
27
+ | Per-host forks of the logic | Duplicates the lifecycle core across edges — the thing the adapter exists to prevent. |
28
+
29
+ ## Consequences
30
+
31
+ - Adding a host today means editing `harness.go` — acceptable friction at N=2,
32
+ the calcification risk rises with N (hence the Proposed registry follow-up).
33
+ - The shared core is tested once; only edge translation is host-specific.
34
+ - `harness-matrix --check` makes host-support drift visible in CI.
@@ -0,0 +1,38 @@
1
+ # ADR-0003: Gates block as a HITL pause, never a crash
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08 (backfilled)
5
+
6
+ ## Context
7
+
8
+ A gate is a deterministic completeness check — e.g. "does this feature have the
9
+ sections required to leave the current phase?" When a gate is not satisfied the
10
+ engine must stop the agent, but *how* it stops matters. A crash (non-zero,
11
+ stderr stack) reads as a tool failure and invites the agent to retry blindly or
12
+ route around it. The intended meaning is the opposite: **the work is
13
+ incomplete, pause and involve the human.**
14
+
15
+ ## Decision
16
+
17
+ A blocked gate is a structured **human-in-the-loop pause**: a specific
18
+ "missing X" message on stdout and a distinct, reserved **exit code 3** — never a
19
+ crash, never a generic non-zero. Gates are **transition-fired** (they run only
20
+ when explicitly invoked at a phase boundary, not on every tool call). Two
21
+ kinds: `Readiness` (sections needed to leave the current phase) and `Seal` (the
22
+ full seal set). `StopGate` enforces the rest-point invariant: a feature claiming
23
+ completion with empty proof, or with `.red` set, cannot end the turn.
24
+
25
+ ## Alternatives considered
26
+
27
+ | Option | Why not |
28
+ |--------|---------|
29
+ | Exit 1 / crash on an unmet gate | Indistinguishable from a real error; agents retry or route around it instead of pausing for the human. |
30
+ | Run gates on every tool call | Turns a transition check into per-action overhead and noise; gates are boundary events. |
31
+ | Auto-advance past a soft-missing section | Defeats the completeness guarantee the gate exists to provide. |
32
+
33
+ ## Consequences
34
+
35
+ - Exit 3 is a reserved, load-bearing contract — the harness and hooks branch on
36
+ it. Its guard test (`tests/adr_0003_gate_exit_code_test.go`) locks it.
37
+ - Blocks are legible: the agent (and human) see exactly which section is missing.
38
+ - Gate authors must classify a stop as "incomplete" (exit 3) vs a true error.
@@ -3,6 +3,10 @@
3
3
  - **Status:** Accepted
4
4
  - **Date:** 2026-07-08 (backfilled)
5
5
 
6
+ > Artifact filename direction was amended by
7
+ > [ADR-0007](0007-canonical-live-workspace-filenames.md). Phase-relative section
8
+ > completeness remains unchanged.
9
+
6
10
  ## Context
7
11
 
8
12
  A feature's working state must be legible to both humans and the engine, and
@@ -13,9 +17,8 @@ that don't exist yet (there's no proof during framing).
13
17
 
14
18
  ## Decision
15
19
 
16
- Model feature state as **six single-concern section files** — `spec`, `plan`,
17
- `decisions`, `tasks`, `proof`, `status` (with transitional aliases
18
- `evidence→proof`, `state→status` that `migrate` normalizes). Drive the lifecycle
20
+ Model feature state as **six single-concern sections** — `spec`, `plan`,
21
+ `decisions`, `tasks`, `proof`, `status`. Drive the lifecycle
19
22
  through the ordered rite-\* arc:
20
23
  `frame → spec → temper → define → plan → vet → build → converge → prove → polish → review → seal → ship → done`.
21
24
  Completeness is **phase-relative and additive**: the typed phase registry maps
@@ -0,0 +1,39 @@
1
+ # ADR-0005: Hooks are engine subcommands, not shell scripts
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08 (backfilled; see commits porting `reviewer-readonly`,
5
+ `subagent-orient`, and the guard hooks into the Go engine)
6
+
7
+ ## Context
8
+
9
+ Lifecycle hooks (orient on session start, readonly-guard for reviewers,
10
+ stop-gate, statusline, redwatch, …) began as standalone `pack/.claude/hooks/*.sh`
11
+ scripts. Shell hooks drift from the engine's own state logic, are hard to test
12
+ in isolation, behave differently across the two hosts, and duplicate parsing the
13
+ engine already does.
14
+
15
+ ## Decision
16
+
17
+ Hooks are **subcommands of the one Go binary**: `devrites-engine hook <id>`.
18
+ The pack ships only `hooks.json` wiring plus host settings; every hook's logic
19
+ lives in the engine, sharing the `orient`/`gate`/`state` core. A control plane
20
+ selects which hooks fire: `DEVRITES_HOOK_PROFILE` (minimal / standard / strict)
21
+ and `DEVRITES_DISABLED_HOOKS`. Each hook has a golden parity test
22
+ (`tests/parity_*_test.go`) pinning its stdout + exit across both hosts.
23
+
24
+ ## Alternatives considered
25
+
26
+ | Option | Why not |
27
+ |--------|---------|
28
+ | Keep hooks as `.sh` scripts | Drift from engine state logic, per-host divergence, poor testability, duplicated parsing. |
29
+ | One monolithic hook entry point | Loses per-hook enable/disable and per-hook golden tests. |
30
+ | Node hook runtime | Reintroduces a runtime dependency ADR-0001 deliberately avoids. |
31
+
32
+ ## Consequences
33
+
34
+ - Hooks are unit-testable Go with golden parity snapshots, not shell fixtures.
35
+ - One implementation serves both hosts; the harness (ADR-0002) handles edge
36
+ translation.
37
+ - Profiles make the hook surface tunable without editing wiring.
38
+ - The pack's `hooks/*.sh` files are removed; `hooks.json` is the only pack-side
39
+ hook artifact.
@@ -0,0 +1,50 @@
1
+ # ADR-0006: Clock seam + Go static-analysis CI gates
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08
5
+
6
+ ## Context
7
+
8
+ Two gaps surfaced while studying a more mature peer system (GSD Core; see
9
+ `docs/research/gsd-core-adoption.md`):
10
+
11
+ 1. **Unwired static analysis.** `engine/Makefile` defined `staticcheck` and
12
+ `govulncheck` targets, but CI ran only `gofmt` + `go vet`. No `-race`, no
13
+ custom analysis on the trust-root binary.
14
+ 2. **A wall-clock leak.** `resolve next-qid` derived today's date from a raw
15
+ `time.Now()` with no seam, so its golden snapshot was pinned to the date it
16
+ was recorded and failed **every other day** — a latent red the peer system's
17
+ clock-seam lint would have caught at author time.
18
+
19
+ ## Decision
20
+
21
+ - **Clock seam.** Wall-clock reads in the resolve command flow through one
22
+ overridable point, `clockNow()`, honoring `DEVRITES_NOW` (RFC-3339 or bare
23
+ `YYYY-MM-DD`). Date-derived output is deterministic under test; goldens pin the
24
+ clock instead of tracking the real date.
25
+ - **Engine CI gates.** The `engine` CI job installs pinned `staticcheck`
26
+ (2025.1.1) + `govulncheck` and runs both as **blocking** steps, and runs the
27
+ suite with `-race`. `make quality` mirrors this so a green local gate means a
28
+ green pipeline.
29
+ - **Coverage** is measured (`make cover`) but **not** yet a hard floor: the
30
+ number is understated because most behaviour is exercised through CLI-level
31
+ integration tests in `tests/` that don't attribute to per-package coverage.
32
+ A ratchet-only floor is a follow-up once attribution is meaningful.
33
+
34
+ ## Alternatives considered
35
+
36
+ | Option | Why not |
37
+ |--------|---------|
38
+ | Regenerate the date golden and move on | Fixes it for one day; the test rots again at the next date boundary. The seam fixes the class. |
39
+ | Introduce `golangci-lint` | New dependency + config surface; `staticcheck` + `govulncheck` were already defined in the Makefile — wire what exists. |
40
+ | Inject a full `Clock` interface everywhere | Heavier than the one failing path needs; the env seam is the minimal correct fix. A shared clock package is a Proposed generalization. |
41
+ | Hard coverage floor now | Misleading at ~23% aggregate due to integration-test attribution; a floor here would gate on noise. |
42
+
43
+ ## Consequences
44
+
45
+ - The engine suite is deterministic across dates and race-checked; static
46
+ analysis and CVE scanning block on the trust-root binary.
47
+ - `DEVRITES_NOW` is a supported test seam; other date-deriving commands
48
+ (`learnings`, `conventions`, `footprint`, `stuck`) still read `time.Now()`
49
+ directly and can adopt the same seam when they need deterministic output.
50
+ - Guard test `tests/adr_0006_clock_seam_test.go` locks the seam behaviour.
@@ -0,0 +1,36 @@
1
+ # ADR-0007: Canonical live workspace filenames
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-20
5
+
6
+ ## Context
7
+
8
+ ADR-0004 established phase-relative `proof` and `status` sections while the
9
+ live workspace later standardized its human-facing artifacts on `README.md`,
10
+ `state.md`, and `evidence.md`. Shipped skills, validators, the typed workflow
11
+ registry, and user documentation already write those names, but migration and
12
+ some engine comments still normalized in the opposite direction.
13
+
14
+ ## Decision
15
+
16
+ Use `README.md`, `state.md`, and `evidence.md` as the canonical live workspace
17
+ map, cursor, and proof log. Continue reading `feature.md`/`index.md`, `status.md`,
18
+ and `proof.md` as compatibility aliases. `devrites-engine migrate` adds missing
19
+ canonical files from aliases without deleting or overwriting either form.
20
+ Internal `proof` and `status` section identifiers remain conceptual completeness
21
+ names, not canonical filename declarations.
22
+
23
+ ## Alternatives considered
24
+
25
+ | Option | Why not |
26
+ |--------|---------|
27
+ | Restore `feature.md`, `status.md`, and `proof.md` as canonical | Would reverse the shipped workspace contract and require coordinated changes across skills, validators, docs, fixtures, and generated hosts. |
28
+ | Treat both directions as equally canonical | Makes migration non-idempotent and leaves writers without one preferred target. |
29
+ | Delete aliases after migration | Risks data loss and breaks existing workspaces and older installed packs. |
30
+
31
+ ## Consequences
32
+
33
+ - Migration direction matches the files produced and required by the live pack.
34
+ - Existing aliases remain losslessly readable and are preserved on disk.
35
+ - Schema version remains `1`; this is an additive filename normalization, not a
36
+ content-schema break.
@@ -0,0 +1,33 @@
1
+ # ADR-0008: Sanctioned engine network boundary
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-20
5
+
6
+ ## Context
7
+
8
+ ADR-0001 described the Go control plane as network-free. The binary now also
9
+ owns explicit installation/update and source-cache operations that require HTTP,
10
+ while deterministic workspace bookkeeping still must not depend on network or
11
+ model responses. The implemented boundary already isolates HTTP in one package.
12
+
13
+ ## Decision
14
+
15
+ Keep deterministic workspace state, gate, hook, migration, and derivation logic
16
+ network- and model-free. Permit network access only in `internal/iohooks` for
17
+ explicit updater and source-cache operations. No other first-party package may
18
+ import network clients, and no engine package may call a model API.
19
+
20
+ ## Alternatives considered
21
+
22
+ | Option | Why not |
23
+ |--------|---------|
24
+ | Forbid all network access in the binary | Would remove the verified updater and source-cache refresh behavior users already rely on. |
25
+ | Allow each consumer its own HTTP client | Expands the audit surface and weakens the executable package boundary. |
26
+ | Ship a second updater binary | Adds packaging, release, and cross-platform complexity without improving the existing package isolation. |
27
+
28
+ ## Consequences
29
+
30
+ - Workspace verdicts remain deterministic and offline-capable.
31
+ - Network behavior has one auditable package and one guard test.
32
+ - The binary as a whole is not network-free; documentation must distinguish the
33
+ control-plane core from explicit I/O commands.
@@ -0,0 +1,58 @@
1
+ # Architecture Decision Records
2
+
3
+ An ADR captures **one** load-bearing decision: the context that forced it, the
4
+ decision itself, the alternatives that were rejected and *why*, and the
5
+ consequences accepted. The rejected-alternatives table is the point — it keeps
6
+ the "why not X" from being re-litigated every quarter.
7
+
8
+ `CLAUDE.md` points every agent here (`CONTEXT.md` + `docs/adr/` are the
9
+ single-context domain record). Keep this directory the source of truth for
10
+ architecture; per-feature `decisions.md` files stay scoped to that feature.
11
+
12
+ ## Convention
13
+
14
+ - Filename: `NNNN-kebab-title.md`, zero-padded, monotonic.
15
+ - Frontmatter fields: **Status** (Proposed / Accepted / Superseded by NNNN),
16
+ **Date** (YYYY-MM-DD), **Deciders** (optional).
17
+ - Sections: **Context** → **Decision** → **Alternatives considered** (a table:
18
+ option · why rejected) → **Consequences**.
19
+ - **Guard test:** an accepted ADR that asserts a runtime invariant SHOULD have a
20
+ named regression test — `engine/tests/adr_NNNN_*_test.go` — so the decision
21
+ has an executable proof it still holds. Reference the ADR number in the test's
22
+ doc comment.
23
+
24
+ ## Template
25
+
26
+ ```markdown
27
+ # ADR-NNNN: <title>
28
+
29
+ - **Status:** Accepted
30
+ - **Date:** YYYY-MM-DD
31
+
32
+ ## Context
33
+ <the forces: what problem, what constraints, what was true at the time>
34
+
35
+ ## Decision
36
+ <what we chose, stated as a present-tense rule>
37
+
38
+ ## Alternatives considered
39
+ | Option | Why not |
40
+ |--------|---------|
41
+ | ... | ... |
42
+
43
+ ## Consequences
44
+ <what this makes easy, what it costs, what follow-up it implies>
45
+ ```
46
+
47
+ ## Index
48
+
49
+ | ADR | Title | Status | Guard test |
50
+ |-----|-------|--------|-----------|
51
+ | [0001](0001-go-engine-as-control-plane.md) | Go engine as deterministic control plane | Accepted | — |
52
+ | [0002](0002-dual-host-harness.md) | Dual-host harness (Claude + Codex) | Accepted | `tests/parity_*_test.go` |
53
+ | [0003](0003-gate-model-hitl-pause.md) | Gates block as HITL pause, never crash | Accepted | `tests/adr_0003_gate_exit_code_test.go` |
54
+ | [0004](0004-state-schema-phases-sections.md) | Phase-relative section completeness | Accepted | `tests/adr_0004_required_by_phase_test.go` |
55
+ | [0005](0005-hooks-as-engine-subcommands.md) | Hooks are engine subcommands, not shell scripts | Accepted | `tests/parity_*_test.go` |
56
+ | [0006](0006-clock-seam-and-engine-ci-gates.md) | Clock seam + Go static-analysis CI gates | Accepted | `tests/adr_0006_clock_seam_test.go` |
57
+ | [0007](0007-canonical-live-workspace-filenames.md) | Canonical live workspace filenames | Accepted | `internal/migrate/migrate_test.go`, `tests/migrate_cli_test.go` |
58
+ | [0008](0008-sanctioned-engine-network-boundary.md) | Sanctioned engine network boundary | Accepted | `tests/meta_test.go` |
@@ -54,9 +54,10 @@ Backward compatibility: `.devrites/features/<slug>/` remains readable as a legac
54
54
  workspace location. `feature.md` / `index.md` may stand in for `README.md`, and
55
55
  `proof.md` may stand in for `evidence.md`.
56
56
 
57
- ### `feature.md`
57
+ ### Workspace maps
58
58
 
59
- The per-feature index. Its YAML frontmatter carries:
59
+ The canonical per-feature index is `README.md`; `feature.md` and `index.md` are
60
+ readable aliases. A map may carry YAML frontmatter with:
60
61
 
61
62
  | field | meaning |
62
63
  | --------------- | ---------------------------------------------- |
@@ -65,9 +66,9 @@ The per-feature index. Its YAML frontmatter carries:
65
66
  | `phase` | current workflow phase (see below) |
66
67
  | `schemaVersion` | schema version the file was written against |
67
68
 
68
- A feature exists when it has either a live `state.md` ledger or the transitional
69
- `feature.md` manifest. The mutable `state.md` cursor is authoritative when both
70
- declare a phase; an unknown declared phase is an error.
69
+ A feature exists when it has either a live `state.md` ledger or a workspace map.
70
+ The mutable `state.md` cursor is authoritative when both declare a phase; an
71
+ unknown declared phase is an error.
71
72
 
72
73
  ## Sections
73
74
 
@@ -90,11 +90,11 @@ func featureDirsNeedingNormalization(root string) ([]normalizationTarget, error)
90
90
  }
91
91
 
92
92
  func needsNormalizeFeature(dir string) bool {
93
- if !regularFileExists(filepath.Join(dir, "feature.md")) {
93
+ if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
94
94
  return true
95
95
  }
96
- return regularFileExists(filepath.Join(dir, "evidence.md")) && !regularFileExists(filepath.Join(dir, "proof.md")) ||
97
- regularFileExists(filepath.Join(dir, state.LedgerFile)) && !regularFileExists(filepath.Join(dir, "status.md"))
96
+ return regularFileExists(filepath.Join(dir, "proof.md")) && !regularFileExists(filepath.Join(dir, state.EvidenceFile)) ||
97
+ regularFileExists(filepath.Join(dir, "status.md")) && !regularFileExists(filepath.Join(dir, state.LedgerFile))
98
98
  }
99
99
 
100
100
  func regularFileExists(path string) bool {
@@ -103,16 +103,21 @@ func regularFileExists(path string) bool {
103
103
  }
104
104
 
105
105
  func normalizeFeatureDir(dir, slug string) error {
106
- if !regularFileExists(filepath.Join(dir, "feature.md")) {
106
+ for _, alias := range state.WorkspaceMapFiles()[1:] {
107
+ if err := copyAliasFile(dir, alias, state.WorkspaceMapFile); err != nil {
108
+ return fmt.Errorf("copy %s to %s: %w", alias, state.WorkspaceMapFile, err)
109
+ }
110
+ }
111
+ if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
107
112
  phase := derivePhase(filepath.Join(dir, state.LedgerFile))
108
- if err := state.AtomicWrite(filepath.Join(dir, "feature.md"), []byte(featureIndex(slug, phase)), 0o644); err != nil {
109
- return fmt.Errorf("write feature index: %w", err)
113
+ if err := state.AtomicWrite(filepath.Join(dir, state.WorkspaceMapFile), []byte(workspaceIndex(slug, phase)), 0o644); err != nil {
114
+ return fmt.Errorf("write workspace index: %w", err)
110
115
  }
111
116
  }
112
- if err := copyAliasFile(dir, "evidence.md", "proof.md"); err != nil {
113
- return fmt.Errorf("copy evidence.md to proof.md: %w", err)
117
+ if err := copyAliasFile(dir, "proof.md", state.EvidenceFile); err != nil {
118
+ return fmt.Errorf("copy proof.md to evidence.md: %w", err)
114
119
  }
115
- return copyAliasFile(dir, state.LedgerFile, "status.md")
120
+ return copyAliasFile(dir, "status.md", state.LedgerFile)
116
121
  }
117
122
 
118
123
  func copyAliasFile(dir, alias, canonical string) error {
@@ -128,8 +133,8 @@ func copyAliasFile(dir, alias, canonical string) error {
128
133
  return state.AtomicWrite(dst, data, 0o644)
129
134
  }
130
135
 
131
- // featureIndex renders the manifest added to a normalized workspace.
132
- func featureIndex(slug string, phase state.Phase) string {
136
+ // workspaceIndex renders the compact map added to a normalized workspace.
137
+ func workspaceIndex(slug string, phase state.Phase) string {
133
138
  return fmt.Sprintf(`---
134
139
  slug: %s
135
140
  title: %s
@@ -1,6 +1,7 @@
1
1
  package migrate
2
2
 
3
3
  import (
4
+ "os"
4
5
  "path/filepath"
5
6
  "strings"
6
7
  "testing"
@@ -33,8 +34,11 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
33
34
  }
34
35
 
35
36
  assertFile(t, root, "work/alpha/spec.md", "spec\n")
36
- assertFile(t, root, "work/alpha/proof.md", "proof\n")
37
- assertFile(t, root, "work/alpha/status.md", "status: done - shipped\n")
37
+ assertFile(t, root, "work/alpha/evidence.md", "proof\n")
38
+ assertFile(t, root, "work/alpha/state.md", "status: done - shipped\n")
39
+ assertFileContains(t, root, "work/alpha/README.md", "phase: done")
40
+ assertMissing(t, root, "work/alpha/proof.md")
41
+ assertMissing(t, root, "work/alpha/status.md")
38
42
  assertFile(t, root, "work/alpha/review.md", "review\n")
39
43
 
40
44
  f, err := state.LoadFeature(root, "alpha")
@@ -57,8 +61,9 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
57
61
 
58
62
  func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
59
63
  root := t.TempDir()
60
- testutil.WriteFile(t, filepath.Join(root, "features/beta/state.md"), "- Phase: prove\n")
61
- testutil.WriteFile(t, filepath.Join(root, "features/beta/evidence.md"), "evidence\n")
64
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/feature.md"), "# Beta\n")
65
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/status.md"), "- Phase: prove\n")
66
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/proof.md"), "evidence\n")
62
67
 
63
68
  res, err := Run(root)
64
69
  if err != nil {
@@ -67,6 +72,9 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
67
72
  if got := strings.Join(res.Migrated, ","); got != "beta" {
68
73
  t.Fatalf("Run normalized=%q, want beta", got)
69
74
  }
75
+ assertFile(t, root, "features/beta/README.md", "# Beta\n")
76
+ assertFile(t, root, "features/beta/state.md", "- Phase: prove\n")
77
+ assertFile(t, root, "features/beta/evidence.md", "evidence\n")
70
78
  assertFile(t, root, "features/beta/status.md", "- Phase: prove\n")
71
79
  assertFile(t, root, "features/beta/proof.md", "evidence\n")
72
80
 
@@ -111,3 +119,17 @@ func assertFile(t *testing.T, root, rel, want string) {
111
119
  t.Fatalf("%s=%q, want %q", rel, got, want)
112
120
  }
113
121
  }
122
+
123
+ func assertFileContains(t *testing.T, root, rel, want string) {
124
+ t.Helper()
125
+ if got := testutil.ReadFile(t, filepath.Join(root, rel)); !strings.Contains(got, want) {
126
+ t.Fatalf("%s=%q, want it to contain %q", rel, got, want)
127
+ }
128
+ }
129
+
130
+ func assertMissing(t *testing.T, root, rel string) {
131
+ t.Helper()
132
+ if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
133
+ t.Fatalf("%s exists or stat failed with %v, want missing", rel, err)
134
+ }
135
+ }
@@ -66,8 +66,8 @@ func featureDir(root, slug string) string {
66
66
 
67
67
  // ListFeatures returns the slugs of every feature under root — directories under
68
68
  // canonical work/ plus compatibility features/ recognized as a feature — sorted.
69
- // A directory is a feature if it has a manifest (feature.md) OR the working-state
70
- // ledger (state.md), so a live workspace the pack created without a manifest
69
+ // A directory is a feature if it has a workspace map OR the working-state
70
+ // ledger (state.md), so a live workspace the pack created without a map
71
71
  // still lists. Missing layout directories yield an empty list, not an error.
72
72
  func ListFeatures(root string) ([]string, error) {
73
73
  seen := map[string]bool{}
@@ -96,11 +96,18 @@ func ListFeatures(root string) ([]string, error) {
96
96
  return slugs, nil
97
97
  }
98
98
 
99
- // isFeatureDir reports whether dir is a recognized feature: it carries a manifest
100
- // or the working-state ledger. Either is a sufficient phase source for LoadFeature.
99
+ // isFeatureDir reports whether dir carries a workspace map or the working-state
100
+ // ledger. Either is a sufficient phase source for LoadFeature.
101
101
  func isFeatureDir(dir string) bool {
102
- return regularFileExists(filepath.Join(dir, "feature.md")) ||
103
- regularFileExists(filepath.Join(dir, LedgerFile))
102
+ if regularFileExists(filepath.Join(dir, LedgerFile)) {
103
+ return true
104
+ }
105
+ for _, name := range workspaceMapFiles {
106
+ if regularFileExists(filepath.Join(dir, name)) {
107
+ return true
108
+ }
109
+ }
110
+ return false
104
111
  }
105
112
 
106
113
  func regularFileExists(path string) bool {
@@ -109,18 +116,25 @@ func regularFileExists(path string) bool {
109
116
  }
110
117
 
111
118
  // LoadFeature reads feature <slug> under root. The phase comes from the live
112
- // working-state ledger when it declares one, otherwise from feature.md
113
- // frontmatter. A feature with neither a manifest nor a ledger does not exist.
114
- // Section content is read from the canonical section files or their transitional
119
+ // working-state ledger when it declares one, otherwise from workspace-map
120
+ // frontmatter. A feature with neither a map nor a ledger does not exist.
121
+ // Section content is read from the canonical section files or their
115
122
  // aliases (see sectionFiles).
116
123
  func LoadFeature(root, slug string) (*Feature, error) {
117
124
  dir := featureDir(root, slug)
118
125
 
119
- manifest, mErr := os.ReadFile(filepath.Join(dir, "feature.md"))
120
- if mErr != nil && !errors.Is(mErr, os.ErrNotExist) {
121
- return nil, fmt.Errorf("feature %q: %w", slug, mErr)
126
+ var manifest []byte
127
+ for _, name := range workspaceMapFiles {
128
+ data, err := os.ReadFile(filepath.Join(dir, name))
129
+ if err == nil {
130
+ manifest = data
131
+ break
132
+ }
133
+ if !errors.Is(err, os.ErrNotExist) {
134
+ return nil, fmt.Errorf("feature %q: read %s: %w", slug, name, err)
135
+ }
122
136
  }
123
- hasManifest := mErr == nil
137
+ hasManifest := manifest != nil
124
138
  hasLedger := regularFileExists(filepath.Join(dir, LedgerFile))
125
139
  if !hasManifest && !hasLedger {
126
140
  return nil, fmt.Errorf("feature %q not found", slug)
@@ -150,7 +164,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
150
164
  phase = manifestPhase
151
165
  }
152
166
  if phase == "" {
153
- return nil, fmt.Errorf("feature %q: no phase in feature.md frontmatter or %s ledger", slug, LedgerFile)
167
+ return nil, fmt.Errorf("feature %q: no phase in workspace-map frontmatter or %s ledger", slug, LedgerFile)
154
168
  }
155
169
  if !KnownPhase(phase) {
156
170
  return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
@@ -3,11 +3,23 @@ package state
3
3
  //go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
4
4
 
5
5
  // SchemaVersion is the .devrites state-schema version this engine understands.
6
- // A feature.md may declare its own schemaVersion in frontmatter; the engine
6
+ // A workspace map may declare its own schemaVersion in frontmatter; the engine
7
7
  // refuses a version newer than this (see LoadFeature) and otherwise reads the
8
8
  // files, which evolve additively.
9
9
  const SchemaVersion = 1
10
10
 
11
+ const (
12
+ WorkspaceMapFile = "README.md"
13
+ EvidenceFile = "evidence.md"
14
+ )
15
+
16
+ var workspaceMapFiles = []string{WorkspaceMapFile, "feature.md", "index.md"}
17
+
18
+ // WorkspaceMapFiles returns the canonical workspace map followed by readable aliases.
19
+ func WorkspaceMapFiles() []string {
20
+ return append([]string(nil), workspaceMapFiles...)
21
+ }
22
+
11
23
  // Section is one single-concern completeness file within a feature directory.
12
24
  // Splitting a feature into small files (rather than one long document) keeps
13
25
  // each file context-cheap and makes completeness self-evident.
@@ -33,23 +45,23 @@ var Sections = []Section{
33
45
  }
34
46
 
35
47
  // sectionFiles lists the filenames that can satisfy each section, canonical name
36
- // first, then the transitional aliases the live pack still writes — the same
37
- // mapping `devrites-engine migrate` normalizes (evidence→proof, state→status). A section
48
+ // first, then supported aliases the same mapping `devrites-engine migrate`
49
+ // normalizes (proof→evidence, status→state). A section
38
50
  // counts as present if ANY of its files has real content, so the engine reads a
39
- // live workspace before the pack sweep converges the filenames. The manifest
40
- // (feature.md) is not a section; it is handled separately in LoadFeature.
51
+ // live workspace before the pack sweep converges the filenames. The workspace
52
+ // map is not a section; it is handled separately in LoadFeature.
41
53
  var sectionFiles = map[Section][]string{
42
54
  SectionSpec: {"spec.md"},
43
55
  SectionPlan: {"plan.md"},
44
56
  SectionDecisions: {"decisions.md"},
45
57
  SectionTasks: {"tasks.md"},
46
- SectionProof: {"proof.md", "evidence.md"},
47
- SectionStatus: {"status.md", "state.md"},
58
+ SectionProof: {EvidenceFile, "proof.md"},
59
+ SectionStatus: {"state.md", "status.md"},
48
60
  }
49
61
 
50
62
  // LedgerFile is the working-state ledger the live pack writes. It carries the
51
63
  // phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
52
- // when no feature.md manifest declares one, and it satisfies the status section.
64
+ // when no workspace map declares one, and it satisfies the status section.
53
65
  const LedgerFile = "state.md"
54
66
 
55
67
  // Phase is a workflow state. The order mirrors the rite-* arc.
@@ -138,9 +138,9 @@ func writeWorkSection(t *testing.T, root, slug, name, body string) {
138
138
  }
139
139
  }
140
140
 
141
- // A live workspace the pack creates has no feature.md manifest: the phase lives in
142
- // the state.md ledger and the proof/status sections are satisfied by their aliases
143
- // (evidence.md / state.md). The engine must load, list, and report it anyway.
141
+ // A live workspace map need not carry frontmatter: the phase lives in
142
+ // the canonical state.md ledger and the proof/status concepts are satisfied by
143
+ // evidence.md/state.md. The engine must load, list, and report it anyway.
144
144
  func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
145
145
  root := filepath.Join(t.TempDir(), ".devrites")
146
146
  writeSection(t, root, "live", "state.md", "- Phase: prove\n- Status: running\n")
@@ -148,7 +148,7 @@ func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
148
148
  writeSection(t, root, "live", "plan.md", "# Plan\n\nApproach.\n")
149
149
  writeSection(t, root, "live", "decisions.md", "# Decisions\n\nChose X.\n")
150
150
  writeSection(t, root, "live", "tasks.md", "# Tasks\n\n- [x] slice 1\n")
151
- writeSection(t, root, "live", "evidence.md", "# Evidence\n\nTests pass.\n") // alias for proof
151
+ writeSection(t, root, "live", "evidence.md", "# Evidence\n\nTests pass.\n")
152
152
 
153
153
  rep, err := Status(root, "live")
154
154
  if err != nil {
@@ -158,10 +158,10 @@ func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
158
158
  t.Errorf("phase = %q, want prove (from the state.md ledger)", rep.Phase)
159
159
  }
160
160
  if !rep.Present[SectionProof] {
161
- t.Error("proof section should be present via its evidence.md alias")
161
+ t.Error("proof section should be present via canonical evidence.md")
162
162
  }
163
163
  if !rep.Present[SectionStatus] {
164
- t.Error("status section should be present via its state.md alias")
164
+ t.Error("status section should be present via canonical state.md")
165
165
  }
166
166
  if !rep.Complete() {
167
167
  t.Errorf("prove-phase feature should be complete, missing: %v", rep.Missing)
@@ -7,8 +7,8 @@ import (
7
7
 
8
8
  // TestStatusLiveWorkspace is the P2 acceptance check: `devrites-engine status <slug>`
9
9
  // must report a canonical work/<slug> feature the live pack created without a
10
- // feature.md manifest — phase from the state.md ledger, proof/status via their
11
- // evidence.md/state.md aliases. Before the schema unification this returned
10
+ // workspace-map frontmatter — phase from the canonical state.md ledger and proof/status
11
+ // completeness from canonical evidence.md/state.md files. Before schema unification this returned
12
12
  // "feature not found".
13
13
  func TestStatusLiveWorkspace(t *testing.T) {
14
14
  work := t.TempDir()
@@ -1,7 +1,7 @@
1
1
  package main_test
2
2
 
3
- // Cross-cutting assertions the spec calls out explicitly: the engine makes zero
4
- // network calls, and the inline fail-open guard no-ops when the binary is absent.
3
+ // Cross-cutting assertions: network I/O stays inside the one sanctioned package,
4
+ // and the inline fail-open guard no-ops when the binary is absent.
5
5
 
6
6
  import (
7
7
  "bytes"
@@ -11,7 +11,7 @@ import (
11
11
  )
12
12
 
13
13
  // TestFirstPartyMakesNoNetworkCalls asserts that no first-party package imports a
14
- // network client EXCEPT internal/iohooks — the ONE sanctioned network surface,
14
+ // network client EXCEPT internal/iohooks — the one sanctioned network surface,
15
15
  // where the source-citation cache does conditional-HEAD revalidation. Confining
16
16
  // network to that single, auditable package keeps the rest of the engine a
17
17
  // network-free control plane that makes zero model calls (PRD: "zero API"). The
@@ -58,25 +58,26 @@ func TestMigrateNormalizesCanonicalWorkSchema(t *testing.T) {
58
58
  }
59
59
 
60
60
  feat := filepath.Join(root, "work", slug)
61
- // evidence.md proof.md, state.md status.md, plus a generated feature.md.
62
- for _, want := range []string{"feature.md", "spec.md", "plan.md", "decisions.md", "tasks.md", "proof.md", "status.md"} {
61
+ // Current canonical files stay canonical; migration adds only the missing map.
62
+ for _, want := range []string{"README.md", "state.md", "spec.md", "plan.md", "decisions.md", "tasks.md", "evidence.md"} {
63
63
  if _, err := os.Stat(filepath.Join(feat, want)); err != nil {
64
64
  t.Errorf("missing new-schema file %s: %v", want, err)
65
65
  }
66
66
  }
67
- fm, err := os.ReadFile(filepath.Join(feat, "feature.md"))
67
+ readme, err := os.ReadFile(filepath.Join(feat, "README.md"))
68
68
  if err != nil {
69
69
  t.Fatal(err)
70
70
  }
71
- if !strings.Contains(string(fm), "phase: temper") {
72
- t.Errorf("feature.md phase not derived from state.md\n%s", fm)
71
+ if !strings.Contains(string(readme), "phase: temper") {
72
+ t.Errorf("README.md phase not derived from state.md\n%s", readme)
73
73
  }
74
- if !strings.Contains(string(fm), "schemaVersion: 1") {
75
- t.Errorf("feature.md missing schemaVersion\n%s", fm)
74
+ if !strings.Contains(string(readme), "schemaVersion: 1") {
75
+ t.Errorf("README.md missing schemaVersion\n%s", readme)
76
76
  }
77
- proof, _ := os.ReadFile(filepath.Join(feat, "proof.md"))
78
- if !strings.Contains(string(proof), "Tests green") {
79
- t.Errorf("proof.md did not carry evidence.md content\n%s", proof)
77
+ for _, unwanted := range []string{"feature.md", "proof.md", "status.md"} {
78
+ if _, err := os.Stat(filepath.Join(feat, unwanted)); !os.IsNotExist(err) {
79
+ t.Errorf("migration created legacy alias %s: %v", unwanted, err)
80
+ }
80
81
  }
81
82
 
82
83
  // A backup of the pre-migration state must exist.
@@ -127,11 +128,12 @@ func TestMigrateNormalizesLiveFeatureInPlace(t *testing.T) {
127
128
  t.Fatal(err)
128
129
  }
129
130
  files := map[string]string{
130
- "state.md": "# State\n\n- Phase: prove\n- Status: running\n",
131
+ "feature.md": "# Legacy feature map\n",
132
+ "status.md": "# State\n\n- Phase: prove\n- Status: running\n",
131
133
  "spec.md": "# Spec\n\nRotate tokens.\n",
132
134
  "plan.md": "# Plan\n\nStep 1, step 2.\n",
133
135
  "tasks.md": "# Tasks\n\n- [x] one\n",
134
- "evidence.md": "# Evidence\n\nTests green.\n",
136
+ "proof.md": "# Evidence\n\nTests green.\n",
135
137
  "decisions.md": "# Decisions\n\nUse HMAC.\n",
136
138
  }
137
139
  for name, body := range files {
@@ -147,25 +149,25 @@ func TestMigrateNormalizesLiveFeatureInPlace(t *testing.T) {
147
149
  if !strings.Contains(out, "migrated 1 feature(s)") {
148
150
  t.Errorf("unexpected migrate output\n%s", out)
149
151
  }
150
- for _, want := range []string{"feature.md", "proof.md", "status.md", "state.md", "evidence.md"} {
152
+ for _, want := range []string{"README.md", "feature.md", "proof.md", "status.md", "state.md", "evidence.md"} {
151
153
  if _, err := os.Stat(filepath.Join(feat, want)); err != nil {
152
154
  t.Errorf("missing normalized file %s: %v", want, err)
153
155
  }
154
156
  }
155
- fm, err := os.ReadFile(filepath.Join(feat, "feature.md"))
157
+ readme, err := os.ReadFile(filepath.Join(feat, "README.md"))
156
158
  if err != nil {
157
159
  t.Fatal(err)
158
160
  }
159
- if !strings.Contains(string(fm), "phase: prove") {
160
- t.Errorf("feature.md phase not derived from ledger\n%s", fm)
161
+ if !strings.Contains(string(readme), "Legacy feature map") {
162
+ t.Errorf("README.md did not carry feature.md content\n%s", readme)
161
163
  }
162
- status, _ := os.ReadFile(filepath.Join(feat, "status.md"))
163
- if !strings.Contains(string(status), "running") {
164
- t.Errorf("status.md did not carry state.md content\n%s", status)
164
+ stateFile, _ := os.ReadFile(filepath.Join(feat, "state.md"))
165
+ if !strings.Contains(string(stateFile), "running") {
166
+ t.Errorf("state.md did not carry status.md content\n%s", stateFile)
165
167
  }
166
- proof, _ := os.ReadFile(filepath.Join(feat, "proof.md"))
167
- if !strings.Contains(string(proof), "Tests green") {
168
- t.Errorf("proof.md did not carry evidence.md content\n%s", proof)
168
+ evidence, _ := os.ReadFile(filepath.Join(feat, "evidence.md"))
169
+ if !strings.Contains(string(evidence), "Tests green") {
170
+ t.Errorf("evidence.md did not carry proof.md content\n%s", evidence)
169
171
  }
170
172
  if got := backupDirs(t, root); len(got) != 1 {
171
173
  t.Errorf("want exactly one backup dir, got %v", got)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.0.3",
3
+ "version": "3.0.4",
4
4
  "description": "DevRites — disciplined senior-engineer workflow skills pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
@@ -54,6 +54,7 @@
54
54
  "test:fast": "node scripts/run-tests.mjs --fast",
55
55
  "test:parallel": "node scripts/run-tests.mjs",
56
56
  "test:serial": "node scripts/run-tests.mjs --serial",
57
+ "audit": "npm audit --audit-level=moderate",
57
58
  "size:baseline": "node scripts/check-instruction-size-baseline.mjs --write",
58
59
  "release": "semantic-release",
59
60
  "release:dry": "semantic-release --dry-run --no-ci"
@@ -4,9 +4,8 @@
4
4
  DevRites' publish (release.yml) and auto-merge paths are high-value targets, so this
5
5
  gate fails CI when a workflow:
6
6
 
7
- - uses a THIRD-PARTY action not pinned to a full 40-char commit SHA — a moving tag
8
- like `@v2` lets a compromised upstream inject code into the pipeline (GitHub-owned
9
- `actions/*` and `github/*` tags are tolerated);
7
+ - uses any non-local action not pinned to a full 40-char commit SHA — a moving
8
+ tag like `@v2` lets a compromised upstream inject code into the pipeline;
10
9
  - declares no `permissions:` scope anywhere — the default token is broad;
11
10
  - uses `permissions: write-all` (over-broad);
12
11
  - uses `pull_request_target`, except for a Dependabot-only workflow that never
@@ -19,7 +18,6 @@ import os
19
18
  import re
20
19
  import sys
21
20
 
22
- FIRST_PARTY = {"actions", "github"} # GitHub-owned; major-tag refs tolerated
23
21
  SHA_RE = re.compile(r"^[0-9a-f]{40}$")
24
22
  USES_RE = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)")
25
23
  DEPENDABOT_ONLY_RE = re.compile(
@@ -61,13 +59,10 @@ def scan_text(path, text):
61
59
  ref = m.group(1)
62
60
  if ref.startswith("./") or ref.startswith("."):
63
61
  continue # local action
64
- owner = ref.split("/", 1)[0]
65
- if owner in FIRST_PARTY:
66
- continue
67
62
  at = ref.rsplit("@", 1)
68
63
  pin = at[1] if len(at) == 2 else ""
69
64
  if not SHA_RE.match(pin):
70
- findings.append("%s:%d: third-party action '%s' not pinned to a full commit "
65
+ findings.append("%s:%d: action '%s' not pinned to a full commit "
71
66
  "SHA — pin it (a moving tag is a supply-chain risk)"
72
67
  % (path, i, ref))
73
68
  return findings