devrites 4.3.0 → 4.4.1

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 (79) hide show
  1. package/CHANGELOG.md +12 -494
  2. package/README.md +4 -4
  3. package/docs/cli.md +22 -7
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/commands.md +24 -9
  6. package/docs/engine/workspace-schema.md +5 -5
  7. package/engine/commands.go +1 -1
  8. package/engine/internal/gate/gate.go +9 -0
  9. package/engine/internal/gate/gate_test.go +110 -0
  10. package/engine/internal/gate/readiness_binding.go +9 -0
  11. package/engine/internal/gate/readiness_binding_test.go +2 -2
  12. package/engine/internal/lib/observe_summary.go +22 -22
  13. package/engine/internal/lib/open_visual.go +254 -0
  14. package/engine/internal/lib/open_visual_test.go +280 -0
  15. package/engine/internal/lib/taskgraph.go +4 -143
  16. package/engine/internal/lib/taskgraph_test.go +203 -0
  17. package/engine/internal/lib/visual_outline.go +163 -0
  18. package/engine/internal/lib/visual_outline_test.go +161 -0
  19. package/engine/internal/state/taskgraph.go +213 -0
  20. package/engine/internal/testutil/testutil.go +3 -0
  21. package/engine/main.go +6 -2
  22. package/engine/root_routing_test.go +49 -14
  23. package/engine/testdata/visual/open-visual-smoke.html +675 -0
  24. package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
  25. package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
  26. package/engine/tests/gate_test.go +4 -2
  27. package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
  28. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  29. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  30. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  31. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  32. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  33. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  34. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  35. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  36. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  37. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  38. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  39. package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
  40. package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
  41. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  42. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
  43. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
  44. package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
  45. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  47. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  48. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  49. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  50. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  51. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  52. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  53. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  54. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  55. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  56. package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
  57. package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
  58. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  59. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
  60. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
  61. package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
  62. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  63. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  64. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  65. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  67. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  68. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  69. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  70. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  71. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  72. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  73. package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
  74. package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
  75. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
  76. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
  77. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
  78. package/package.json +1 -1
  79. package/scripts/install-lib.sh +1 -1
