devrites 4.2.0 → 4.4.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 (117) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/cli.md +15 -0
  4. package/docs/engine/commands.md +20 -2
  5. package/docs/orchestration.md +6 -3
  6. package/engine/commands.go +1 -1
  7. package/engine/internal/lib/cli_observe.go +66 -0
  8. package/engine/internal/lib/observe_summary.go +79 -0
  9. package/engine/internal/lib/open_visual.go +254 -0
  10. package/engine/internal/lib/open_visual_test.go +280 -0
  11. package/engine/internal/lib/skilltrust.go +196 -0
  12. package/engine/internal/lib/taskgraph.go +162 -0
  13. package/engine/internal/lib/taskgraph_test.go +150 -0
  14. package/engine/internal/lib/visual_outline.go +163 -0
  15. package/engine/internal/lib/visual_outline_test.go +161 -0
  16. package/engine/internal/lib/workspace_read.go +29 -0
  17. package/engine/internal/parallel/cli.go +467 -0
  18. package/engine/internal/parallel/git.go +181 -0
  19. package/engine/internal/parallel/lease.go +244 -0
  20. package/engine/internal/parallel/ops.go +416 -0
  21. package/engine/internal/parallel/parallel_test.go +242 -0
  22. package/engine/internal/parallel/pathdisjoint.go +129 -0
  23. package/engine/internal/parallel/pathdisjoint_test.go +97 -0
  24. package/engine/internal/state/status.go +41 -16
  25. package/engine/main.go +76 -3
  26. package/engine/root_routing_test.go +50 -15
  27. package/engine/testdata/visual/open-visual-smoke.html +675 -0
  28. package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
  29. package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
  30. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  31. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  32. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  33. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  34. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  35. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  36. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  37. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  38. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  39. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  40. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  41. package/pack/.claude/skills/rite-build/SKILL.md +29 -36
  42. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
  43. package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  44. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  45. package/pack/.claude/skills/rite-build/reference/output.md +2 -0
  46. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
  47. package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
  48. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  49. package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  50. package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
  51. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  52. package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
  53. package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
  54. package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
  55. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  56. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
  57. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
  58. package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
  59. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  60. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  61. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  62. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  63. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  64. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  65. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  66. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  67. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  68. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  69. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  70. package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
  71. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
  72. package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  73. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  74. package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
  75. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
  76. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
  77. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  78. package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  79. package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
  80. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  81. package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
  82. package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
  83. package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
  84. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  85. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
  86. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
  87. package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
  88. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  89. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  90. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  91. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  92. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  93. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  94. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  95. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  96. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  97. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  98. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  99. package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
  100. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
  101. package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  102. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
  103. package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
  104. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
  105. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
  106. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
  107. package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
  108. package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
  109. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  110. package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
  111. package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
  112. package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
  113. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
  114. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
  115. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
  116. package/package.json +1 -1
  117. package/scripts/install-lib.sh +1 -1
