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 +7 -0
- package/README.md +1 -1
- package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
- package/docs/adr/0002-dual-host-harness.md +34 -0
- package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
- package/docs/adr/0004-state-schema-phases-sections.md +6 -3
- package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
- package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
- package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
- package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
- package/docs/adr/README.md +58 -0
- package/docs/engine/state-schema.md +6 -5
- package/engine/internal/migrate/migrate.go +16 -11
- package/engine/internal/migrate/migrate_test.go +26 -4
- package/engine/internal/state/feature.go +28 -14
- package/engine/internal/state/schema.go +20 -8
- package/engine/internal/state/state_test.go +6 -6
- package/engine/tests/budget_test.go +2 -2
- package/engine/tests/meta_test.go +3 -3
- package/engine/tests/migrate_cli_test.go +24 -22
- package/package.json +2 -1
- package/scripts/validate-workflow-security.py +3 -8
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.
|
|
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
|
|
17
|
-
`decisions`, `tasks`, `proof`, `status
|
|
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
|
-
###
|
|
57
|
+
### Workspace maps
|
|
58
58
|
|
|
59
|
-
The per-feature index.
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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,
|
|
93
|
+
if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
|
|
94
94
|
return true
|
|
95
95
|
}
|
|
96
|
-
return regularFileExists(filepath.Join(dir, "
|
|
97
|
-
regularFileExists(filepath.Join(dir,
|
|
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
|
-
|
|
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,
|
|
109
|
-
return fmt.Errorf("write
|
|
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, "
|
|
113
|
-
return fmt.Errorf("copy
|
|
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,
|
|
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
|
-
//
|
|
132
|
-
func
|
|
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/
|
|
37
|
-
assertFile(t, root, "work/alpha/
|
|
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/
|
|
61
|
-
testutil.WriteFile(t, filepath.Join(root, "features/beta/
|
|
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
|
|
70
|
-
// ledger (state.md), so a live workspace the pack created without a
|
|
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
|
|
100
|
-
//
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
113
|
-
// frontmatter. A feature with neither a
|
|
114
|
-
// Section content is read from the canonical section files or their
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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 :=
|
|
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
|
|
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
|
|
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
|
|
37
|
-
//
|
|
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
|
|
40
|
-
//
|
|
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: {
|
|
47
|
-
SectionStatus: {"
|
|
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
|
|
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
|
|
142
|
-
// the state.md ledger and the proof/status
|
|
143
|
-
//
|
|
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")
|
|
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
|
|
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
|
|
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
|
-
//
|
|
11
|
-
// evidence.md/state.md
|
|
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
|
|
4
|
-
//
|
|
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
|
|
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
|
-
//
|
|
62
|
-
for _, want := range []string{"
|
|
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
|
-
|
|
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(
|
|
72
|
-
t.Errorf("
|
|
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(
|
|
75
|
-
t.Errorf("
|
|
74
|
+
if !strings.Contains(string(readme), "schemaVersion: 1") {
|
|
75
|
+
t.Errorf("README.md missing schemaVersion\n%s", readme)
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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(
|
|
160
|
-
t.Errorf("
|
|
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
|
-
|
|
163
|
-
if !strings.Contains(string(
|
|
164
|
-
t.Errorf("
|
|
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
|
-
|
|
167
|
-
if !strings.Contains(string(
|
|
168
|
-
t.Errorf("
|
|
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
|
+
"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
|
|
8
|
-
like `@v2` lets a compromised upstream inject code into the pipeline
|
|
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:
|
|
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
|