devrites 3.0.3 → 3.0.5

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +55 -43
  3. package/SECURITY.md +72 -58
  4. package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
  5. package/docs/adr/0002-dual-host-harness.md +34 -0
  6. package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
  7. package/docs/adr/0004-state-schema-phases-sections.md +6 -3
  8. package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
  9. package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
  10. package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
  11. package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
  12. package/docs/adr/README.md +58 -0
  13. package/docs/agents/issue-driven-rites.md +5 -5
  14. package/docs/architecture.md +61 -45
  15. package/docs/capability-surface-selection.md +3 -1
  16. package/docs/cli.md +9 -2
  17. package/docs/command-map.md +6 -2
  18. package/docs/engine/agent-contract.md +5 -3
  19. package/docs/engine/commands.md +27 -14
  20. package/docs/engine/state-schema.md +14 -9
  21. package/docs/engine/workspace-schema.md +5 -4
  22. package/docs/flow.md +18 -7
  23. package/docs/porting-to-a-new-harness.md +6 -2
  24. package/docs/release.md +10 -9
  25. package/docs/skills.md +1 -1
  26. package/docs/templates/CRITIC_REVIEW_PACKET_TEMPLATE.md +35 -0
  27. package/docs/usage.md +26 -16
  28. package/engine/internal/lib/packageexistence.go +729 -52
  29. package/engine/internal/lib/packageexistence_test.go +343 -0
  30. package/engine/internal/lib/util.go +1 -1
  31. package/engine/internal/migrate/migrate.go +16 -11
  32. package/engine/internal/migrate/migrate_test.go +26 -4
  33. package/engine/internal/state/feature.go +28 -14
  34. package/engine/internal/state/schema.go +20 -8
  35. package/engine/internal/state/state_test.go +6 -6
  36. package/engine/tests/budget_test.go +2 -2
  37. package/engine/tests/meta_test.go +3 -3
  38. package/engine/tests/migrate_cli_test.go +24 -22
  39. package/pack/.claude/skills/devrites-lib/SKILL.md +7 -9
  40. package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +11 -3
  41. package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
  42. package/pack/.claude/skills/rite/SKILL.md +6 -7
  43. package/pack/.claude/skills/rite/reference/menu.md +5 -5
  44. package/pack/.claude/skills/rite-adopt/SKILL.md +2 -3
  45. package/pack/generated/claude/skills/devrites-lib/SKILL.md +7 -9
  46. package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +11 -3
  47. package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
  48. package/pack/generated/claude/skills/rite/SKILL.md +6 -7
  49. package/pack/generated/claude/skills/rite/reference/menu.md +5 -5
  50. package/pack/generated/claude/skills/rite-adopt/SKILL.md +2 -3
  51. package/pack/generated/codex/skills/devrites-lib/SKILL.md +7 -9
  52. package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +11 -3
  53. package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
  54. package/pack/generated/codex/skills/rite/SKILL.md +6 -7
  55. package/pack/generated/codex/skills/rite/reference/menu.md +5 -5
  56. package/pack/generated/codex/skills/rite-adopt/SKILL.md +2 -3
  57. package/package.json +2 -1
  58. package/scripts/skills-inventory.mjs +18 -0
  59. package/scripts/validate-workflow-security.py +3 -8
