devrites 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +4 -0
  4. package/docs/orchestration.md +6 -3
  5. package/engine/internal/lib/cli_observe.go +66 -0
  6. package/engine/internal/lib/observe_summary.go +79 -0
  7. package/engine/internal/lib/skilltrust.go +196 -0
  8. package/engine/internal/lib/taskgraph.go +162 -0
  9. package/engine/internal/lib/taskgraph_test.go +150 -0
  10. package/engine/internal/lib/workspace_read.go +29 -0
  11. package/engine/internal/parallel/cli.go +467 -0
  12. package/engine/internal/parallel/git.go +181 -0
  13. package/engine/internal/parallel/lease.go +244 -0
  14. package/engine/internal/parallel/ops.go +416 -0
  15. package/engine/internal/parallel/parallel_test.go +242 -0
  16. package/engine/internal/parallel/pathdisjoint.go +129 -0
  17. package/engine/internal/parallel/pathdisjoint_test.go +97 -0
  18. package/engine/internal/state/status.go +41 -16
  19. package/engine/main.go +72 -3
  20. package/engine/root_routing_test.go +1 -1
  21. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  22. package/pack/.claude/skills/rite-build/SKILL.md +29 -36
  23. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
  24. package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  25. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  26. package/pack/.claude/skills/rite-build/reference/output.md +2 -0
  27. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
  28. package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
  29. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  30. package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  31. package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
  32. package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
  33. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  34. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  35. package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
  36. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
  37. package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  38. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  39. package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
  40. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
  41. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
  42. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  43. package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  44. package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
  45. package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
  46. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  47. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  48. package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
  49. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
  50. package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  51. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
  52. package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
  53. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
  54. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
  55. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
  56. package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
  57. package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
  58. package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
  59. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
  60. package/package.json +1 -1
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
+ ## [4.3.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.2.0...v4.3.0) (2026-08-25)
6
+
7
+ ### Added
8
+
9
+ * **devrites:** add skill-trust, task-graph, and observe commands ([#38](https://github.com/ViktorsBaikers/DevRites/issues/38)) ([2562355](https://github.com/ViktorsBaikers/DevRites/commit/2562355a970726fb95e57e2f328961ca136c9f81))
10
+ * **rite-build:** opt-in parallel worktree slices (N≤3) ([#37](https://github.com/ViktorsBaikers/DevRites/issues/37)) ([fe71d67](https://github.com/ViktorsBaikers/DevRites/commit/fe71d672408559af030e95f700b18ead798f18fc))
11
+
5
12
  ## [4.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.1.0...v4.2.0) (2026-08-23)
6
13
 
7
14
  ### Added
package/README.md CHANGED
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
28
28
  Unattended runs may create local WIP checkpoint commits along the way, but they
29
29
  remain local unless Ship's disclosed plan includes an approved remote action.
30
30
 
31
- **Status:** [`v4.2.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.2.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
31
+ **Status:** [`v4.3.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.3.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
32
32
 
33
33
  This is the latest published release; `main` may contain unreleased work.
34
34
 
@@ -15,6 +15,10 @@ workflow strategy.
15
15
  | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
16
16
  | `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
17
17
  | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
18
+ | `check path-disjoint [--root <dir>] [<json-file>|-]` | Verify slice path sets are pairwise disjoint. |
19
+ | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
20
+ | `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
21
+ | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
18
22
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
19
23
  | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
20
24
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
@@ -126,9 +126,12 @@ base before native transfer, then re-proves the reconciled candidate. Missing tr
126
126
  conflict, extra paths, moved base, or cleanup failure stops with worktree evidence
127
127
  preserved. No ad hoc copy/cherry-pick/merge occurs from the read-only root.
128
128
 
129
- Isolation does not authorize throughput. At most one writer runs across all linked
130
- worktrees for the workspace; parallel writers remain forbidden until serial transfer,
131
- conflict, proof, and review measurements justify a separate design.
129
+ Isolation does not authorize same-worktree throughput. Parallel writers are allowed
130
+ **only** under `/rite-build --parallel N` when path-disjoint eligibility, abort-batch,
131
+ and a control `parallel-lease.md` apply (see
132
+ [`parallel-batch.md`](../pack/.claude/skills/rite-build/reference/parallel-batch.md)).
133
+ Same-worktree multi-writer and root-emulated worktrees remain forbidden; default
134
+ `/rite-build` stays one writer across linked worktrees.
132
135
 
133
136
  ## Engine boundary
134
137
 
@@ -0,0 +1,66 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+
7
+ "github.com/devrites/devrites/internal/devritespaths"
8
+ )
9
+
10
+ // RunTaskGraphCheck validates tasks.md for one slug.
11
+ func RunTaskGraphCheck(root, slug string, stdout, stderr io.Writer) int {
12
+ graph, err := CheckTaskGraph(root, slug)
13
+ if err != nil {
14
+ fmt.Fprintf(stderr, "task-graph: %v\n", err)
15
+ return 2
16
+ }
17
+ if len(graph.Problems) > 0 {
18
+ for _, problem := range graph.Problems {
19
+ fmt.Fprintf(stdout, "task-graph: BLOCKED: %s\n", problem)
20
+ }
21
+ return 3
22
+ }
23
+ fmt.Fprintf(stdout, "task-graph: ok (%d slices)\n", len(graph.Slices))
24
+ return 0
25
+ }
26
+
27
+ // RunSkillTrustCheck scans one Markdown path.
28
+ func RunSkillTrustCheck(path string, stdout, stderr io.Writer) int {
29
+ result, err := ScanSkillTrust(path)
30
+ if err != nil {
31
+ fmt.Fprintf(stderr, "skill-trust: %v\n", err)
32
+ return 2
33
+ }
34
+ fmt.Fprint(stdout, FormatSkillTrust(result))
35
+ if SkillTrustBlocks(result.Findings) {
36
+ return 3
37
+ }
38
+ return 0
39
+ }
40
+
41
+ // RunObserveSummary emits JSON for one slug.
42
+ func RunObserveSummary(root, slug string, stdout, stderr io.Writer) int {
43
+ if err := WriteObserveSummaryJSON(root, slug, stdout); err != nil {
44
+ fmt.Fprintf(stderr, "observe: %v\n", err)
45
+ return 2
46
+ }
47
+ return 0
48
+ }
49
+
50
+ // ActiveSlug resolves slug from args or ACTIVE pointer.
51
+ func ActiveSlug(root string, args []string) (string, int, error) {
52
+ if len(args) == 1 {
53
+ return args[0], 0, nil
54
+ }
55
+ if len(args) != 0 {
56
+ return "", 2, fmt.Errorf("expected one slug argument")
57
+ }
58
+ slug, err := devritespaths.ActiveSlug(root)
59
+ if err != nil {
60
+ return "", 2, err
61
+ }
62
+ if slug == "" {
63
+ return "", 2, fmt.Errorf("no slug and ACTIVE is empty")
64
+ }
65
+ return slug, 0, nil
66
+ }
@@ -0,0 +1,79 @@
1
+ package lib
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+
7
+ "github.com/devrites/devrites/internal/state"
8
+ )
9
+
10
+ // ObserveSummary is a sanitized, machine-readable workspace snapshot.
11
+ type ObserveSummary struct {
12
+ Slug string `json:"slug"`
13
+ Phase string `json:"phase,omitempty"`
14
+ Status string `json:"status,omitempty"`
15
+ NextAction string `json:"next_action,omitempty"`
16
+ MissingSections []string `json:"missing_sections,omitempty"`
17
+ MissingFiles []string `json:"missing_files,omitempty"`
18
+ PrinciplesPresent bool `json:"principles_present"`
19
+ TaskGraph *struct {
20
+ SliceCount int `json:"slice_count"`
21
+ Cycle []string `json:"cycle,omitempty"`
22
+ Unknown []string `json:"unknown_dependencies,omitempty"`
23
+ OK bool `json:"ok"`
24
+ } `json:"task_graph,omitempty"`
25
+ }
26
+
27
+ // ObserveSummaryFor builds a summary for one feature slug.
28
+ func ObserveSummaryFor(root, slug string) (ObserveSummary, error) {
29
+ report, err := state.Status(root, slug)
30
+ if err != nil {
31
+ return ObserveSummary{}, err
32
+ }
33
+ summary := ObserveSummary{
34
+ Slug: report.Slug,
35
+ Phase: string(report.Phase),
36
+ Status: report.Status,
37
+ NextAction: report.NextAction,
38
+ PrinciplesPresent: report.PrinciplesPresent,
39
+ MissingFiles: append([]string(nil), report.MissingFiles...),
40
+ MissingSections: missingSectionNames(report.Missing),
41
+ }
42
+
43
+ if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && len(graph.Slices) > 0 {
44
+ summary.TaskGraph = &struct {
45
+ SliceCount int `json:"slice_count"`
46
+ Cycle []string `json:"cycle,omitempty"`
47
+ Unknown []string `json:"unknown_dependencies,omitempty"`
48
+ OK bool `json:"ok"`
49
+ }{
50
+ SliceCount: len(graph.Slices),
51
+ Cycle: graph.Cycle,
52
+ Unknown: graph.Unknown,
53
+ OK: len(graph.Problems) == 0,
54
+ }
55
+ }
56
+ return summary, nil
57
+ }
58
+
59
+ func missingSectionNames(sections []state.Section) []string {
60
+ if len(sections) == 0 {
61
+ return nil
62
+ }
63
+ out := make([]string, len(sections))
64
+ for i, section := range sections {
65
+ out[i] = string(section)
66
+ }
67
+ return out
68
+ }
69
+
70
+ // WriteObserveSummaryJSON prints one JSON object to stdout.
71
+ func WriteObserveSummaryJSON(root, slug string, stdout io.Writer) error {
72
+ summary, err := ObserveSummaryFor(root, slug)
73
+ if err != nil {
74
+ return err
75
+ }
76
+ enc := json.NewEncoder(stdout)
77
+ enc.SetIndent("", " ")
78
+ return enc.Encode(summary)
79
+ }
@@ -0,0 +1,196 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+ "regexp"
9
+ "strings"
10
+ "unicode"
11
+ )
12
+
13
+ // SkillTrustFinding is one deterministic trust scan result.
14
+ type SkillTrustFinding struct {
15
+ Severity string // HIGH or MEDIUM
16
+ Rule string
17
+ Line int
18
+ Detail string
19
+ }
20
+
21
+ // SkillTrustResult summarizes a skill-trust scan.
22
+ type SkillTrustResult struct {
23
+ Path string
24
+ Findings []SkillTrustFinding
25
+ }
26
+
27
+ var skillTrustRules = []struct {
28
+ severity string
29
+ rule string
30
+ re *regexp.Regexp
31
+ detail string
32
+ }{
33
+ {
34
+ severity: "HIGH",
35
+ rule: "prompt_injection_disregard",
36
+ re: regexp.MustCompile(`(?i)\b(disregard|ignore)\b.{0,40}\b(prior|previous|above|system|instruction|rule|constraint|safety)\b`),
37
+ detail: "instruction attempts to override prior constraints",
38
+ },
39
+ {
40
+ severity: "HIGH",
41
+ rule: "credential_exfil_curl_env",
42
+ re: regexp.MustCompile(`(?i)\bcurl\b.{0,120}\$(?:ENV|HOME|[A-Z_][A-Z0-9_]*)`),
43
+ detail: "curl command references environment variables (possible exfiltration)",
44
+ },
45
+ {
46
+ severity: "HIGH",
47
+ rule: "sensitive_path_reference",
48
+ re: regexp.MustCompile(`(?i)(~/.ssh|~/.aws|/etc/passwd|id_rsa|\.pem\b|api[_-]?key\s*[:=])`),
49
+ detail: "references sensitive filesystem or credential material",
50
+ },
51
+ {
52
+ severity: "MEDIUM",
53
+ rule: "hidden_remote_fetch",
54
+ re: regexp.MustCompile(`(?i)\b(curl|wget|fetch)\b.{0,80}\b(http|https)://`),
55
+ detail: "network fetch instruction in skill prose",
56
+ },
57
+ }
58
+
59
+ // ScanSkillTrust reads one Markdown skill/agent file and returns structural trust findings.
60
+ func ScanSkillTrust(path string) (SkillTrustResult, error) {
61
+ info, err := os.Stat(path)
62
+ if err != nil {
63
+ return SkillTrustResult{}, err
64
+ }
65
+ if info.IsDir() {
66
+ return SkillTrustResult{}, fmt.Errorf("skill-trust: %q is a directory", path)
67
+ }
68
+ if info.Size() > 1<<20 {
69
+ return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
70
+ }
71
+ file, err := os.Open(path)
72
+ if err != nil {
73
+ return SkillTrustResult{}, err
74
+ }
75
+ defer file.Close()
76
+ raw, err := io.ReadAll(io.LimitReader(file, 1<<20+1))
77
+ if err != nil {
78
+ return SkillTrustResult{}, err
79
+ }
80
+ if len(raw) > 1<<20 {
81
+ return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
82
+ }
83
+ return SkillTrustResult{Path: filepath.Clean(path), Findings: analyzeSkillTrustContent(string(raw))}, nil
84
+ }
85
+
86
+ func analyzeSkillTrustContent(text string) []SkillTrustFinding {
87
+ var findings []SkillTrustFinding
88
+ lines := strings.Split(text, "\n")
89
+ for i, line := range lines {
90
+ lineNum := i + 1
91
+ for _, r := range line {
92
+ if isSuspiciousUnicode(r) {
93
+ findings = append(findings, SkillTrustFinding{
94
+ Severity: "HIGH",
95
+ Rule: "suspicious_unicode",
96
+ Line: lineNum,
97
+ Detail: fmt.Sprintf("suspicious Unicode U+%04X", r),
98
+ })
99
+ break
100
+ }
101
+ }
102
+ for _, rule := range skillTrustRules {
103
+ if rule.re.MatchString(line) {
104
+ findings = append(findings, SkillTrustFinding{
105
+ Severity: rule.severity,
106
+ Rule: rule.rule,
107
+ Line: lineNum,
108
+ Detail: rule.detail,
109
+ })
110
+ }
111
+ }
112
+ }
113
+ findings = appendMissingNameFinding(text, findings)
114
+ return dedupeFindings(findings)
115
+ }
116
+
117
+ func looksLikeSkillFile(text string) bool {
118
+ lower := strings.ToLower(text)
119
+ return strings.Contains(lower, "user-invocable:") || strings.Contains(lower, "# /rite-") || strings.Contains(lower, "disable-model-invocation:")
120
+ }
121
+
122
+ func appendMissingNameFinding(text string, findings []SkillTrustFinding) []SkillTrustFinding {
123
+ if strings.Contains(text, "---") && !strings.Contains(text, "\nname:") && looksLikeSkillFile(text) {
124
+ findings = append(findings, SkillTrustFinding{
125
+ Severity: "MEDIUM",
126
+ Rule: "missing_name_frontmatter",
127
+ Line: 1,
128
+ Detail: "skill-like Markdown lacks name frontmatter",
129
+ })
130
+ }
131
+ return findings
132
+ }
133
+
134
+ func isSuspiciousUnicode(r rune) bool {
135
+ switch r {
136
+ case '\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060':
137
+ return true
138
+ }
139
+ if r >= '\u202A' && r <= '\u202E' {
140
+ return true
141
+ }
142
+ if r >= '\u2066' && r <= '\u2069' {
143
+ return true
144
+ }
145
+ return false
146
+ }
147
+
148
+ func dedupeFindings(in []SkillTrustFinding) []SkillTrustFinding {
149
+ if len(in) == 0 {
150
+ return nil
151
+ }
152
+ seen := make(map[string]bool, len(in))
153
+ out := make([]SkillTrustFinding, 0, len(in))
154
+ for _, finding := range in {
155
+ key := finding.Rule + "|" + fmt.Sprint(finding.Line) + "|" + finding.Detail
156
+ if seen[key] {
157
+ continue
158
+ }
159
+ seen[key] = true
160
+ out = append(out, finding)
161
+ }
162
+ return out
163
+ }
164
+
165
+ // SkillTrustBlocks returns true when findings contain any HIGH severity item.
166
+ func SkillTrustBlocks(findings []SkillTrustFinding) bool {
167
+ for _, finding := range findings {
168
+ if finding.Severity == "HIGH" {
169
+ return true
170
+ }
171
+ }
172
+ return false
173
+ }
174
+
175
+ // FormatSkillTrust writes a human-readable report.
176
+ func FormatSkillTrust(result SkillTrustResult) string {
177
+ if len(result.Findings) == 0 {
178
+ return fmt.Sprintf("skill-trust: PASS (%s)\n", result.Path)
179
+ }
180
+ var b strings.Builder
181
+ fmt.Fprintf(&b, "skill-trust: FINDINGS (%s)\n", result.Path)
182
+ for _, finding := range result.Findings {
183
+ fmt.Fprintf(&b, "%s %s line=%d %s\n", finding.Severity, finding.Rule, finding.Line, finding.Detail)
184
+ }
185
+ return b.String()
186
+ }
187
+
188
+ // ContainsOnlyASCII returns false when non-ASCII control or bidi characters appear.
189
+ func ContainsOnlyASCII(text string) bool {
190
+ for _, r := range text {
191
+ if r > unicode.MaxASCII && isSuspiciousUnicode(r) {
192
+ return false
193
+ }
194
+ }
195
+ return true
196
+ }
@@ -0,0 +1,162 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "regexp"
6
+ "strings"
7
+ )
8
+
9
+ // TaskSlice is one SLICE-### block parsed from tasks.md.
10
+ type TaskSlice struct {
11
+ ID string
12
+ Dependencies []string
13
+ }
14
+
15
+ // TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
16
+ type TaskGraphResult struct {
17
+ Slices []TaskSlice
18
+ Cycle []string // empty when acyclic
19
+ Unknown []string // dependency ids with no defining slice
20
+ Problems []string // human-readable blockers
21
+ }
22
+
23
+ var (
24
+ sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
25
+ dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
26
+ sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
27
+ )
28
+
29
+ // ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
30
+ func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
31
+ text := string(tasksMarkdown)
32
+ result := TaskGraphResult{}
33
+ if strings.TrimSpace(text) == "" {
34
+ result.Problems = append(result.Problems, "tasks.md is empty")
35
+ return result
36
+ }
37
+
38
+ headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
39
+ if len(headers) == 0 {
40
+ result.Problems = append(result.Problems, "no SLICE-### sections found")
41
+ return result
42
+ }
43
+
44
+ known := make(map[string]bool)
45
+ for _, match := range headers {
46
+ id := text[match[2]:match[3]]
47
+ known[id] = true
48
+ }
49
+
50
+ for i, match := range headers {
51
+ id := text[match[2]:match[3]]
52
+ start := match[1]
53
+ end := len(text)
54
+ if i+1 < len(headers) {
55
+ end = headers[i+1][0]
56
+ }
57
+ block := text[start:end]
58
+ deps := parseSliceDependencies(block)
59
+ result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
60
+ for _, dep := range deps {
61
+ if !known[dep] {
62
+ result.Unknown = append(result.Unknown, dep)
63
+ result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
64
+ }
65
+ }
66
+ }
67
+
68
+ if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
69
+ result.Cycle = cycle
70
+ result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
71
+ }
72
+ return result
73
+ }
74
+
75
+ func parseSliceDependencies(block string) []string {
76
+ match := dependenciesRE.FindStringSubmatch(block)
77
+ if len(match) < 2 {
78
+ return nil
79
+ }
80
+ raw := strings.TrimSpace(match[1])
81
+ if raw == "" || strings.EqualFold(raw, "none") {
82
+ return nil
83
+ }
84
+ parts := strings.FieldsFunc(raw, func(r rune) bool {
85
+ return r == ',' || r == ';'
86
+ })
87
+ var deps []string
88
+ seen := make(map[string]bool)
89
+ for _, part := range parts {
90
+ id := strings.TrimSpace(part)
91
+ if id == "" {
92
+ continue
93
+ }
94
+ if !sliceIDValidRE.MatchString(id) {
95
+ continue
96
+ }
97
+ if seen[id] {
98
+ continue
99
+ }
100
+ seen[id] = true
101
+ deps = append(deps, id)
102
+ }
103
+ return deps
104
+ }
105
+
106
+ func findTaskCycle(slices []TaskSlice) []string {
107
+ graph := make(map[string][]string, len(slices))
108
+ for _, slice := range slices {
109
+ graph[slice.ID] = append([]string(nil), slice.Dependencies...)
110
+ }
111
+ visited := make(map[string]uint8, len(slices))
112
+ stack := make([]string, 0, len(slices))
113
+ var cycle []string
114
+
115
+ var visit func(id string) bool
116
+ visit = func(id string) bool {
117
+ switch visited[id] {
118
+ case 2:
119
+ return false
120
+ case 1:
121
+ for i := len(stack) - 1; i >= 0; i-- {
122
+ cycle = append(cycle, stack[i])
123
+ if stack[i] == id {
124
+ break
125
+ }
126
+ }
127
+ for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
128
+ cycle[i], cycle[j] = cycle[j], cycle[i]
129
+ }
130
+ return true
131
+ }
132
+ visited[id] = 1
133
+ stack = append(stack, id)
134
+ for _, dep := range graph[id] {
135
+ if visit(dep) {
136
+ return true
137
+ }
138
+ }
139
+ stack = stack[:len(stack)-1]
140
+ visited[id] = 2
141
+ return false
142
+ }
143
+
144
+ for _, slice := range slices {
145
+ if visited[slice.ID] == 0 {
146
+ cycle = nil
147
+ if visit(slice.ID) {
148
+ return cycle
149
+ }
150
+ }
151
+ }
152
+ return nil
153
+ }
154
+
155
+ // CheckTaskGraph validates tasks.md for a feature workspace slug.
156
+ func CheckTaskGraph(root, slug string) (TaskGraphResult, error) {
157
+ raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
158
+ if err != nil {
159
+ return TaskGraphResult{}, err
160
+ }
161
+ return ParseTaskGraph(raw), nil
162
+ }