@@ -0,0 +1,163 @@
1
+ package lib
2
+
3
+ import (
4
+ "regexp"
5
+ "strings"
6
+ )
7
+
8
+ // htmlIDAttrRE matches id="..." or id='...' attributes without a full HTML parser.
9
+ var htmlIDAttrRE = regexp.MustCompile(`(?i)\bid\s*=\s*(?:"([^"]*)"|'([^']*)')`)
10
+
11
+ // VisualIDConsistency summarizes outline ## ID inventory vs HTML id attributes.
12
+ //
13
+ // Policy: warn only for inventory → HTML mismatches. HTML-only ids (for example
14
+ // SVG marker defs such as id="arrow") are treated as decorative and are not
15
+ // reported, to keep open-visual tips quiet on intentional landmark-only
16
+ // inventories.
17
+ type VisualIDConsistency struct {
18
+ Inventory []string
19
+ HTML []string
20
+ MissingInHTML []string
21
+ }
22
+
23
+ // ParseOutlineInventoryIDs extracts HTML ids from the outline ## ID inventory table.
24
+ func ParseOutlineInventoryIDs(outline string) []string {
25
+ section := extractMarkdownSection(outline, "ID inventory")
26
+ if section == "" {
27
+ return nil
28
+ }
29
+ return parseMarkdownFirstColumnIDs(section)
30
+ }
31
+
32
+ // ScanHTMLIDs returns unique id="..." / id='...' attribute values in document order.
33
+ func ScanHTMLIDs(html string) []string {
34
+ matches := htmlIDAttrRE.FindAllStringSubmatch(html, -1)
35
+ if len(matches) == 0 {
36
+ return nil
37
+ }
38
+ seen := make(map[string]struct{}, len(matches))
39
+ ids := make([]string, 0, len(matches))
40
+ for _, m := range matches {
41
+ id := m[1]
42
+ if id == "" {
43
+ id = m[2]
44
+ }
45
+ id = strings.TrimSpace(id)
46
+ if id == "" {
47
+ continue
48
+ }
49
+ if _, ok := seen[id]; ok {
50
+ continue
51
+ }
52
+ seen[id] = struct{}{}
53
+ ids = append(ids, id)
54
+ }
55
+ return ids
56
+ }
57
+
58
+ // CheckVisualIDConsistency compares outline inventory ids against HTML attributes.
59
+ func CheckVisualIDConsistency(html, outline string) VisualIDConsistency {
60
+ inventory := ParseOutlineInventoryIDs(outline)
61
+ htmlIDs := ScanHTMLIDs(html)
62
+ report := VisualIDConsistency{
63
+ Inventory: inventory,
64
+ HTML: htmlIDs,
65
+ }
66
+ if len(inventory) == 0 {
67
+ return report
68
+ }
69
+ htmlSet := make(map[string]struct{}, len(htmlIDs))
70
+ for _, id := range htmlIDs {
71
+ htmlSet[id] = struct{}{}
72
+ }
73
+ for _, id := range inventory {
74
+ if _, ok := htmlSet[id]; !ok {
75
+ report.MissingInHTML = append(report.MissingInHTML, id)
76
+ }
77
+ }
78
+ return report
79
+ }
80
+
81
+ func extractMarkdownSection(md, heading string) string {
82
+ lines := strings.Split(md, "\n")
83
+ start := -1
84
+ for i, line := range lines {
85
+ trimmed := strings.TrimSpace(line)
86
+ if !strings.HasPrefix(trimmed, "## ") {
87
+ continue
88
+ }
89
+ h := strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
90
+ if strings.EqualFold(h, heading) {
91
+ start = i + 1
92
+ continue
93
+ }
94
+ if start >= 0 {
95
+ return strings.Join(lines[start:i], "\n")
96
+ }
97
+ }
98
+ if start < 0 {
99
+ return ""
100
+ }
101
+ return strings.Join(lines[start:], "\n")
102
+ }
103
+
104
+ func parseMarkdownFirstColumnIDs(section string) []string {
105
+ var ids []string
106
+ seen := make(map[string]struct{})
107
+ for _, line := range strings.Split(section, "\n") {
108
+ line = strings.TrimSpace(line)
109
+ if !strings.HasPrefix(line, "|") {
110
+ continue
111
+ }
112
+ cells := splitMarkdownRow(line)
113
+ if len(cells) == 0 {
114
+ continue
115
+ }
116
+ raw := strings.TrimSpace(cells[0])
117
+ if raw == "" || isMarkdownSeparatorCell(raw) {
118
+ continue
119
+ }
120
+ normalizedHeader := strings.ToLower(strings.ReplaceAll(raw, "`", ""))
121
+ normalizedHeader = strings.Join(strings.Fields(normalizedHeader), " ")
122
+ if normalizedHeader == "html id" || normalizedHeader == "id" {
123
+ continue
124
+ }
125
+ id := strings.TrimSpace(strings.Trim(raw, "`"))
126
+ if id == "" {
127
+ continue
128
+ }
129
+ if _, ok := seen[id]; ok {
130
+ continue
131
+ }
132
+ seen[id] = struct{}{}
133
+ ids = append(ids, id)
134
+ }
135
+ return ids
136
+ }
137
+
138
+ func splitMarkdownRow(line string) []string {
139
+ line = strings.TrimSpace(line)
140
+ line = strings.TrimPrefix(line, "|")
141
+ line = strings.TrimSuffix(line, "|")
142
+ parts := strings.Split(line, "|")
143
+ cells := make([]string, 0, len(parts))
144
+ for _, part := range parts {
145
+ cells = append(cells, strings.TrimSpace(part))
146
+ }
147
+ return cells
148
+ }
149
+
150
+ func isMarkdownSeparatorCell(cell string) bool {
151
+ if cell == "" {
152
+ return false
153
+ }
154
+ for _, r := range cell {
155
+ switch r {
156
+ case '-', ':', ' ':
157
+ continue
158
+ default:
159
+ return false
160
+ }
161
+ }
162
+ return strings.ContainsRune(cell, '-')
163
+ }
@@ -0,0 +1,161 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestParseOutlineInventoryIDs(t *testing.T) {
11
+ outline := `# Title
12
+
13
+ ## Purpose
14
+ demo
15
+
16
+ ## ID inventory
17
+
18
+ | HTML ` + "`id`" + ` | Meaning |
19
+ | --- | --- |
20
+ | ` + "`viz-title`" + ` | Header |
21
+ | ` + "`diagram-overview`" + ` | Overview |
22
+ | viz-title | duplicate ignored |
23
+
24
+ ## Relationships
25
+ | From | To | Note |
26
+ | --- | --- | --- |
27
+ | a | b | c |
28
+ `
29
+ got := ParseOutlineInventoryIDs(outline)
30
+ want := []string{"viz-title", "diagram-overview"}
31
+ if len(got) != len(want) {
32
+ t.Fatalf("ids = %#v, want %#v", got, want)
33
+ }
34
+ for i := range want {
35
+ if got[i] != want[i] {
36
+ t.Fatalf("ids = %#v, want %#v", got, want)
37
+ }
38
+ }
39
+ }
40
+
41
+ func TestScanHTMLIDs(t *testing.T) {
42
+ html := `<!doctype html><div id="viz-title"></div><svg><marker id='arrow'></marker><g id="viz-title"></g></svg>`
43
+ got := ScanHTMLIDs(html)
44
+ want := []string{"viz-title", "arrow"}
45
+ if len(got) != len(want) {
46
+ t.Fatalf("ids = %#v, want %#v", got, want)
47
+ }
48
+ for i := range want {
49
+ if got[i] != want[i] {
50
+ t.Fatalf("ids = %#v, want %#v", got, want)
51
+ }
52
+ }
53
+ }
54
+
55
+ func TestCheckVisualIDConsistencyMatch(t *testing.T) {
56
+ html := `<section id="viz-title"></section><section id="diagram-overview"></section><marker id="arrow"></marker>`
57
+ outline := `## ID inventory
58
+ | HTML id | Meaning |
59
+ | --- | --- |
60
+ | viz-title | Header |
61
+ | diagram-overview | Overview |
62
+ `
63
+ report := CheckVisualIDConsistency(html, outline)
64
+ if len(report.MissingInHTML) != 0 {
65
+ t.Fatalf("MissingInHTML = %#v, want empty", report.MissingInHTML)
66
+ }
67
+ if len(report.Inventory) != 2 {
68
+ t.Fatalf("Inventory = %#v, want 2 ids", report.Inventory)
69
+ }
70
+ // HTML-only decorative ids are not reported.
71
+ if len(report.HTML) != 3 {
72
+ t.Fatalf("HTML = %#v, want viz-title, diagram-overview, arrow", report.HTML)
73
+ }
74
+ }
75
+
76
+ func TestCheckVisualIDConsistencyMissingInHTML(t *testing.T) {
77
+ html := `<section id="viz-title"></section>`
78
+ outline := `## ID inventory
79
+ | HTML id | Meaning |
80
+ | --- | --- |
81
+ | viz-title | Header |
82
+ | viz-open-questions | Open questions |
83
+ `
84
+ report := CheckVisualIDConsistency(html, outline)
85
+ if len(report.MissingInHTML) != 1 || report.MissingInHTML[0] != "viz-open-questions" {
86
+ t.Fatalf("MissingInHTML = %#v, want [viz-open-questions]", report.MissingInHTML)
87
+ }
88
+ }
89
+
90
+ func TestCheckVisualIDConsistencyMissingOutlineSection(t *testing.T) {
91
+ report := CheckVisualIDConsistency(`<div id="x"></div>`, "# Title\n\ndemo\n")
92
+ if len(report.Inventory) != 0 || len(report.MissingInHTML) != 0 {
93
+ t.Fatalf("report = %#v, want empty inventory/missing", report)
94
+ }
95
+ }
96
+
97
+ func TestOpenVisualWarnsIDMismatch(t *testing.T) {
98
+ root, html := writeOpenVisualFixture(t, true)
99
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
100
+
101
+ outline := strings.TrimSuffix(html, ".html") + ".outline.md"
102
+ writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
103
+
104
+ ## ID inventory
105
+ | HTML id | Meaning |
106
+ | --- | --- |
107
+ | viz-title | Header |
108
+ | missing-node | Absent from HTML |
109
+ `)
110
+ writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section>
111
+ `)
112
+
113
+ restore := swapOpenVisualOpener(func(string) error { return nil })
114
+ defer restore()
115
+
116
+ var stdout, stderr bytes.Buffer
117
+ code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
118
+ if code != 0 {
119
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
120
+ }
121
+ if !strings.Contains(stderr.String(), "outline inventory id(s) missing from HTML") {
122
+ t.Fatalf("stderr = %q, want missing-id warning", stderr.String())
123
+ }
124
+ if !strings.Contains(stderr.String(), "missing-node") {
125
+ t.Fatalf("stderr = %q, want missing-node listed", stderr.String())
126
+ }
127
+ if !strings.Contains(stdout.String(), "open-visual: ids=mismatch") {
128
+ t.Fatalf("stdout = %q, want ids=mismatch tip", stdout.String())
129
+ }
130
+ }
131
+
132
+ func TestOpenVisualIDsOkTip(t *testing.T) {
133
+ root, html := writeOpenVisualFixture(t, true)
134
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
135
+
136
+ outline := strings.TrimSuffix(html, ".html") + ".outline.md"
137
+ writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
138
+
139
+ ## ID inventory
140
+ | HTML id | Meaning |
141
+ | --- | --- |
142
+ | viz-title | Header |
143
+ `)
144
+ writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section><marker id="arrow"></marker>
145
+ `)
146
+
147
+ restore := swapOpenVisualOpener(func(string) error { return nil })
148
+ defer restore()
149
+
150
+ var stdout, stderr bytes.Buffer
151
+ code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
152
+ if code != 0 {
153
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
154
+ }
155
+ if stderr.Len() != 0 {
156
+ t.Fatalf("stderr = %q, want empty when consistent", stderr.String())
157
+ }
158
+ if !strings.Contains(stdout.String(), "open-visual: ids=ok (1 inventory)") {
159
+ t.Fatalf("stdout = %q, want ids=ok tip", stdout.String())
160
+ }
161
+ }
@@ -0,0 +1,213 @@
1
+ package state
2
+
3
+ import (
4
+ "fmt"
5
+ "regexp"
6
+ "strings"
7
+ "unicode"
8
+ )
9
+
10
+ // TaskSlice is one SLICE-### block parsed from tasks.md.
11
+ type TaskSlice struct {
12
+ ID string
13
+ Dependencies []string
14
+ }
15
+
16
+ // TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
17
+ type TaskGraphResult struct {
18
+ Slices []TaskSlice
19
+ Cycle []string // empty when acyclic
20
+ Unknown []string // dependency ids with no defining slice
21
+ Problems []string // human-readable blockers
22
+ }
23
+
24
+ var (
25
+ sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
26
+ dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
27
+ dependsOnRE = regexp.MustCompile(`(?m)^depends_on:\s*(.+)\s*$`)
28
+ sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
29
+ )
30
+
31
+ // ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
32
+ func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
33
+ text := string(tasksMarkdown)
34
+ result := TaskGraphResult{}
35
+ if strings.TrimSpace(text) == "" {
36
+ result.Problems = append(result.Problems, "tasks.md is empty")
37
+ return result
38
+ }
39
+
40
+ headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
41
+ if len(headers) == 0 {
42
+ result.Problems = append(result.Problems, "no SLICE-### sections found")
43
+ return result
44
+ }
45
+
46
+ known := make(map[string]bool, len(headers))
47
+ firstIndex := make(map[string]int, len(headers))
48
+ for i, match := range headers {
49
+ id := text[match[2]:match[3]]
50
+ if _, seen := firstIndex[id]; seen {
51
+ result.Problems = append(result.Problems, fmt.Sprintf("duplicate slice id %s", id))
52
+ continue
53
+ }
54
+ firstIndex[id] = i
55
+ known[id] = true
56
+ }
57
+
58
+ for i, match := range headers {
59
+ id := text[match[2]:match[3]]
60
+ if firstIndex[id] != i {
61
+ continue
62
+ }
63
+ start := match[1]
64
+ end := len(text)
65
+ if i+1 < len(headers) {
66
+ end = headers[i+1][0]
67
+ }
68
+ block := text[start:end]
69
+ deps, problems := parseSliceGraph(id, block)
70
+ result.Problems = append(result.Problems, problems...)
71
+ result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
72
+ for _, dep := range deps {
73
+ if !known[dep] {
74
+ result.Unknown = append(result.Unknown, dep)
75
+ result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
76
+ }
77
+ }
78
+ }
79
+
80
+ if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
81
+ result.Cycle = cycle
82
+ result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
83
+ }
84
+ return result
85
+ }
86
+
87
+ func parseSliceGraph(id, block string) ([]string, []string) {
88
+ var problems []string
89
+ depsLine := firstLineValue(dependenciesRE, block)
90
+ mirrorLine := firstLineValue(dependsOnRE, block)
91
+ deps, malformed := parseIDList(depsLine)
92
+ for _, token := range malformed {
93
+ problems = append(problems, fmt.Sprintf("%s has malformed dependency %q", id, token))
94
+ }
95
+ mirror, mirrorMalformed := parseIDList(mirrorLine)
96
+ for _, token := range mirrorMalformed {
97
+ problems = append(problems, fmt.Sprintf("%s has malformed depends_on %q", id, token))
98
+ }
99
+ if depsLine != "" && mirrorLine != "" && !sameIDSet(deps, mirror) {
100
+ problems = append(problems, fmt.Sprintf("%s Dependencies and depends_on sets differ", id))
101
+ }
102
+ if depsLine == "" && mirrorLine == "" {
103
+ problems = append(problems, fmt.Sprintf("%s is missing Dependencies", id))
104
+ }
105
+ if depsLine == "" {
106
+ deps = mirror
107
+ }
108
+ return deps, problems
109
+ }
110
+
111
+ func firstLineValue(re *regexp.Regexp, block string) string {
112
+ match := re.FindStringSubmatch(block)
113
+ if len(match) < 2 {
114
+ return ""
115
+ }
116
+ return strings.TrimSpace(match[1])
117
+ }
118
+
119
+ func parseIDList(raw string) ([]string, []string) {
120
+ raw = strings.TrimSpace(raw)
121
+ raw = strings.Trim(raw, "[]")
122
+ raw = strings.TrimSpace(raw)
123
+ if raw == "" || strings.EqualFold(raw, "none") {
124
+ return nil, nil
125
+ }
126
+ parts := strings.FieldsFunc(raw, func(r rune) bool {
127
+ return r == ',' || r == ';' || unicode.IsSpace(r)
128
+ })
129
+ var ids []string
130
+ var malformed []string
131
+ seen := make(map[string]bool)
132
+ for _, part := range parts {
133
+ id := strings.Trim(strings.TrimSpace(part), "`'\"(){}")
134
+ if id == "" {
135
+ continue
136
+ }
137
+ if !sliceIDValidRE.MatchString(id) {
138
+ malformed = append(malformed, id)
139
+ continue
140
+ }
141
+ if seen[id] {
142
+ continue
143
+ }
144
+ seen[id] = true
145
+ ids = append(ids, id)
146
+ }
147
+ return ids, malformed
148
+ }
149
+
150
+ func sameIDSet(a, b []string) bool {
151
+ if len(a) != len(b) {
152
+ return false
153
+ }
154
+ seen := make(map[string]bool, len(a))
155
+ for _, id := range a {
156
+ seen[id] = true
157
+ }
158
+ for _, id := range b {
159
+ if !seen[id] {
160
+ return false
161
+ }
162
+ }
163
+ return true
164
+ }
165
+
166
+ func findTaskCycle(slices []TaskSlice) []string {
167
+ graph := make(map[string][]string, len(slices))
168
+ for _, slice := range slices {
169
+ graph[slice.ID] = append([]string(nil), slice.Dependencies...)
170
+ }
171
+ visited := make(map[string]uint8, len(slices))
172
+ stack := make([]string, 0, len(slices))
173
+ var cycle []string
174
+
175
+ var visit func(id string) bool
176
+ visit = func(id string) bool {
177
+ switch visited[id] {
178
+ case 2:
179
+ return false
180
+ case 1:
181
+ for i := len(stack) - 1; i >= 0; i-- {
182
+ cycle = append(cycle, stack[i])
183
+ if stack[i] == id {
184
+ break
185
+ }
186
+ }
187
+ for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
188
+ cycle[i], cycle[j] = cycle[j], cycle[i]
189
+ }
190
+ return true
191
+ }
192
+ visited[id] = 1
193
+ stack = append(stack, id)
194
+ for _, dep := range graph[id] {
195
+ if visit(dep) {
196
+ return true
197
+ }
198
+ }
199
+ stack = stack[:len(stack)-1]
200
+ visited[id] = 2
201
+ return false
202
+ }
203
+
204
+ for _, slice := range slices {
205
+ if visited[slice.ID] == 0 {
206
+ cycle = nil
207
+ if visit(slice.ID) {
208
+ return cycle
209
+ }
210
+ }
211
+ }
212
+ return nil
213
+ }
@@ -7,6 +7,9 @@ import (
7
7
  "testing"
8
8
  )