@@ -0,0 +1,343 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestPackageExistenceJavaScriptWorkspaces(t *testing.T) {
13
+ tests := []struct {
14
+ name string
15
+ sourcePath string
16
+ workingDir string
17
+ importLine string
18
+ files map[string]string
19
+ wantCode int
20
+ wantFinding string
21
+ }{
22
+ {
23
+ name: "nested workspace",
24
+ sourcePath: "frontend/src/example.ts",
25
+ workingDir: "frontend",
26
+ importLine: `import { Dialog } from "@ark-ui/svelte/dialog";`,
27
+ files: map[string]string{
28
+ "frontend/package.json": `{"dependencies":{"@ark-ui/svelte":"^5.22.1"}}`,
29
+ },
30
+ },
31
+ {
32
+ name: "scoped package subpath",
33
+ sourcePath: "app/src/example.ts",
34
+ importLine: `import value from "@scope/package/subpath";`,
35
+ files: map[string]string{
36
+ "app/package.json": `{"devDependencies":{"@scope/package":"1.0.0"}}`,
37
+ },
38
+ },
39
+ {
40
+ name: "wildcard alias",
41
+ sourcePath: "frontend/src/example.ts",
42
+ importLine: `import Button from "$lib/components/button";`,
43
+ files: map[string]string{
44
+ "frontend/package.json": `{}`,
45
+ "frontend/tsconfig.json": "{\n // SvelteKit generates the inherited aliases.\n \"extends\": \"./.svelte-kit/tsconfig.json\"\n}",
46
+ "frontend/.svelte-kit/tsconfig.json": `{"compilerOptions":{"paths":{"$lib/*":["../src/lib/*"]}}}`,
47
+ },
48
+ },
49
+ {
50
+ name: "exact alias",
51
+ sourcePath: "frontend/src/example.ts",
52
+ importLine: `import library from "$lib";`,
53
+ files: map[string]string{
54
+ "frontend/package.json": `{}`,
55
+ "frontend/tsconfig.json": `{"compilerOptions":{"paths":{"$lib":["./src/lib"]}}}`,
56
+ },
57
+ },
58
+ {
59
+ name: "package shaped alias",
60
+ sourcePath: "frontend/src/example.svelte",
61
+ importLine: `import { css } from "styled-system/css";`,
62
+ files: map[string]string{
63
+ "frontend/package.json": `{}`,
64
+ "frontend/jsconfig.json": `{"compilerOptions":{"paths":{"styled-system/*":["./styled-system/*"]}}}`,
65
+ },
66
+ },
67
+ {
68
+ name: "undeclared dependency",
69
+ sourcePath: "frontend/src/example.ts",
70
+ importLine: `import missing from "definitely-missing-package";`,
71
+ files: map[string]string{"frontend/package.json": `{}`},
72
+ wantCode: 3,
73
+ wantFinding: "definitely-missing-package",
74
+ },
75
+ {
76
+ name: "sibling isolation",
77
+ sourcePath: "workspace-a/src/example.ts",
78
+ importLine: `import sibling from "sibling-only-package";`,
79
+ files: map[string]string{
80
+ "workspace-a/package.json": `{}`,
81
+ "workspace-b/package.json": `{"dependencies":{"sibling-only-package":"1.0.0"}}`,
82
+ },
83
+ wantCode: 3,
84
+ wantFinding: "sibling-only-package",
85
+ },
86
+ {
87
+ name: "malformed config does not allow alias",
88
+ sourcePath: "frontend/src/example.ts",
89
+ importLine: `import { css } from "styled-system/css";`,
90
+ files: map[string]string{
91
+ "frontend/package.json": `{}`,
92
+ "frontend/tsconfig.json": `{"compilerOptions":{"paths":{"styled-system/*":["./styled-system/*"]}}`,
93
+ },
94
+ wantCode: 3,
95
+ wantFinding: "styled-system",
96
+ },
97
+ }
98
+
99
+ for _, test := range tests {
100
+ t.Run(test.name, func(t *testing.T) {
101
+ repo := t.TempDir()
102
+ writePackageExistenceFile(t, repo, "Cargo.toml", "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n")
103
+ writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n")
104
+ for path, content := range test.files {
105
+ writePackageExistenceFile(t, repo, path, content+"\n")
106
+ }
107
+ gitPackageExistence(t, repo, "init", "-q")
108
+ gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
109
+ gitPackageExistence(t, repo, "config", "user.name", "Test")
110
+ gitPackageExistence(t, repo, "add", ".")
111
+ gitPackageExistence(t, repo, "commit", "-qm", "baseline")
112
+ if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n"+test.importLine+"\n")
116
+ t.Chdir(filepath.Join(repo, test.workingDir))
117
+
118
+ var stdout, stderr bytes.Buffer
119
+ gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
120
+ if gotCode != test.wantCode {
121
+ t.Fatalf("code = %d, want %d\nstdout: %s\nstderr: %s", gotCode, test.wantCode, stdout.String(), stderr.String())
122
+ }
123
+ if test.wantFinding != "" && !strings.Contains(stderr.String(), test.wantFinding) {
124
+ t.Fatalf("stderr does not name %q: %s", test.wantFinding, stderr.String())
125
+ }
126
+ })
127
+ }
128
+ }
129
+
130
+ func TestPackageExistenceNonJavaScriptWorkspaces(t *testing.T) {
131
+ tests := []struct {
132
+ name string
133
+ sourcePath string
134
+ addedSource string
135
+ files map[string]string
136
+ wantCode int
137
+ wantFinding string
138
+ }{
139
+ {
140
+ name: "go import block rejects undeclared module",
141
+ sourcePath: "main.go",
142
+ addedSource: "package main\n\nimport (\n\t\"definitely.invalid/missing\"\n)\n",
143
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
144
+ wantCode: 3,
145
+ wantFinding: "definitely.invalid/missing",
146
+ },
147
+ {
148
+ name: "go modules do not collide by hostname",
149
+ sourcePath: "main.go",
150
+ addedSource: "package main\n\nimport \"github.com/evil/missing\"\n",
151
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n\nrequire github.com/good/pkg v1.0.0\n"},
152
+ wantCode: 3,
153
+ wantFinding: "github.com/evil/missing",
154
+ },
155
+ {
156
+ name: "nested go module declaration",
157
+ sourcePath: "backend/main.go",
158
+ addedSource: "package main\n\nimport \"github.com/good/pkg/sub\"\n",
159
+ files: map[string]string{
160
+ "Cargo.toml": "[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
161
+ "backend/go.mod": "module example.com/backend\n\ngo 1.26\n\nrequire github.com/good/pkg v1.0.0\n",
162
+ },
163
+ },
164
+ {
165
+ name: "go standard library subpackage",
166
+ sourcePath: "main.go",
167
+ addedSource: "package main\n\nimport \"encoding/json\"\n",
168
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
169
+ },
170
+ {
171
+ name: "go standard library typo remains undeclared",
172
+ sourcePath: "main.go",
173
+ addedSource: "package main\n\nimport \"encoding/josn\"\n",
174
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
175
+ wantCode: 3,
176
+ wantFinding: "encoding/josn",
177
+ },
178
+ {
179
+ name: "malformed go manifest does not declare import",
180
+ sourcePath: "main.go",
181
+ addedSource: "package main\n\nimport \"github.com/missing/pkg\"\n",
182
+ files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n\nrequire (\n github.com/missing/pkg v1.0.0\n"},
183
+ wantCode: 3,
184
+ wantFinding: "github.com/missing/pkg",
185
+ },
186
+ {
187
+ name: "rust rejects undeclared crate",
188
+ sourcePath: "src/main.rs",
189
+ addedSource: "use definitely_missing_crate::Thing;\n",
190
+ files: map[string]string{"Cargo.toml": "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[dependencies]\n"},
191
+ wantCode: 3,
192
+ wantFinding: "definitely_missing_crate",
193
+ },
194
+ {
195
+ name: "nested rust dependency and hyphen normalization",
196
+ sourcePath: "crates/app/src/main.rs",
197
+ addedSource: "use serde_json::Value;\n",
198
+ files: map[string]string{
199
+ "Cargo.toml": "[workspace]\nmembers = [\"crates/app\"]\n",
200
+ "crates/app/Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies.serde-json]\nversion = \"1\"\n",
201
+ },
202
+ },
203
+ {
204
+ name: "python dotted import uses top-level module",
205
+ sourcePath: "app.py",
206
+ addedSource: "from requests.sessions import Session\n",
207
+ files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
208
+ },
209
+ {
210
+ name: "python standard library module",
211
+ sourcePath: "app.py",
212
+ addedSource: "import asyncio\n",
213
+ files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
214
+ },
215
+ {
216
+ name: "nested python manifest declaration",
217
+ sourcePath: "backend/app.py",
218
+ addedSource: "import requests\n",
219
+ files: map[string]string{
220
+ "Cargo.toml": "[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
221
+ "backend/requirements.txt": "requests==2.32.0\n",
222
+ },
223
+ },
224
+ {
225
+ name: "pyproject dependency declaration",
226
+ sourcePath: "service/app.py",
227
+ addedSource: "import requests\n",
228
+ files: map[string]string{
229
+ "service/pyproject.toml": "[project]\ndependencies = [\n \"requests[socks]>=2.32\",\n]\n",
230
+ },
231
+ },
232
+ {
233
+ name: "pipfile dependency declaration",
234
+ sourcePath: "service/app.py",
235
+ addedSource: "import requests\n",
236
+ files: map[string]string{
237
+ "service/Pipfile": "[packages]\nrequests = \"*\"\n",
238
+ },
239
+ },
240
+ {
241
+ name: "malformed pyproject does not declare import",
242
+ sourcePath: "service/app.py",
243
+ addedSource: "import definitely_missing\n",
244
+ files: map[string]string{
245
+ "service/pyproject.toml": "[project]\ndependencies = [\n \"definitely_missing\"\n",
246
+ },
247
+ wantCode: 3,
248
+ wantFinding: "definitely_missing",
249
+ },
250
+ {
251
+ name: "python multiple imports reject undeclared module",
252
+ sourcePath: "app.py",
253
+ addedSource: "import requests, definitely_missing\n",
254
+ files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
255
+ wantCode: 3,
256
+ wantFinding: "definitely_missing",
257
+ },
258
+ {
259
+ name: "comments and other ecosystems do not declare python imports",
260
+ sourcePath: "app.py",
261
+ addedSource: "import definitely_missing\n",
262
+ files: map[string]string{
263
+ "Cargo.toml": "[package]\nname = \"definitely_missing\"\nversion = \"0.1.0\"\n",
264
+ "requirements.txt": "# definitely_missing is intentionally absent\n",
265
+ },
266
+ wantCode: 3,
267
+ wantFinding: "definitely_missing",
268
+ },
269
+ }
270
+
271
+ for _, test := range tests {
272
+ t.Run(test.name, func(t *testing.T) {
273
+ repo := t.TempDir()
274
+ for path, content := range test.files {
275
+ writePackageExistenceFile(t, repo, path, content)
276
+ }
277
+ writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n")
278
+ gitPackageExistence(t, repo, "init", "-q")
279
+ gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
280
+ gitPackageExistence(t, repo, "config", "user.name", "Test")
281
+ gitPackageExistence(t, repo, "add", ".")
282
+ gitPackageExistence(t, repo, "commit", "-qm", "baseline")
283
+ if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
284
+ t.Fatal(err)
285
+ }
286
+ writePackageExistenceFile(t, repo, test.sourcePath, test.addedSource)
287
+ t.Chdir(repo)
288
+
289
+ var stdout, stderr bytes.Buffer
290
+ gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
291
+ if gotCode != test.wantCode {
292
+ t.Fatalf("code = %d, want %d\nstdout: %s\nstderr: %s", gotCode, test.wantCode, stdout.String(), stderr.String())
293
+ }
294
+ if test.wantFinding != "" && !strings.Contains(stderr.String(), test.wantFinding) {
295
+ t.Fatalf("stderr does not name %q: %s", test.wantFinding, stderr.String())
296
+ }
297
+ })
298
+ }
299
+ }
300
+
301
+ func TestPackageExistenceGenericResolverFailsClosed(t *testing.T) {
302
+ repo := t.TempDir()
303
+ writePackageExistenceFile(t, repo, "package.json", `{"dependencies":{"definitely-missing-package":"1.0.0"}}`)
304
+ writePackageExistenceFile(t, repo, "src/example.custom", "// baseline\n")
305
+ gitPackageExistence(t, repo, "init", "-q")
306
+ gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
307
+ gitPackageExistence(t, repo, "config", "user.name", "Test")
308
+ gitPackageExistence(t, repo, "add", ".")
309
+ gitPackageExistence(t, repo, "commit", "-qm", "baseline")
310
+ if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
311
+ t.Fatal(err)
312
+ }
313
+ writePackageExistenceFile(t, repo, "src/example.custom", "import \"definitely-missing-package\"\n")
314
+ t.Chdir(repo)
315
+
316
+ var stdout, stderr bytes.Buffer
317
+ gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
318
+ if gotCode != 3 {
319
+ t.Fatalf("code = %d, want 3\nstdout: %s\nstderr: %s", gotCode, stdout.String(), stderr.String())
320
+ }
321
+ if !strings.Contains(stderr.String(), "definitely-missing-package") {
322
+ t.Fatalf("stderr does not name unresolved import: %s", stderr.String())
323
+ }
324
+ }
325
+
326
+ func writePackageExistenceFile(t *testing.T, root, path, content string) {
327
+ t.Helper()
328
+ fullPath := filepath.Join(root, filepath.FromSlash(path))
329
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
330
+ t.Fatal(err)
331
+ }
332
+ if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
333
+ t.Fatal(err)
334
+ }
335
+ }
336
+
337
+ func gitPackageExistence(t *testing.T, root string, args ...string) {
338
+ t.Helper()
339
+ cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
340
+ if out, err := cmd.CombinedOutput(); err != nil {
341
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
342
+ }
343
+ }
@@ -14,7 +14,7 @@ func gitToplevel(dir string) string {
14
14
  if err != nil {
15
15
  return ""
16
16
  }
17
- return strings.TrimSpace(string(out))
17
+ return filepath.Clean(strings.TrimSpace(string(out)))
18
18
  }
19
19
 
20
20
  // gitDiffNames lists the paths that differ, relative to gitRoot. With one ref it
@@ -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