@@ -0,0 +1,280 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync/atomic"
10
+ "testing"
11
+ )
12
+
13
+ func TestOpenVisualResolvesNameUnderWorkspace(t *testing.T) {
14
+ root, html := writeOpenVisualFixture(t, true)
15
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
16
+
17
+ var opens atomic.Int32
18
+ restore := swapOpenVisualOpener(func(path string) error {
19
+ opens.Add(1)
20
+ if path != html {
21
+ t.Fatalf("opened %q, want %q", path, html)
22
+ }
23
+ return nil
24
+ })
25
+ defer restore()
26
+
27
+ var stdout, stderr bytes.Buffer
28
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
29
+ if code != 0 {
30
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
31
+ }
32
+ if opens.Load() != 1 {
33
+ t.Fatalf("opener calls = %d, want 1", opens.Load())
34
+ }
35
+ out := stdout.String()
36
+ if !strings.Contains(out, "open-visual: html="+html) {
37
+ t.Fatalf("stdout missing html tip:\n%s", out)
38
+ }
39
+ if !strings.Contains(out, "open-visual: outline="+strings.TrimSuffix(html, ".html")+".outline.md") {
40
+ t.Fatalf("stdout missing outline tip:\n%s", out)
41
+ }
42
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
43
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
44
+ }
45
+ if stderr.Len() != 0 {
46
+ t.Fatalf("stderr = %q, want empty", stderr.String())
47
+ }
48
+ }
49
+
50
+ func TestOpenVisualAbsolutePathAndNoOpen(t *testing.T) {
51
+ root, html := writeOpenVisualFixture(t, true)
52
+ if root == "" {
53
+ t.Fatal("empty fixture root")
54
+ }
55
+
56
+ var opens atomic.Int32
57
+ restore := swapOpenVisualOpener(func(string) error {
58
+ opens.Add(1)
59
+ return nil
60
+ })
61
+ defer restore()
62
+
63
+ var stdout, stderr bytes.Buffer
64
+ code := OpenVisual("", []string{html, "--no-open"}, &stdout, &stderr)
65
+ if code != 0 {
66
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
67
+ }
68
+ if opens.Load() != 0 {
69
+ t.Fatalf("opener calls = %d, want 0 with --no-open", opens.Load())
70
+ }
71
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
72
+ t.Fatalf("stdout = %q", stdout.String())
73
+ }
74
+ }
75
+
76
+ func TestOpenVisualWarnsMissingOutline(t *testing.T) {
77
+ root, html := writeOpenVisualFixture(t, false)
78
+ t.Setenv("DEVRITES_ROOT", root)
79
+
80
+ restore := swapOpenVisualOpener(func(string) error { return nil })
81
+ defer restore()
82
+
83
+ var stdout, stderr bytes.Buffer
84
+ code := OpenVisual(root, []string{"demo", "--slug", "feature", "--no-open"}, &stdout, &stderr)
85
+ if code != 0 {
86
+ t.Fatalf("OpenVisual() = %d", code)
87
+ }
88
+ if !strings.Contains(stderr.String(), "warning: missing outline companion") {
89
+ t.Fatalf("stderr = %q, want missing-outline warning", stderr.String())
90
+ }
91
+ if !strings.Contains(stdout.String(), "open-visual: outline=(missing)") {
92
+ t.Fatalf("stdout = %q, want missing outline tip", stdout.String())
93
+ }
94
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
95
+ t.Fatalf("stdout = %q, want html tip for %s", stdout.String(), html)
96
+ }
97
+ }
98
+
99
+ func TestOpenVisualUsageErrors(t *testing.T) {
100
+ tests := []struct {
101
+ name string
102
+ args []string
103
+ want string
104
+ }{
105
+ {name: "missing operand", args: nil, want: "path or visual name required"},
106
+ {name: "unknown flag", args: []string{"demo", "--poll"}, want: `unknown flag "--poll"`},
107
+ {name: "slug without value", args: []string{"demo", "--slug"}, want: "--slug requires a value"},
108
+ }
109
+ for _, test := range tests {
110
+ t.Run(test.name, func(t *testing.T) {
111
+ var stdout, stderr bytes.Buffer
112
+ code := OpenVisual("", test.args, &stdout, &stderr)
113
+ if code != 2 {
114
+ t.Fatalf("code = %d, want 2", code)
115
+ }
116
+ if stdout.Len() != 0 {
117
+ t.Fatalf("stdout = %q", stdout.String())
118
+ }
119
+ if !strings.Contains(stderr.String(), test.want) {
120
+ t.Fatalf("stderr = %q, want %q", stderr.String(), test.want)
121
+ }
122
+ })
123
+ }
124
+ }
125
+
126
+ func TestOpenVisualRejectsNonHTML(t *testing.T) {
127
+ dir := t.TempDir()
128
+ path := writeBasenameFile(t, dir, "notes.md", "# hi\n")
129
+ restore := swapOpenVisualOpener(func(string) error {
130
+ t.Fatal("opener must not run for non-html")
131
+ return nil
132
+ })
133
+ defer restore()
134
+
135
+ var stdout, stderr bytes.Buffer
136
+ code := OpenVisual("", []string{path, "--no-open"}, &stdout, &stderr)
137
+ if code != 2 || !strings.Contains(stderr.String(), "require a .html file") {
138
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
139
+ }
140
+ }
141
+
142
+ func TestOpenVisualRefusesNameModeSymlinkEscape(t *testing.T) {
143
+ root, html := writeOpenVisualFixture(t, true)
144
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
145
+
146
+ outside := writeBasenameFile(t, t.TempDir(), "escape.html", "<!doctype html><title>escape</title>\n")
147
+ removeBasename(t, filepath.Dir(html), filepath.Base(html))
148
+ if err := os.Symlink(outside, html); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+
152
+ restore := swapOpenVisualOpener(func(string) error {
153
+ t.Fatal("opener must not run for escaped symlink")
154
+ return nil
155
+ })
156
+ defer restore()
157
+
158
+ var stdout, stderr bytes.Buffer
159
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
160
+ if code != 2 {
161
+ t.Fatalf("code = %d, want 2", code)
162
+ }
163
+ if !strings.Contains(stderr.String(), "escapes workspace visual/") {
164
+ t.Fatalf("stderr = %q, want symlink escape refusal", stderr.String())
165
+ }
166
+ if stdout.Len() != 0 {
167
+ t.Fatalf("stdout = %q, want empty on refuse", stdout.String())
168
+ }
169
+ }
170
+
171
+ func TestOpenVisualPrintsTipsWhenOpenerFails(t *testing.T) {
172
+ root, html := writeOpenVisualFixture(t, true)
173
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
174
+
175
+ restore := swapOpenVisualOpener(func(string) error {
176
+ return errors.New("xdg-open: not found")
177
+ })
178
+ defer restore()
179
+
180
+ var stdout, stderr bytes.Buffer
181
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
182
+ if code != 0 {
183
+ t.Fatalf("OpenVisual() = %d, want 0 (warn-and-continue)", code)
184
+ }
185
+ out := stdout.String()
186
+ if !strings.Contains(out, "open-visual: html="+html) {
187
+ t.Fatalf("stdout missing html tip:\n%s", out)
188
+ }
189
+ if !strings.Contains(out, "open-visual: outline=") {
190
+ t.Fatalf("stdout missing outline tip:\n%s", out)
191
+ }
192
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
193
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
194
+ }
195
+ if !strings.Contains(stderr.String(), "warning: failed to open browser") {
196
+ t.Fatalf("stderr = %q, want opener warning", stderr.String())
197
+ }
198
+ }
199
+
200
+ func TestIsOpenVisualPathOperandLeadingDotName(t *testing.T) {
201
+ if isOpenVisualPathOperand(".draft") {
202
+ t.Fatal(".draft should be a visual name, not a path operand")
203
+ }
204
+ if !isOpenVisualPathOperand("./demo.html") {
205
+ t.Fatal("./demo.html should be a path operand")
206
+ }
207
+ if !isOpenVisualPathOperand("../other.html") {
208
+ t.Fatal("../other.html should be a path operand")
209
+ }
210
+ }
211
+
212
+ func writeOpenVisualFixture(t *testing.T, withOutline bool) (root, html string) {
213
+ t.Helper()
214
+ project := t.TempDir()
215
+ root = filepath.Join(project, ".devrites")
216
+ visual := filepath.Join(root, "work", "feature", "visual")
217
+ if err := os.MkdirAll(visual, 0o755); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ html = writeBasenameFile(t, visual, "demo.html", "<!doctype html><title>demo</title>\n")
221
+ if withOutline {
222
+ writeBasenameFile(t, visual, "demo.outline.md", "# Title\n\ndemo\n")
223
+ }
224
+ writeBasenameFile(t, filepath.Join(root, "work", "feature"), "state.md", "# state\n")
225
+ if resolved, err := filepath.EvalSymlinks(html); err == nil {
226
+ html = resolved
227
+ }
228
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
229
+ root = resolved
230
+ }
231
+ return root, html
232
+ }
233
+
234
+ // writeBasenameFile writes contents under dir using only filepath.Base(name),
235
+ // via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
236
+ func writeBasenameFile(t *testing.T, dir, name, contents string) string {
237
+ t.Helper()
238
+ base := filepath.Base(name)
239
+ dst := filepath.Join(dir, base)
240
+ tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ written, werr := tmp.WriteString(contents)
245
+ if werr != nil {
246
+ if cerr := tmp.Close(); cerr != nil {
247
+ t.Fatalf("write: %v; close: %v", werr, cerr)
248
+ }
249
+ t.Fatal(werr)
250
+ }
251
+ if written != len(contents) {
252
+ if cerr := tmp.Close(); cerr != nil {
253
+ t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
254
+ }
255
+ t.Fatalf("short write %d/%d", written, len(contents))
256
+ }
257
+ if err := tmp.Close(); err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if err := os.Rename(tmp.Name(), dst); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ return dst
264
+ }
265
+
266
+ // removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
267
+ func removeBasename(t *testing.T, dir, name string) {
268
+ t.Helper()
269
+ src := filepath.Join(dir, filepath.Base(name))
270
+ dst := filepath.Join(t.TempDir(), filepath.Base(name))
271
+ if err := os.Rename(src, dst); err != nil {
272
+ t.Fatal(err)
273
+ }
274
+ }
275
+
276
+ func swapOpenVisualOpener(fn func(string) error) func() {
277
+ prev := openVisualOpener
278
+ openVisualOpener = fn
279
+ return func() { openVisualOpener = prev }
280
+ }
@@ -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
+ }