9
9
 
10
+ // CanonicalTasksMarkdown is a minimal valid slice graph for gate fixtures.
11
+ const CanonicalTasksMarkdown = "# Tasks\n\n## SLICE-001 Ready\nDependencies: none\n"
12
+
10
13
  // CopyTree recursively copies the directory tree at src into dst, creating dst
11
14
  // and any parents. It is used to give each test an isolated, writable copy of a
12
15
  // read-only fixture.
package/engine/main.go CHANGED
@@ -22,9 +22,9 @@ Usage:
22
22
  devrites-engine update [flags] Update an existing DevRites install in place
23
23
  devrites-engine uninstall [flags] Remove a DevRites install, preserving runtime state
24
24
  devrites-engine check candidate <slug> Validate and hash the closed project candidate
25
- devrites-engine check readiness <slug> Check required files and the stable Build-input binding
25
+ devrites-engine check readiness <slug> Check required files, tasks.md graph, and Build-input binding
26
26
  devrites-engine check readiness --emit-binding <slug> Emit the stable Build-input binding for Vet
27
- devrites-engine check seal <slug> Recheck the Build-input binding, final files, and evidence freshness
27
+ devrites-engine check seal <slug> Recheck files, tasks.md graph, Build-input binding, and evidence freshness
28
28
  devrites-engine check path-disjoint [--root <dir>] [<json-file>|-]
