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
@@ -0,0 +1,181 @@
1
+ package parallel
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "fmt"
8
+ "os"
9
+ "os/exec"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/devrites/devrites/internal/gitenv"
14
+ )
15
+
16
+ const gitTimeout = 60 * time.Second
17
+
18
+ func git(repo string, args ...string) (string, error) {
19
+ ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
20
+ defer cancel()
21
+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repo}, args...)...)
22
+ env := append(gitenv.Sanitize(os.Environ()),
23
+ "GIT_TERMINAL_PROMPT=0",
24
+ "GCM_INTERACTIVE=never",
25
+ "GIT_PAGER=cat",
26
+ "PAGER=cat",
27
+ "LC_ALL=C",
28
+ )
29
+ if gitOpNeedsIdentity(args) {
30
+ env = ensureGitIdentityEnv(env)
31
+ }
32
+ cmd.Env = env
33
+ var stdout, stderr bytes.Buffer
34
+ cmd.Stdout = &stdout
35
+ cmd.Stderr = &stderr
36
+ err := cmd.Run()
37
+ out := strings.TrimSpace(stdout.String())
38
+ if err != nil {
39
+ detail := strings.TrimSpace(stderr.String())
40
+ if detail == "" {
41
+ detail = out
42
+ }
43
+ if ctx.Err() != nil {
44
+ return out, fmt.Errorf("git %s: %w", strings.Join(args, " "), ctx.Err())
45
+ }
46
+ var exitErr *exec.ExitError
47
+ if errors.As(err, &exitErr) {
48
+ return out, fmt.Errorf("git %s: exit %d: %s", strings.Join(args, " "), exitErr.ExitCode(), detail)
49
+ }
50
+ return out, fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, detail)
51
+ }
52
+ return out, nil
53
+ }
54
+
55
+ func gitOpNeedsIdentity(args []string) bool {
56
+ for _, a := range args {
57
+ switch a {
58
+ case "cherry-pick", "commit", "merge", "rebase", "am":
59
+ return true
60
+ }
61
+ }
62
+ return false
63
+ }
64
+
65
+ // ensureGitIdentityEnv fills author/committer when the host has none (common on
66
+ // CI images). Prefer existing env identity when present.
67
+ func ensureGitIdentityEnv(env []string) []string {
68
+ keys := map[string]string{
69
+ "GIT_AUTHOR_NAME": "devrites-engine",
70
+ "GIT_AUTHOR_EMAIL": "engine@devrites.invalid",
71
+ "GIT_COMMITTER_NAME": "devrites-engine",
72
+ "GIT_COMMITTER_EMAIL": "engine@devrites.invalid",
73
+ }
74
+ have := make(map[string]bool, len(env))
75
+ for _, e := range env {
76
+ if i := strings.IndexByte(e, '='); i > 0 {
77
+ have[e[:i]] = true
78
+ }
79
+ }
80
+ for k, v := range keys {
81
+ if !have[k] {
82
+ env = append(env, k+"="+v)
83
+ }
84
+ }
85
+ return env
86
+ }
87
+
88
+ func revParse(repo, rev string) (string, error) {
89
+ return git(repo, "rev-parse", "--verify", rev+"^{commit}")
90
+ }
91
+
92
+ func isAncestor(repo, ancestor, descendant string) (bool, error) {
93
+ _, err := git(repo, "merge-base", "--is-ancestor", ancestor, descendant)
94
+ if err == nil {
95
+ return true, nil
96
+ }
97
+ if strings.Contains(err.Error(), "exit 1") {
98
+ return false, nil
99
+ }
100
+ return false, err
101
+ }
102
+
103
+ func diffNames(repo, a, b string) ([]string, error) {
104
+ out, err := git(repo, "diff", "--name-only", a, b)
105
+ if err != nil {
106
+ return nil, err
107
+ }
108
+ if out == "" {
109
+ return nil, nil
110
+ }
111
+ lines := strings.Split(out, "\n")
112
+ for i := range lines {
113
+ lines[i] = strings.TrimSpace(lines[i])
114
+ }
115
+ return lines, nil
116
+ }
117
+
118
+ func headSHA(repo string) (string, error) {
119
+ return revParse(repo, "HEAD")
120
+ }
121
+
122
+ func resetHard(repo, sha string) error {
123
+ _, err := git(repo, "reset", "--hard", sha)
124
+ return err
125
+ }
126
+
127
+ func porcelainDirty(repo string) (bool, error) {
128
+ out, err := git(repo, "status", "--porcelain", "--untracked-files=no")
129
+ if err != nil {
130
+ return false, err
131
+ }
132
+ return out != "", nil
133
+ }
134
+
135
+ func branchExists(repo, branch string) (bool, error) {
136
+ _, err := git(repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
137
+ if err == nil {
138
+ return true, nil
139
+ }
140
+ if strings.Contains(err.Error(), "exit 1") {
141
+ return false, nil
142
+ }
143
+ return false, err
144
+ }
145
+
146
+ func deleteBranch(repo, branch string) error {
147
+ exists, err := branchExists(repo, branch)
148
+ if err != nil || !exists {
149
+ return err
150
+ }
151
+ _, err = git(repo, "branch", "-D", branch)
152
+ return err
153
+ }
154
+
155
+ func worktreeAdd(repo, path, branch, startPoint string) error {
156
+ _, err := git(repo, "worktree", "add", "-b", branch, path, startPoint)
157
+ return err
158
+ }
159
+
160
+ func worktreeRemove(repo, path string) error {
161
+ _, err := git(repo, "worktree", "remove", "--force", path)
162
+ if err != nil {
163
+ _ = os.RemoveAll(path)
164
+ _, _ = git(repo, "worktree", "prune")
165
+ }
166
+ return err
167
+ }
168
+
169
+ func mergeFFOnly(repo, commit string) error {
170
+ _, err := git(repo, "merge", "--ff-only", commit)
171
+ return err
172
+ }
173
+
174
+ func cherryPickRange(repo, fromExclusive, toInclusive string) error {
175
+ _, err := git(repo, "cherry-pick", "-x", fromExclusive+".."+toInclusive)
176
+ return err
177
+ }
178
+
179
+ func cherryPickAbort(repo string) {
180
+ _, _ = git(repo, "cherry-pick", "--abort")
181
+ }
@@ -0,0 +1,244 @@
1
+ package parallel
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "regexp"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ const (
14
+ StatusRunning = "running"
15
+ StatusAborted = "aborted"
16
+ StatusIntegrateFailed = "integrate-failed"
17
+ StatusComplete = "complete"
18
+
19
+ WrightPending = "pending"
20
+ WrightGreen = "green"
21
+ WrightRed = "red"
22
+ WrightGap = "gap"
23
+ )
24
+
25
+ var (
26
+ slugRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
27
+ batchRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
28
+ sliceRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
29
+ shaRE = regexp.MustCompile(`(?i)^[0-9a-f]{7,40}$`)
30
+ jsonFenceRE = regexp.MustCompile("(?s)```json\\n(.*?)\\n```")
31
+ )
32
+
33
+ // Lease is the machine-readable parallel batch lease (control tree only).
34
+ type Lease struct {
35
+ BatchID string `json:"batch_id"`
36
+ CreatedAt string `json:"created_at"`
37
+ BaseSHA string `json:"base_sha"`
38
+ N int `json:"n"`
39
+ Status string `json:"status"`
40
+ ControlPIDOrSession string `json:"control_pid_or_session"`
41
+ Slices []LeaseSlice `json:"slices"`
42
+ }
43
+
44
+ // LeaseSlice records one worker worktree under the lease.
45
+ type LeaseSlice struct {
46
+ ID string `json:"id"`
47
+ Paths []string `json:"paths"`
48
+ WorktreePath string `json:"worktree_path"`
49
+ Branch string `json:"branch"`
50
+ WrightStatus string `json:"wright_status"`
51
+ TransferCommit string `json:"transfer_commit,omitempty"`
52
+ }
53
+
54
+ func LeasePath(repoRoot, slug string) (string, error) {
55
+ if err := validateSlug(slug); err != nil {
56
+ return "", err
57
+ }
58
+ return filepath.Join(repoRoot, ".devrites", "work", slug, "parallel-lease.md"), nil
59
+ }
60
+
61
+ func ScratchRoot(repoRoot string) string {
62
+ return filepath.Join(repoRoot, ".scratch", "parallel-wt")
63
+ }
64
+
65
+ func WorkerBranch(slug, batchID, sliceID string) string {
66
+ return fmt.Sprintf("devrites/parallel/%s/%s/%s", slug, batchID, sliceID)
67
+ }
68
+
69
+ func IntegrateBranchName(slug, batchID string) string {
70
+ return fmt.Sprintf("devrites/parallel/%s/%s/integrate", slug, batchID)
71
+ }
72
+
73
+ func WorkerWorktreePath(repoRoot, batchID, sliceID string) string {
74
+ return filepath.Join(ScratchRoot(repoRoot), batchID, sliceID)
75
+ }
76
+
77
+ func validateSlug(slug string) error {
78
+ if !slugRE.MatchString(slug) {
79
+ return fmt.Errorf("invalid slug %q", slug)
80
+ }
81
+ return nil
82
+ }
83
+
84
+ func validateBatchID(batchID string) error {
85
+ if !batchRE.MatchString(batchID) {
86
+ return fmt.Errorf("invalid batch id %q", batchID)
87
+ }
88
+ return nil
89
+ }
90
+
91
+ func validateSliceID(id string) error {
92
+ if !sliceRE.MatchString(id) {
93
+ return fmt.Errorf("invalid slice id %q", id)
94
+ }
95
+ return nil
96
+ }
97
+
98
+ func ValidateLease(lease *Lease) error {
99
+ if lease == nil {
100
+ return fmt.Errorf("lease is nil")
101
+ }
102
+ if err := validateBatchID(lease.BatchID); err != nil {
103
+ return err
104
+ }
105
+ if lease.CreatedAt == "" {
106
+ return fmt.Errorf("lease missing created_at")
107
+ }
108
+ if !shaRE.MatchString(lease.BaseSHA) {
109
+ return fmt.Errorf("invalid base_sha %q", lease.BaseSHA)
110
+ }
111
+ switch lease.Status {
112
+ case StatusRunning, StatusAborted, StatusIntegrateFailed, StatusComplete:
113
+ default:
114
+ return fmt.Errorf("invalid lease status %q", lease.Status)
115
+ }
116
+ if len(lease.Slices) < 2 || len(lease.Slices) > 3 {
117
+ return fmt.Errorf("lease must have 2 or 3 slices (got %d)", len(lease.Slices))
118
+ }
119
+ if lease.N != len(lease.Slices) {
120
+ return fmt.Errorf("lease n=%d does not match slices=%d", lease.N, len(lease.Slices))
121
+ }
122
+ seen := make(map[string]struct{}, len(lease.Slices))
123
+ for i, sl := range lease.Slices {
124
+ if err := validateSliceID(sl.ID); err != nil {
125
+ return fmt.Errorf("slice %d: %w", i, err)
126
+ }
127
+ if _, ok := seen[sl.ID]; ok {
128
+ return fmt.Errorf("duplicate slice id %q", sl.ID)
129
+ }
130
+ seen[sl.ID] = struct{}{}
131
+ if _, err := validateSlicePaths(sl.Paths, fmt.Sprintf("slice %q", sl.ID), ""); err != nil {
132
+ return err
133
+ }
134
+ switch sl.WrightStatus {
135
+ case "", WrightPending, WrightGreen, WrightRed, WrightGap:
136
+ default:
137
+ return fmt.Errorf("slice %q: invalid wright_status %q", sl.ID, sl.WrightStatus)
138
+ }
139
+ if sl.TransferCommit != "" && !shaRE.MatchString(sl.TransferCommit) {
140
+ return fmt.Errorf("slice %q: invalid transfer_commit %q", sl.ID, sl.TransferCommit)
141
+ }
142
+ }
143
+ return nil
144
+ }
145
+
146
+ func EmitLeaseMarkdown(lease *Lease) (string, error) {
147
+ if err := ValidateLease(lease); err != nil {
148
+ return "", err
149
+ }
150
+ payload, err := json.MarshalIndent(lease, "", " ")
151
+ if err != nil {
152
+ return "", err
153
+ }
154
+ var b strings.Builder
155
+ b.WriteString("# parallel-lease.md — advisory control lease for /rite-build --parallel\n")
156
+ b.WriteString("# Machine block below is authoritative for helpers; YAML mirrors schema.\n\n")
157
+ fmt.Fprintf(&b, "batch_id: %s\n", yamlQuote(lease.BatchID))
158
+ fmt.Fprintf(&b, "created_at: %s\n", yamlQuote(lease.CreatedAt))
159
+ fmt.Fprintf(&b, "base_sha: %s\n", yamlQuote(lease.BaseSHA))
160
+ fmt.Fprintf(&b, "n: %d\n", lease.N)
161
+ fmt.Fprintf(&b, "status: %s\n", yamlQuote(lease.Status))
162
+ fmt.Fprintf(&b, "control_pid_or_session: %s\n", yamlQuote(lease.ControlPIDOrSession))
163
+ b.WriteString("slices:\n")
164
+ for _, sl := range lease.Slices {
165
+ fmt.Fprintf(&b, " - id: %s\n", yamlQuote(sl.ID))
166
+ if len(sl.Paths) == 0 {
167
+ b.WriteString(" paths: []\n")
168
+ } else {
169
+ b.WriteString(" paths:\n")
170
+ for _, p := range sl.Paths {
171
+ fmt.Fprintf(&b, " - %s\n", yamlQuote(p))
172
+ }
173
+ }
174
+ fmt.Fprintf(&b, " worktree_path: %s\n", yamlQuote(sl.WorktreePath))
175
+ fmt.Fprintf(&b, " branch: %s\n", yamlQuote(sl.Branch))
176
+ status := sl.WrightStatus
177
+ if status == "" {
178
+ status = WrightPending
179
+ }
180
+ fmt.Fprintf(&b, " wright_status: %s\n", yamlQuote(status))
181
+ if sl.TransferCommit != "" {
182
+ fmt.Fprintf(&b, " transfer_commit: %s\n", yamlQuote(sl.TransferCommit))
183
+ }
184
+ }
185
+ b.WriteString("\n<!-- machine-readable lease SSOT -->\n")
186
+ b.WriteString("```json\n")
187
+ b.Write(payload)
188
+ b.WriteString("\n```\n")
189
+ return b.String(), nil
190
+ }
191
+
192
+ func yamlQuote(v string) string {
193
+ if v == "" || strings.ContainsAny(v, ":#{}[]&*!|>'\"%@`") || strings.TrimSpace(v) != v {
194
+ raw, _ := json.Marshal(v)
195
+ return string(raw)
196
+ }
197
+ return v
198
+ }
199
+
200
+ func WriteLease(path string, lease *Lease) error {
201
+ text, err := EmitLeaseMarkdown(lease)
202
+ if err != nil {
203
+ return err
204
+ }
205
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
206
+ return err
207
+ }
208
+ tmp := path + ".tmp"
209
+ if err := os.WriteFile(tmp, []byte(text), 0o644); err != nil {
210
+ return err
211
+ }
212
+ return os.Rename(tmp, path)
213
+ }
214
+
215
+ func ReadLease(path string) (*Lease, error) {
216
+ data, err := os.ReadFile(path)
217
+ if err != nil {
218
+ return nil, err
219
+ }
220
+ match := jsonFenceRE.FindSubmatch(data)
221
+ if match == nil {
222
+ return nil, fmt.Errorf("lease missing machine-readable json fence: %s", path)
223
+ }
224
+ var lease Lease
225
+ if err := json.Unmarshal(match[1], &lease); err != nil {
226
+ return nil, fmt.Errorf("lease json invalid: %w", err)
227
+ }
228
+ if err := ValidateLease(&lease); err != nil {
229
+ return nil, err
230
+ }
231
+ return &lease, nil
232
+ }
233
+
234
+ func ClearLease(path string) error {
235
+ err := os.Remove(path)
236
+ if err != nil && !os.IsNotExist(err) {
237
+ return err
238
+ }
239
+ return nil
240
+ }
241
+
242
+ func NowUTC() string {
243
+ return time.Now().UTC().Format(time.RFC3339)
244
+ }