devrites 7.3.1 → 7.3.2
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.
- package/CHANGELOG.md +22 -0
- package/README.md +19 -3
- package/bin/devrites.mjs +31 -5
- package/docs/engine/commands.md +35 -3
- package/docs/release.md +5 -2
- package/engine/Makefile +2 -2
- package/engine/internal/acceptance/newfinal_origins.go +39 -0
- package/engine/internal/acceptance/preconditions.go +17 -11
- package/engine/internal/acceptance/transcript.go +23 -4
- package/engine/internal/gate/gate.go +8 -6
- package/engine/internal/gate/readiness_binding.go +50 -21
- package/engine/internal/install/apply.go +36 -21
- package/engine/internal/install/binary.go +67 -9
- package/engine/internal/install/install.go +63 -21
- package/engine/internal/install/manifest.go +4 -5
- package/engine/internal/install/merge.go +89 -33
- package/engine/internal/install/preflight.go +60 -19
- package/engine/internal/install/uninstall.go +19 -9
- package/engine/internal/install/update.go +0 -4
- package/engine/internal/lib/claims.go +170 -4
- package/engine/internal/lib/claims_holder.go +13 -16
- package/engine/internal/lib/closeout.go +52 -28
- package/engine/internal/lib/context_io.go +88 -4
- package/engine/internal/lib/continuation.go +111 -24
- package/engine/internal/lib/dispatch.go +157 -24
- package/engine/internal/lib/final_prerequisites.go +137 -0
- package/engine/internal/lib/manifest_union.go +62 -25
- package/engine/internal/lib/next.go +10 -10
- package/engine/internal/lib/packet.go +32 -8
- package/engine/internal/lib/packet_integrity.go +194 -2
- package/engine/internal/lib/resolve.go +128 -142
- package/engine/internal/lib/treebaseline.go +0 -1
- package/engine/internal/markdowntext/markdowntext.go +110 -31
- package/engine/internal/overhaul/records/check.go +45 -5
- package/engine/internal/overhaul/records/consistency.go +34 -8
- package/engine/internal/overhaul/records/run.go +55 -11
- package/engine/internal/parallel/landing.go +17 -0
- package/engine/internal/state/acceptance.go +35 -5
- package/engine/internal/state/feature.go +3 -2
- package/engine/internal/state/observation.go +1 -1
- package/engine/internal/state/write.go +20 -44
- package/engine/internal/workflow/commands.go +25 -3
- package/engine/main.go +19 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/.claude/skills/overhaul/SKILL.md +8 -4
- package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
- package/pack/.claude/skills/overhaul/references/records.md +23 -8
- package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
- package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
- package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
- package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
- package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
- package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
- package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
- package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
- package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
- package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
- package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
- package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
- package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
- package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
- package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
- package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
- package/package.json +1 -1
- package/scripts/dsh-generate.py +1 -1
- package/scripts/install-lib.sh +29 -0
- package/uninstall.sh +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/devrites/devrites/internal/acceptance"
|
|
8
|
+
"github.com/devrites/devrites/internal/state"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// CheckFinalPrerequisites validates retained dispatch settlement and runnable gate
|
|
12
|
+
// origins without executing proof. The caller must hold the feature lock through
|
|
13
|
+
// this check and any final mutation. A missing ledger cannot certify settlement.
|
|
14
|
+
func CheckFinalPrerequisites(root, slug string) error {
|
|
15
|
+
ledger, _, err := loadDispatch(root, slug)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return fmt.Errorf("review dispatch: %w", err)
|
|
18
|
+
}
|
|
19
|
+
reviewers := 0
|
|
20
|
+
for _, name := range sortedKeys(ledger.Waves) {
|
|
21
|
+
wave := ledger.Waves[name]
|
|
22
|
+
if wave.State == "abandoned" && (isWrightWave(wave) || wave.Claim != "") && wave.TerminalAt == "" {
|
|
23
|
+
for _, account := range wave.Roles {
|
|
24
|
+
if _, _, err := settledReviewer(account); err != nil {
|
|
25
|
+
return fmt.Errorf("review dispatch wave %q has no confirmed terminal writer account; confirm host termination with abandon --release", name)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for _, role := range sortedKeys(wave.Roles) {
|
|
30
|
+
if isReviewerRole(role) {
|
|
31
|
+
reviewers++
|
|
32
|
+
}
|
|
33
|
+
if wave.Phase == "" {
|
|
34
|
+
return fmt.Errorf("review dispatch wave %q has no phase", name)
|
|
35
|
+
}
|
|
36
|
+
account := wave.Roles[role]
|
|
37
|
+
switch wave.State {
|
|
38
|
+
case "complete":
|
|
39
|
+
if _, _, err := settledReviewer(account); err != nil {
|
|
40
|
+
return fmt.Errorf("review dispatch wave %q role %q: %w", name, role, err)
|
|
41
|
+
}
|
|
42
|
+
case "abandoned":
|
|
43
|
+
if !isReviewerRole(role) && roleBase(role) != "proof-runner" {
|
|
44
|
+
if wave.TerminalAt != "" {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if isWrightWave(wave) || wave.Claim != "" {
|
|
48
|
+
if _, _, err := settledReviewer(account); err == nil {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if !reconciledReviewer(ledger, wave, role, account) {
|
|
54
|
+
return fmt.Errorf("review dispatch wave %q role %q is abandoned; retain a fresh settled account with the same phase, role and slice subject", name, role)
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return fmt.Errorf("review dispatch wave %q role %q is not settled (state %s)", name, role, wave.State)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if reviewers == 0 {
|
|
62
|
+
return fmt.Errorf("review dispatch has no retained reviewer accounts; restore the native dispatch ledger")
|
|
63
|
+
}
|
|
64
|
+
return acceptance.CheckRetainedOrigins(root, slug)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func settledReviewer(account *dispatchRole) (time.Time, time.Time, error) {
|
|
68
|
+
if account == nil || account.Handle == "" || !account.Returned {
|
|
69
|
+
return time.Time{}, time.Time{}, fmt.Errorf("missing reviewer start identity or return")
|
|
70
|
+
}
|
|
71
|
+
start, startErr := time.Parse(time.RFC3339, account.StartedAt)
|
|
72
|
+
end, endErr := time.Parse(time.RFC3339, account.ReturnedAt)
|
|
73
|
+
if startErr != nil || endErr != nil || end.Before(start) {
|
|
74
|
+
return time.Time{}, time.Time{}, fmt.Errorf("invalid reviewer start/return timestamps")
|
|
75
|
+
}
|
|
76
|
+
return start, end, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func reconciledReviewer(ledger *dispatchFile, abandoned *dispatchWave, role string, account *dispatchRole) bool {
|
|
80
|
+
if account == nil {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
var boundary time.Time
|
|
84
|
+
if account.Handle != "" {
|
|
85
|
+
var err error
|
|
86
|
+
boundary, err = time.Parse(time.RFC3339Nano, account.StartedAt)
|
|
87
|
+
if err != nil {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
if account.ReturnedAt != "" {
|
|
91
|
+
returned, err := time.Parse(time.RFC3339Nano, account.ReturnedAt)
|
|
92
|
+
if err != nil || returned.Before(boundary) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
boundary = returned
|
|
96
|
+
}
|
|
97
|
+
} else if account.StartedAt != "" || account.Returned || account.ReturnedAt != "" {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
if abandoned.AbandonedAt != "" {
|
|
101
|
+
stamp, err := time.Parse(time.RFC3339Nano, abandoned.AbandonedAt)
|
|
102
|
+
if err != nil || stamp.Before(boundary) {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
boundary = stamp
|
|
106
|
+
}
|
|
107
|
+
// A never-started legacy launch needs an explicit native abandonment first.
|
|
108
|
+
if boundary.IsZero() {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
for _, wave := range ledger.Waves {
|
|
112
|
+
if wave.State != "complete" || state.CanonicalPhase(wave.Phase) != state.CanonicalPhase(abandoned.Phase) || !sameSliceSubject(wave.Slices, abandoned.Slices) {
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
replacement := wave.Roles[role]
|
|
116
|
+
start, _, err := settledReviewer(replacement)
|
|
117
|
+
// Equal or older starts cannot prove a fresh replacement.
|
|
118
|
+
if err == nil && replacement.Handle != account.Handle && start.After(boundary) {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func sameSliceSubject(a, b []string) bool {
|
|
126
|
+
for _, id := range a {
|
|
127
|
+
if !containsSlice(b, id) {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for _, id := range b {
|
|
132
|
+
if !containsSlice(a, id) {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"os"
|
|
8
8
|
"path/filepath"
|
|
9
9
|
"sort"
|
|
10
|
+
"strconv"
|
|
10
11
|
"strings"
|
|
11
12
|
|
|
12
13
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
@@ -134,9 +135,9 @@ func mergeManifestLocked(root, slug, workspace string, args []string, stdout, st
|
|
|
134
135
|
return 0
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
// explicitManifests parses the caller-ordered predecessor list.
|
|
138
|
-
// workspace
|
|
139
|
-
//
|
|
138
|
+
// explicitManifests parses the caller-ordered predecessor list. A numbered
|
|
139
|
+
// workspace or one recording a parent requires the complete recorded chain;
|
|
140
|
+
// legacy workspaces without cursors retain caller-supplied ordering.
|
|
140
141
|
func explicitManifests(root, slug string, predecessors []string) ([][]candidateRow, error) {
|
|
141
142
|
seen := make(map[string]struct{}, len(predecessors))
|
|
142
143
|
for _, pred := range predecessors {
|
|
@@ -148,8 +149,31 @@ func explicitManifests(root, slug string, predecessors []string) ([][]candidateR
|
|
|
148
149
|
}
|
|
149
150
|
seen[pred] = struct{}{}
|
|
150
151
|
}
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
dir, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return nil, err
|
|
155
|
+
}
|
|
156
|
+
raw, err := readBoundedRegularFile(filepath.Join(dir, "state.md"), maxCandidateManifestBytes)
|
|
157
|
+
if err != nil {
|
|
158
|
+
return nil, fmt.Errorf("%s state.md: %w", slug, err)
|
|
159
|
+
}
|
|
160
|
+
lines := strings.Split(string(raw), "\n")
|
|
161
|
+
parent, _ := state.CursorField(lines, state.CursorSequenceParent)
|
|
162
|
+
_, numbered := state.CursorField(lines, state.CursorSequencePosition)
|
|
163
|
+
if parent != "" || numbered {
|
|
164
|
+
chain, manifests, err := walkSequenceChain(root, slug, dir)
|
|
165
|
+
if err != nil {
|
|
166
|
+
return nil, err
|
|
167
|
+
}
|
|
168
|
+
if len(chain) != len(predecessors) {
|
|
169
|
+
return nil, fmt.Errorf("%s requires the complete ordered sequence_parent chain", slug)
|
|
170
|
+
}
|
|
171
|
+
for i, pred := range predecessors {
|
|
172
|
+
if pred != chain[i] {
|
|
173
|
+
return nil, fmt.Errorf("%s requires the complete ordered sequence_parent chain", slug)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return manifests, nil
|
|
153
177
|
}
|
|
154
178
|
manifests := make([][]candidateRow, 0, len(predecessors))
|
|
155
179
|
for _, pred := range predecessors {
|
|
@@ -162,26 +186,52 @@ func explicitManifests(root, slug string, predecessors []string) ([][]candidateR
|
|
|
162
186
|
return manifests, nil
|
|
163
187
|
}
|
|
164
188
|
|
|
165
|
-
// walkSequenceChain follows
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
189
|
+
// walkSequenceChain follows sequence_parent links, returning predecessors
|
|
190
|
+
// oldest-first. Numbered chains must descend consecutively to position 1;
|
|
191
|
+
// fully unnumbered legacy chains may end at any parentless workspace. Broken
|
|
192
|
+
// links or mixed numbering refuse the merge instead of producing a partial union.
|
|
169
193
|
func walkSequenceChain(root, slug, workspace string) ([]string, [][]candidateRow, error) {
|
|
170
194
|
visited := map[string]struct{}{slug: {}}
|
|
171
195
|
chain := []string{}
|
|
172
196
|
manifests := [][]candidateRow{}
|
|
173
197
|
dir := workspace
|
|
198
|
+
numbered := false
|
|
199
|
+
var expectedPosition uint64
|
|
174
200
|
for {
|
|
175
201
|
raw, err := readBoundedRegularFile(filepath.Join(dir, "state.md"), maxCandidateManifestBytes)
|
|
176
202
|
if err != nil {
|
|
177
203
|
return nil, nil, fmt.Errorf("%s state.md: %w", filepath.Base(dir), err)
|
|
178
204
|
}
|
|
179
|
-
|
|
205
|
+
lines := strings.Split(string(raw), "\n")
|
|
206
|
+
parent, _ := state.CursorField(lines, state.CursorSequenceParent)
|
|
207
|
+
positionText, hasPosition := state.CursorField(lines, state.CursorSequencePosition)
|
|
208
|
+
if len(chain) == 0 {
|
|
209
|
+
numbered = hasPosition
|
|
210
|
+
}
|
|
211
|
+
if hasPosition != numbered {
|
|
212
|
+
return nil, nil, fmt.Errorf("%s mixes numbered and unnumbered sequence positions", filepath.Base(dir))
|
|
213
|
+
}
|
|
214
|
+
if numbered {
|
|
215
|
+
position, err := strconv.ParseUint(positionText, 10, 64)
|
|
216
|
+
if err != nil || position == 0 || strconv.FormatUint(position, 10) != positionText {
|
|
217
|
+
return nil, nil, fmt.Errorf("%s sequence_position %q must be a positive canonical decimal", filepath.Base(dir), positionText)
|
|
218
|
+
}
|
|
219
|
+
if len(chain) > 0 && position != expectedPosition {
|
|
220
|
+
return nil, nil, fmt.Errorf("%s sequence_position %d; expected %d", filepath.Base(dir), position, expectedPosition)
|
|
221
|
+
}
|
|
222
|
+
if parent == "" && position != 1 {
|
|
223
|
+
return nil, nil, fmt.Errorf("%s sequence_position %d has no sequence_parent; only position 1 may end the chain", filepath.Base(dir), position)
|
|
224
|
+
}
|
|
225
|
+
if parent != "" && position == 1 {
|
|
226
|
+
return nil, nil, fmt.Errorf("%s sequence_position 1 must not have a sequence_parent", filepath.Base(dir))
|
|
227
|
+
}
|
|
228
|
+
expectedPosition = position - 1
|
|
229
|
+
}
|
|
180
230
|
if parent == "" {
|
|
181
231
|
break
|
|
182
232
|
}
|
|
183
233
|
if len(chain) == maxSequenceChain {
|
|
184
|
-
return nil, nil, fmt.Errorf("sequence chain longer than %d
|
|
234
|
+
return nil, nil, fmt.Errorf("sequence chain longer than %d", maxSequenceChain)
|
|
185
235
|
}
|
|
186
236
|
if _, dup := visited[parent]; dup {
|
|
187
237
|
return nil, nil, fmt.Errorf("sequence_parent cycle at %q", parent)
|
|
@@ -201,7 +251,7 @@ func walkSequenceChain(root, slug, workspace string) ([]string, [][]candidateRow
|
|
|
201
251
|
chain = append(chain, parent)
|
|
202
252
|
manifests = append(manifests, rows)
|
|
203
253
|
}
|
|
204
|
-
if len(chain) == 0 {
|
|
254
|
+
if len(chain) == 0 && !numbered {
|
|
205
255
|
return nil, nil, fmt.Errorf("%s has no sequence_parent; pass an explicit ordered predecessor list", slug)
|
|
206
256
|
}
|
|
207
257
|
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
|
|
@@ -211,19 +261,6 @@ func walkSequenceChain(root, slug, workspace string) ([]string, [][]candidateRow
|
|
|
211
261
|
return chain, manifests, nil
|
|
212
262
|
}
|
|
213
263
|
|
|
214
|
-
func sequenceParent(root, slug string) string {
|
|
215
|
-
dir, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
216
|
-
if err != nil {
|
|
217
|
-
return ""
|
|
218
|
-
}
|
|
219
|
-
raw, err := readBoundedRegularFile(filepath.Join(dir, "state.md"), maxCandidateManifestBytes)
|
|
220
|
-
if err != nil {
|
|
221
|
-
return ""
|
|
222
|
-
}
|
|
223
|
-
parent, _ := state.CursorField(strings.Split(string(raw), "\n"), state.CursorSequenceParent)
|
|
224
|
-
return parent
|
|
225
|
-
}
|
|
226
|
-
|
|
227
264
|
// VerifyReleaseUnion enforces the release-milestone contract at the candidate
|
|
228
265
|
// and seal gates: a workspace whose cursor declares `sequence_role: release`
|
|
229
266
|
// must carry a candidate manifest covering every predecessor in its recorded
|
|
@@ -16,11 +16,11 @@ import (
|
|
|
16
16
|
"github.com/devrites/devrites/internal/workflow"
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
// RunNext prints the minimal remaining lifecycle path
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// RunNext prints the minimal remaining lifecycle path using artifact presence
|
|
20
|
+
// where it proves completion, retaining phases that add no required artifact.
|
|
21
|
+
// Advisory only — the rite still owns the decision to advance — except the
|
|
22
|
+
// root-edit tripwire, which exits 3 while an unattributed product edit is in the
|
|
23
|
+
// tree. Observation never records lifecycle metrics.
|
|
24
24
|
func RunNext(root string, args []string, stdout, stderr io.Writer) int {
|
|
25
25
|
slug, code, err := ActiveSlug(root, args)
|
|
26
26
|
if err != nil {
|
|
@@ -55,6 +55,7 @@ func RunNext(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
55
55
|
return 2
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
fmt.Fprintf(stdout, "source: %s\n", slug)
|
|
58
59
|
fmt.Fprintf(stdout, "current: %s\n", report.Phase)
|
|
59
60
|
if report.Complete() {
|
|
60
61
|
fmt.Fprintln(stdout, "current-status: complete")
|
|
@@ -69,7 +70,6 @@ func RunNext(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
69
70
|
fmt.Fprintf(stdout, "blocked: tree baseline: %v\n", err)
|
|
70
71
|
exit = 3
|
|
71
72
|
} else if len(flagged) > 0 {
|
|
72
|
-
recordUnattributed(root, slug, string(report.Phase), flagged)
|
|
73
73
|
fmt.Fprintf(stdout, "blocked: %s\n", unattributedMessage(slug, flagged))
|
|
74
74
|
exit = 3
|
|
75
75
|
}
|
|
@@ -94,7 +94,7 @@ func RunNext(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
94
94
|
}
|
|
95
95
|
var missing []string
|
|
96
96
|
if i == currentIdx {
|
|
97
|
-
if report.Complete() {
|
|
97
|
+
if report.Complete() && (currentIdx == 0 || addsArtifact(policies[currentIdx-1], policy) || policy.Target == state.PhaseConverge) {
|
|
98
98
|
continue
|
|
99
99
|
}
|
|
100
100
|
missing = report.MissingFiles
|
|
@@ -114,7 +114,7 @@ func RunNext(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
fmt.Fprintf(stdout, "next-phase: %s\n", policy.Target)
|
|
117
|
-
fmt.Fprintf(stdout, "next-command: %s\n", renderNextCommand(policy))
|
|
117
|
+
fmt.Fprintf(stdout, "next-command: %s\n", renderNextCommand(policy, slug))
|
|
118
118
|
if policy.BlocksOpenQuestions && openQuestions > 0 && policy.Target != state.PhaseClarify {
|
|
119
119
|
fmt.Fprintf(stdout, "blocked: %d open question(s) gate this phase\n", openQuestions)
|
|
120
120
|
}
|
|
@@ -153,12 +153,12 @@ func printPlanBatch(stdout io.Writer, root, slug string) {
|
|
|
153
153
|
fmt.Fprintf(stdout, "select-command: %s\n", planSelectCommand(filepath.Dir(root), slug, capN))
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
func renderNextCommand(policy state.PhasePolicy) string {
|
|
156
|
+
func renderNextCommand(policy state.PhasePolicy, slug string) string {
|
|
157
157
|
verb := policy.ResumeVerb
|
|
158
158
|
if verb == "" {
|
|
159
159
|
return "(no resume command)"
|
|
160
160
|
}
|
|
161
|
-
return workflow.ForVerb(verb).
|
|
161
|
+
return workflow.ForVerb(verb).SourceInvocation(slug)
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
func printSkips(stdout io.Writer, skips []string) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package lib
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"crypto/sha256"
|
|
4
5
|
"errors"
|
|
5
6
|
"fmt"
|
|
6
7
|
"io"
|
|
@@ -25,11 +26,14 @@ check, and each --checks gates.md gate's CHECK, which must be an approved
|
|
|
25
26
|
test-plan.md Build-entry preflight row; a native-worktree pilot keeps its own
|
|
26
27
|
contract. A reviewer
|
|
27
28
|
role (*-reviewer, *-auditor, test-analyst) writes packets/<w>.<role>.md with
|
|
28
|
-
its Outcome/Counts return head. Read first names
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
packets/<w>.
|
|
32
|
-
|
|
29
|
+
its Outcome/Counts return head. Read first names an immutable wave/role copy
|
|
30
|
+
of the role's context bundle, bound by its actual SHA256 in the packet
|
|
31
|
+
(run context first; --recheck copies the build --recheck bundle), and
|
|
32
|
+
packets/<w>.diff when candidate freeze wrote one. The context path names the
|
|
33
|
+
selected workspace, including authorized overrides outside the project.
|
|
34
|
+
Candidate copies packets/<w>.sha256 verbatim, or only its header rows when the per-file rows
|
|
35
|
+
would overflow the packet (required at review and seal). A wave/role cannot
|
|
36
|
+
be re-rendered with a different context read-set. --focus (relative
|
|
33
37
|
to the workspace) holds one "- <ID> <path:line> <text>" row per line, text up
|
|
34
38
|
to 240 characters, and any other non-blank line is refused: open findings
|
|
35
39
|
for a reviewer, defects and quoted carry-forward constraints for a wright,
|
|
@@ -108,13 +112,22 @@ func renderPacketLocked(root, slug, workspace, wave, phase, role, slice, checks,
|
|
|
108
112
|
}
|
|
109
113
|
rel := workspaceRel(root, workspace)
|
|
110
114
|
bundle := "ctx/" + bundleName(contextOpts{phase: phase, role: role, slice: slice, recheck: recheck})
|
|
111
|
-
|
|
115
|
+
context, err := (&contextSources{dir: workspace}).read(bundle)
|
|
116
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
112
117
|
scope := recheck
|
|
113
118
|
if slice != "" {
|
|
114
119
|
scope = " --slice " + slice + recheck
|
|
115
120
|
}
|
|
116
121
|
return fail(fmt.Errorf("%s/%s is missing; run devrites-engine context %s --phase %s --role %s%s first", rel, bundle, slug, phase, role, scope))
|
|
117
122
|
}
|
|
123
|
+
if err != nil {
|
|
124
|
+
return fail(err)
|
|
125
|
+
}
|
|
126
|
+
if len(context) == 0 {
|
|
127
|
+
return fail(fmt.Errorf("%s/%s is empty; run context first", rel, bundle))
|
|
128
|
+
}
|
|
129
|
+
contextName := wave + "." + role + ".context.bundle.md"
|
|
130
|
+
contextPath := rel + "/packets/" + contextName
|
|
118
131
|
candidate, partial, err := frozenCandidate(workspace, wave)
|
|
119
132
|
if err != nil {
|
|
120
133
|
return fail(err)
|
|
@@ -135,10 +148,11 @@ func renderPacketLocked(root, slug, workspace, wave, phase, role, slice, checks,
|
|
|
135
148
|
} else {
|
|
136
149
|
b.WriteString("Authority: read-only reviewer. Edit nothing and ask the user nothing; judge the candidate against the bundle sources.\n")
|
|
137
150
|
}
|
|
138
|
-
fmt.Fprintf(&b, "\nRead first:\n- %s
|
|
151
|
+
fmt.Fprintf(&b, "\nRead first:\n- %s\n", contextPath)
|
|
139
152
|
if candidate != "" {
|
|
140
153
|
fmt.Fprintf(&b, "- %s/packets/%s.diff\n", rel, wave)
|
|
141
154
|
}
|
|
155
|
+
fmt.Fprintf(&b, "\nContext binding:\ncontext %x %s\n", sha256.Sum256(context), contextPath)
|
|
142
156
|
if wright {
|
|
143
157
|
raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
|
|
144
158
|
if err != nil {
|
|
@@ -199,7 +213,14 @@ func renderPacketLocked(root, slug, workspace, wave, phase, role, slice, checks,
|
|
|
199
213
|
name = wave + ".contract.md"
|
|
200
214
|
}
|
|
201
215
|
out := filepath.Join(workspace, "packets", name)
|
|
202
|
-
if err :=
|
|
216
|
+
if prior, err := readBoundedRegularFile(out, maxContractBytes); err == nil {
|
|
217
|
+
if err := lintPacketContexts(root, slug, prior); err != nil {
|
|
218
|
+
return fail(fmt.Errorf("existing packet context: %w", err))
|
|
219
|
+
}
|
|
220
|
+
} else if !errors.Is(err, os.ErrNotExist) {
|
|
221
|
+
return fail(err)
|
|
222
|
+
}
|
|
223
|
+
if err := retainPacketContext(workspace, contextName, context); err != nil {
|
|
203
224
|
return fail(err)
|
|
204
225
|
}
|
|
205
226
|
// Lint a sibling draft so a refused re-render keeps the packet already on disk.
|
|
@@ -231,6 +252,9 @@ func renderPacketLocked(root, slug, workspace, wave, phase, role, slice, checks,
|
|
|
231
252
|
// contract also gets the gates.md `RUN: writer` precheck lint.
|
|
232
253
|
func lintRenderedPacket(root, slug, path, role string, wright, finalPhase bool) error {
|
|
233
254
|
if wright {
|
|
255
|
+
if err := lintPacketDiffs(root, slug, path, false); err != nil {
|
|
256
|
+
return err
|
|
257
|
+
}
|
|
234
258
|
return lintWrightContractFor(root, slug, path)
|
|
235
259
|
}
|
|
236
260
|
if err := lintReviewerWavePacket(path, []string{role}); err != nil {
|
|
@@ -8,7 +8,10 @@ import (
|
|
|
8
8
|
"io"
|
|
9
9
|
"os"
|
|
10
10
|
"path/filepath"
|
|
11
|
+
"regexp"
|
|
11
12
|
"strings"
|
|
13
|
+
|
|
14
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
12
15
|
)
|
|
13
16
|
|
|
14
17
|
// Binary patches can exceed the candidate's raw-byte bound; stream verification.
|
|
@@ -85,13 +88,21 @@ func verifyFrozenDiff(name, expected string) error {
|
|
|
85
88
|
|
|
86
89
|
func resolvePacketDiff(root, slug, name string) (string, error) {
|
|
87
90
|
clean := filepath.Clean(filepath.FromSlash(name))
|
|
91
|
+
workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return "", err
|
|
94
|
+
}
|
|
95
|
+
if rel, ok := strings.CutPrefix(name, workspaceRel(root, workspace)+"/"); ok && clean == filepath.FromSlash(name) {
|
|
96
|
+
name = rel
|
|
97
|
+
clean = filepath.Clean(filepath.FromSlash(name))
|
|
98
|
+
}
|
|
88
99
|
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean != filepath.FromSlash(name) {
|
|
89
100
|
return "", fmt.Errorf("unsafe frozen candidate diff path %q", name)
|
|
90
101
|
}
|
|
91
|
-
dirs := []string{projectDir(root),
|
|
102
|
+
dirs := []string{projectDir(root), workspace}
|
|
92
103
|
// Freeze's checksum rows use this workspace-relative namespace, never a product packets/ directory.
|
|
93
104
|
if strings.HasPrefix(name, "packets/") {
|
|
94
|
-
dirs = []string{
|
|
105
|
+
dirs = []string{workspace}
|
|
95
106
|
}
|
|
96
107
|
if filepath.IsAbs(name) {
|
|
97
108
|
parent, err := filepath.EvalSymlinks(filepath.Dir(name))
|
|
@@ -120,12 +131,193 @@ func resolvePacketDiff(root, slug, name string) (string, error) {
|
|
|
120
131
|
return "", fmt.Errorf("frozen candidate diff %q does not exist", name)
|
|
121
132
|
}
|
|
122
133
|
|
|
134
|
+
var packetContextPathRE = regexp.MustCompile(`(?:[A-Za-z]:/)?[A-Za-z0-9_./@+-]+\.bundle\.md\b`)
|
|
135
|
+
|
|
136
|
+
type packetContextRef struct {
|
|
137
|
+
dir string
|
|
138
|
+
name string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Preserve the leaf spelling below the workspace when an ancestor has an OS alias.
|
|
142
|
+
func packetContextWorkspaceRelative(workspace, name string) (string, error) {
|
|
143
|
+
if rel, err := filepath.Rel(workspace, name); err == nil && filepath.IsLocal(rel) {
|
|
144
|
+
return rel, nil
|
|
145
|
+
}
|
|
146
|
+
selected, err := os.Stat(workspace)
|
|
147
|
+
if err != nil {
|
|
148
|
+
return "", err
|
|
149
|
+
}
|
|
150
|
+
for parent := filepath.Dir(name); ; parent = filepath.Dir(parent) {
|
|
151
|
+
info, err := os.Stat(parent)
|
|
152
|
+
if err != nil {
|
|
153
|
+
return "", err
|
|
154
|
+
}
|
|
155
|
+
if os.SameFile(selected, info) {
|
|
156
|
+
return filepath.Rel(parent, name)
|
|
157
|
+
}
|
|
158
|
+
if parent == filepath.Dir(parent) {
|
|
159
|
+
return "", nil
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func resolvePacketContext(root, slug, name string) (packetContextRef, error) {
|
|
165
|
+
workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
166
|
+
if err != nil {
|
|
167
|
+
return packetContextRef{}, err
|
|
168
|
+
}
|
|
169
|
+
workspace, err = filepath.Abs(workspace)
|
|
170
|
+
if err != nil {
|
|
171
|
+
return packetContextRef{}, err
|
|
172
|
+
}
|
|
173
|
+
clean := filepath.Clean(filepath.FromSlash(name))
|
|
174
|
+
if clean != filepath.FromSlash(name) {
|
|
175
|
+
return packetContextRef{}, fmt.Errorf("unsafe packet context path %q", name)
|
|
176
|
+
}
|
|
177
|
+
dirs := []string{projectDir(root), workspace}
|
|
178
|
+
// Only the selected workspace's exact namespace may contain leading "..".
|
|
179
|
+
if rel, ok := strings.CutPrefix(name, workspaceRel(root, workspace)+"/"); ok {
|
|
180
|
+
dirs, clean = []string{workspace}, filepath.FromSlash(rel)
|
|
181
|
+
} else if filepath.IsAbs(clean) {
|
|
182
|
+
rel, err := packetContextWorkspaceRelative(workspace, clean)
|
|
183
|
+
if err != nil {
|
|
184
|
+
return packetContextRef{}, err
|
|
185
|
+
}
|
|
186
|
+
if rel != "" {
|
|
187
|
+
dirs, clean = []string{workspace}, rel
|
|
188
|
+
} else {
|
|
189
|
+
// Absolute manual bundles remain supported, but not foreign workspaces.
|
|
190
|
+
physicalParent, err := filepath.EvalSymlinks(filepath.Dir(clean))
|
|
191
|
+
if err != nil {
|
|
192
|
+
return packetContextRef{}, err
|
|
193
|
+
}
|
|
194
|
+
physical := filepath.Join(physicalParent, filepath.Base(clean))
|
|
195
|
+
for _, work := range []string{filepath.Dir(workspace), filepath.Join(root, "work")} {
|
|
196
|
+
absoluteWork, err := filepath.Abs(work)
|
|
197
|
+
if err != nil {
|
|
198
|
+
return packetContextRef{}, err
|
|
199
|
+
}
|
|
200
|
+
absoluteWork, err = filepath.EvalSymlinks(absoluteWork)
|
|
201
|
+
if err != nil {
|
|
202
|
+
return packetContextRef{}, err
|
|
203
|
+
}
|
|
204
|
+
if rel, err := filepath.Rel(absoluteWork, physical); err == nil && filepath.IsLocal(rel) {
|
|
205
|
+
return packetContextRef{}, fmt.Errorf("foreign packet context path %q", name)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
dirs, clean = []string{filepath.Dir(clean)}, filepath.Base(clean)
|
|
209
|
+
}
|
|
210
|
+
} else if strings.HasPrefix(name, "ctx/") || strings.HasPrefix(name, "packets/") {
|
|
211
|
+
dirs = []string{workspace}
|
|
212
|
+
} else {
|
|
213
|
+
work := filepath.ToSlash(workspaceRel(root, filepath.Dir(workspace))) + "/"
|
|
214
|
+
if strings.HasPrefix(name, work) {
|
|
215
|
+
return packetContextRef{}, fmt.Errorf("foreign packet context path %q", name)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if !filepath.IsLocal(clean) {
|
|
219
|
+
return packetContextRef{}, fmt.Errorf("unsafe packet context path %q", name)
|
|
220
|
+
}
|
|
221
|
+
for _, dir := range dirs {
|
|
222
|
+
if err := contextParents(dir, clean); err != nil {
|
|
223
|
+
return packetContextRef{}, err
|
|
224
|
+
}
|
|
225
|
+
ws, err := os.OpenRoot(dir)
|
|
226
|
+
if err != nil {
|
|
227
|
+
return packetContextRef{}, err
|
|
228
|
+
}
|
|
229
|
+
info, err := ws.Lstat(clean)
|
|
230
|
+
closeErr := ws.Close()
|
|
231
|
+
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
232
|
+
return packetContextRef{}, err
|
|
233
|
+
}
|
|
234
|
+
if closeErr != nil {
|
|
235
|
+
return packetContextRef{}, closeErr
|
|
236
|
+
}
|
|
237
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
if !info.Mode().IsRegular() {
|
|
241
|
+
return packetContextRef{}, errors.New("packet context is not a regular file")
|
|
242
|
+
}
|
|
243
|
+
absoluteDir, err := filepath.Abs(dir)
|
|
244
|
+
if err != nil {
|
|
245
|
+
return packetContextRef{}, err
|
|
246
|
+
}
|
|
247
|
+
return packetContextRef{dir: absoluteDir, name: clean}, nil
|
|
248
|
+
}
|
|
249
|
+
return packetContextRef{}, fmt.Errorf("packet context %q does not exist", name)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func lintPacketContexts(root, slug string, raw []byte) error {
|
|
253
|
+
bound := make(map[string]string)
|
|
254
|
+
var names []string
|
|
255
|
+
for _, line := range strings.Split(string(raw), "\n") {
|
|
256
|
+
fields := strings.Fields(line)
|
|
257
|
+
if len(fields) > 0 && fields[0] == "context" {
|
|
258
|
+
if len(fields) < 3 || len(fields[1]) != sha256.Size*2 {
|
|
259
|
+
return errors.New("malformed context checksum; use context <SHA256> <path.bundle.md>")
|
|
260
|
+
}
|
|
261
|
+
if _, err := hex.DecodeString(fields[1]); err != nil {
|
|
262
|
+
return errors.New("malformed context SHA256 checksum")
|
|
263
|
+
}
|
|
264
|
+
entry := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "context"))
|
|
265
|
+
name := strings.TrimSpace(entry[len(fields[1]):])
|
|
266
|
+
ref, err := resolvePacketContext(root, slug, name)
|
|
267
|
+
if err != nil {
|
|
268
|
+
return err
|
|
269
|
+
}
|
|
270
|
+
path := filepath.Join(ref.dir, ref.name)
|
|
271
|
+
digest := strings.ToLower(fields[1])
|
|
272
|
+
if prior, ok := bound[path]; ok && prior != digest {
|
|
273
|
+
return fmt.Errorf("conflicting context checksums for %s", name)
|
|
274
|
+
}
|
|
275
|
+
body, err := readContextArtifact(ref.dir, ref.name)
|
|
276
|
+
if err != nil {
|
|
277
|
+
return fmt.Errorf("packet context %s: %w", name, err)
|
|
278
|
+
}
|
|
279
|
+
if len(body) == 0 || fmt.Sprintf("%x", sha256.Sum256(body)) != digest {
|
|
280
|
+
return fmt.Errorf("packet context %s SHA256 mismatch; render the intended read-set under a new wave", name)
|
|
281
|
+
}
|
|
282
|
+
bound[path] = digest
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if len(fields) >= 3 && fields[0] == "file" {
|
|
286
|
+
if fields[1] == "absent" {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if digest, err := hex.DecodeString(fields[1]); err == nil && len(digest) == sha256.Size {
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if name, ok := strings.CutPrefix(strings.TrimSpace(line), "- "); ok && strings.HasSuffix(name, ".bundle.md") {
|
|
294
|
+
names = append(names, name)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
names = append(names, packetContextPathRE.FindAllString(line, -1)...)
|
|
298
|
+
}
|
|
299
|
+
for _, name := range names {
|
|
300
|
+
ref, err := resolvePacketContext(root, slug, name)
|
|
301
|
+
if err != nil {
|
|
302
|
+
return err
|
|
303
|
+
}
|
|
304
|
+
path := filepath.Join(ref.dir, ref.name)
|
|
305
|
+
if _, ok := bound[path]; !ok {
|
|
306
|
+
return fmt.Errorf("unbound packet context %s; add context <SHA256> %s to the packet", name, name)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return nil
|
|
310
|
+
}
|
|
311
|
+
|
|
123
312
|
// Caller may already hold the feature lock (dispatch); never acquire it here.
|
|
124
313
|
func lintPacketDiffs(root, slug, packet string, required bool) error {
|
|
125
314
|
raw, err := readBoundedRegularFile(packet, maxContractBytes)
|
|
126
315
|
if err != nil {
|
|
127
316
|
return fmt.Errorf("reviewer packet %s: %w", packet, err)
|
|
128
317
|
}
|
|
318
|
+
if err := lintPacketContexts(root, slug, raw); err != nil {
|
|
319
|
+
return err
|
|
320
|
+
}
|
|
129
321
|
bindings, err := frozenDiffBindings(raw)
|
|
130
322
|
if err != nil {
|
|
131
323
|
return err
|