29
29
  Verify slice path sets are pairwise disjoint
30
30
  devrites-engine check task-graph <slug> Validate tasks.md slice dependency graph
@@ -34,6 +34,8 @@ Usage:
34
34
  devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
35
35
  devrites-engine state close <slug> Archive a shipped feature and clear ACTIVE
36
36
  devrites-engine secret-scan [--staged] [--stdin] [slug] Scan exact staged blobs, stdin, or touched files; HIGH blocks
37
+ devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
38
+ Resolve a visual HTML file, optionally open it locally, print agent paths
37
39
  devrites-engine version Print the engine binary's version
38
40
  Exit codes:
39
41
  0 ok / gate passed
@@ -87,6 +89,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
87
89
  return cmdState(root, args[1:], stdout, stderr)
88
90
  case "secret-scan":
89
91
  return lib.SecretScan(root, args[1:], stdin, stdout, stderr)
92
+ case "open-visual":
93
+ return lib.OpenVisual(root, args[1:], stdout, stderr)
90
94
  case "version", "--version":
91
95
  fmt.Fprintln(stdout, version.Version)
92
96
  return exitOK
@@ -21,6 +21,7 @@ func TestRootModeForCoversReadAndWriteSurfaces(t *testing.T) {
21
21
  {name: "close", command: "state", args: []string{"close"}, want: rootStrict},
22
22
  {name: "unknown state command", command: "state", args: []string{"unknown"}, want: rootUnused},
23
23
  {name: "secret scan", command: "secret-scan", want: rootLenient},
24
+ {name: "open visual", command: "open-visual", want: rootLenient},
24
25
  }
25
26
 
26
27
  for _, test := range tests {
@@ -102,9 +103,9 @@ func TestNestedCommandFamiliesAreRoutedAndAdvertised(t *testing.T) {
102
103
  for _, args := range [][]string{{"check", "candidate"}, {"check", "readiness"}, {"check", "seal"}, {"state", "resolve"}, {"state", "close"}} {
103
104
  t.Run(strings.Join(args, "-"), func(t *testing.T) {
104
105
  var stdout, stderr bytes.Buffer
105
- _ = run(args, strings.NewReader(""), &stdout, &stderr)
106
+ code := run(args, strings.NewReader(""), &stdout, &stderr)
106
107
  if strings.Contains(stderr.String(), "unknown command") || strings.Contains(stderr.String(), "unknown check") || strings.Contains(stderr.String(), "unknown state") {
107
- t.Fatalf("%q was not routed: %s", args, stderr.String())
108
+ t.Fatalf("%q was not routed (exit=%d): %s", args, code, stderr.String())
108
109
  }
109
110
  })
110
111
  }
@@ -153,13 +154,9 @@ func TestCheckCandidateRoutesAndPrintsIdentity(t *testing.T) {
153
154
  if err := os.MkdirAll(workspace, 0o755); err != nil {
154
155
  t.Fatal(err)
155
156
  }
156
- if err := os.WriteFile(filepath.Join(project, "source.go"), []byte("package source\n"), 0o644); err != nil {
157
- t.Fatal(err)
158
- }
157
+ writeBasenameFile(t, project, "source.go", "package source\n")
159
158
  manifest := "# Touched files\n\n## Touched files\nCandidate paths are declared below.\n\n## Candidate manifest\n| State | File | Slice | Reason |\n| --- | --- | --- | --- |\n| present | `source.go` | S-1 | Implementation. |\n"
160
- if err := os.WriteFile(filepath.Join(workspace, "touched-files.md"), []byte(manifest), 0o644); err != nil {
161
- t.Fatal(err)
162
- }
159
+ writeBasenameFile(t, workspace, "touched-files.md", manifest)
163
160
  t.Setenv("DEVRITES_ROOT", root)
164
161
  var stdout, stderr bytes.Buffer
165
162
  if code := run([]string{"check", "candidate", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitOK {
@@ -186,9 +183,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
186
183
  if err := os.MkdirAll(workspace, 0o755); err != nil {
187
184
  t.Fatal(err)
188
185
  }
189
- if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o644); err != nil {
190
- t.Fatal(err)
191
- }
186
+ writeBasenameFile(t, workspace, name, body)
192
187
  }
193
188
  t.Setenv("DEVRITES_ROOT", root)
194
189
 
@@ -217,9 +212,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
217
212
  }
218
213
  }
219
214
 
220
- if err := os.Remove(filepath.Join(workspace, "plan.md")); err != nil {
221
- t.Fatal(err)
222
- }
215
+ removeBasename(t, workspace, "plan.md")
223
216
  stdout.Reset()
224
217
  stderr.Reset()
225
218
  if code := run([]string{"check", "readiness", "--emit-binding", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitBlocked || stdout.Len() != 0 || !strings.Contains(stderr.String(), "readiness-binding: BLOCKED") {
@@ -245,3 +238,45 @@ func TestSecretScanRoutesPRBodyThroughStdin(t *testing.T) {
245
238
  t.Fatal("secret material disclosed")
246
239
  }
247
240
  }
241
+
242
+ // writeBasenameFile writes contents under dir using only filepath.Base(name),
243
+ // via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
244
+ func writeBasenameFile(t *testing.T, dir, name, contents string) string {
245
+ t.Helper()
246
+ base := filepath.Base(name)
247
+ dst := filepath.Join(dir, base)
248
+ tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
249
+ if err != nil {
250
+ t.Fatal(err)
251
+ }
252
+ written, werr := tmp.WriteString(contents)
253
+ if werr != nil {
254
+ if cerr := tmp.Close(); cerr != nil {
255
+ t.Fatalf("write: %v; close: %v", werr, cerr)
256
+ }
257
+ t.Fatal(werr)
258
+ }
259
+ if written != len(contents) {
260
+ if cerr := tmp.Close(); cerr != nil {
261
+ t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
262
+ }
263
+ t.Fatalf("short write %d/%d", written, len(contents))
264
+ }
265
+ if err := tmp.Close(); err != nil {
266
+ t.Fatal(err)
267
+ }
268
+ if err := os.Rename(tmp.Name(), dst); err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ return dst
272
+ }
273
+
274
+ // removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
275
+ func removeBasename(t *testing.T, dir, name string) {
276
+ t.Helper()
277
+ src := filepath.Join(dir, filepath.Base(name))
278
+ dst := filepath.Join(t.TempDir(), filepath.Base(name))
279
+ if err := os.Rename(src, dst); err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ }