devrites 6.0.2 → 6.0.4
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 +16 -0
- package/README.md +1 -1
- package/docs/adr/0034-workflow-artifact-byte-corrections-get-code-review.md +60 -0
- package/docs/adr/README.md +2 -1
- package/docs/engine/commands.md +8 -8
- package/engine/help.go +12 -7
- package/engine/help_test.go +1 -0
- package/engine/internal/gate/readiness_binding.go +19 -0
- package/engine/internal/lib/cli_observe.go +16 -0
- package/engine/internal/lib/context.go +197 -19
- package/engine/internal/lib/context_next_test.go +238 -0
- package/engine/internal/lib/dispatch.go +189 -15
- package/engine/internal/lib/drift.go +10 -2
- package/engine/internal/lib/drift_test.go +29 -0
- package/engine/internal/lib/metrics.go +74 -1
- package/engine/internal/lib/observe_budgets_test.go +32 -0
- package/engine/internal/lib/observe_summary.go +8 -0
- package/engine/internal/lib/slicecheck.go +19 -0
- package/engine/internal/lib/slicecheck_test.go +41 -0
- package/engine/internal/lib/windows.go +109 -13
- package/engine/internal/lib/windows_test.go +85 -0
- package/engine/main.go +3 -3
- package/pack/.claude/agents/devrites-slice-wright.md +7 -2
- package/pack/.claude/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/.claude/skills/rite-build/SKILL.md +1 -1
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/.claude/skills/rite-build/reference/output.md +1 -1
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/.claude/skills/rite-define/SKILL.md +2 -1
- package/pack/.claude/skills/rite-plan/SKILL.md +2 -1
- package/pack/.claude/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/.claude/skills/rite-vet/SKILL.md +9 -1
- package/pack/.claude/skills/rite-vet/reference/review-axes.md +6 -2
- package/pack/generated/claude/agents/devrites-slice-wright.md +7 -2
- package/pack/generated/claude/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/generated/claude/skills/rite-build/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/claude/skills/rite-build/reference/output.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/generated/claude/skills/rite-define/SKILL.md +2 -1
- package/pack/generated/claude/skills/rite-plan/SKILL.md +2 -1
- package/pack/generated/claude/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/generated/claude/skills/rite-vet/SKILL.md +9 -1
- package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +6 -2
- package/pack/generated/codex/agents/devrites-slice-wright.toml +7 -2
- package/pack/generated/codex/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/generated/codex/skills/rite-build/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/codex/skills/rite-build/reference/output.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/generated/codex/skills/rite-define/SKILL.md +2 -1
- package/pack/generated/codex/skills/rite-plan/SKILL.md +2 -1
- package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/generated/codex/skills/rite-vet/SKILL.md +9 -1
- package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +6 -2
- package/pack/generated/devin/agents/devrites-slice-wright.md +7 -2
- package/pack/generated/devin/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/generated/devin/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/generated/devin/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/generated/devin/skills/rite-build/SKILL.md +1 -1
- package/pack/generated/devin/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/generated/devin/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/devin/skills/rite-build/reference/output.md +1 -1
- package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/generated/devin/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/generated/devin/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/generated/devin/skills/rite-define/SKILL.md +2 -1
- package/pack/generated/devin/skills/rite-plan/SKILL.md +2 -1
- package/pack/generated/devin/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/generated/devin/skills/rite-vet/SKILL.md +9 -1
- package/pack/generated/devin/skills/rite-vet/reference/review-axes.md +6 -2
- package/pack/generated/omp/agents/devrites-slice-wright.md +7 -2
- package/pack/generated/omp/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/generated/omp/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/generated/omp/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/generated/omp/skills/rite-build/SKILL.md +1 -1
- package/pack/generated/omp/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/omp/skills/rite-build/reference/output.md +1 -1
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/generated/omp/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/generated/omp/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/generated/omp/skills/rite-define/SKILL.md +2 -1
- package/pack/generated/omp/skills/rite-plan/SKILL.md +2 -1
- package/pack/generated/omp/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/generated/omp/skills/rite-vet/SKILL.md +9 -1
- package/pack/generated/omp/skills/rite-vet/reference/review-axes.md +6 -2
- package/pack/generated/pi/agents/devrites-slice-wright.md +7 -2
- package/pack/generated/pi/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/README.md +1 -1
- package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/code-review.md +3 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/glossary.md +1 -1
- package/pack/generated/pi/skills/devrites-lib/reference/standards/security.md +1 -0
- package/pack/generated/pi/skills/devrites-lib/reference/standards/windows.md +11 -3
- package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
- package/pack/generated/pi/skills/rite-build/SKILL.md +1 -1
- package/pack/generated/pi/skills/rite-build/reference/afk-discipline.md +3 -1
- package/pack/generated/pi/skills/rite-build/reference/one-slice-cycle.md +3 -2
- package/pack/generated/pi/skills/rite-build/reference/output.md +1 -1
- package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +5 -3
- package/pack/generated/pi/skills/rite-build/reference/phase-contract.md +7 -3
- package/pack/generated/pi/skills/rite-build/reference/spec-drift-guard.md +3 -1
- package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +23 -9
- package/pack/generated/pi/skills/rite-define/SKILL.md +2 -1
- package/pack/generated/pi/skills/rite-plan/SKILL.md +2 -1
- package/pack/generated/pi/skills/rite-pressure-test/SKILL.md +6 -3
- package/pack/generated/pi/skills/rite-vet/SKILL.md +9 -1
- package/pack/generated/pi/skills/rite-vet/reference/review-axes.md +6 -2
- package/package.json +1 -1
|
@@ -672,3 +672,241 @@ func TestContextWorkspaceIncludeMarkerStaysLiteral(t *testing.T) {
|
|
|
672
672
|
t.Fatalf("workspace include marker not kept literal:\n%s", text)
|
|
673
673
|
}
|
|
674
674
|
}
|
|
675
|
+
|
|
676
|
+
func TestContextSliceScopesWorkspaceAndRole(t *testing.T) {
|
|
677
|
+
root, featureDir := newWorkspace(t, "build")
|
|
678
|
+
skills := newSkillsRoot(t)
|
|
679
|
+
writeFile(t, filepath.Join(skills, "rite-build", "reference", "root-procedure.md"), "# root procedure body\n")
|
|
680
|
+
writeFile(t, filepath.Join(skills, "rite-build", "SKILL.md"), "---\nname: rite-build\n---\n<!-- loads: {\"always\":[\"devrites-lib/reference/standards/core.md\",\"rite-build/reference/root-procedure.md\"],\"rootOnly\":[\"rite-build/reference/root-procedure.md\"],\"workspace\":[\"tasks.md\",\"decisions.md\",\"spec.md\"]} -->\n# build\n")
|
|
681
|
+
writeFile(t, filepath.Join(featureDir, "tasks.md"), "# Tasks\n\n## SLICE-001 First\nGoal: first body\n\n## SLICE-002 Second\nGoal: second body\nSatisfies: AC-001\nDecisions: DEC-010\ndepends_on: [SLICE-001]\n")
|
|
682
|
+
var decisions strings.Builder
|
|
683
|
+
decisions.WriteString("# Decisions\n")
|
|
684
|
+
for i := 0; i < 250; i++ {
|
|
685
|
+
decisions.WriteString("filler decision row\n")
|
|
686
|
+
}
|
|
687
|
+
decisions.WriteString("## DEC-010 Use the store\nDEC-0100 is a different id\n")
|
|
688
|
+
writeFile(t, filepath.Join(featureDir, "decisions.md"), decisions.String())
|
|
689
|
+
writeFile(t, filepath.Join(featureDir, "spec.md"), "# spec\nAC-001 small spec body\n")
|
|
690
|
+
|
|
691
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
692
|
+
args := []string{"feat", "--phase", "build", "--role", "slice-wright", "--slice", "SLICE-002", "--skills-root", skills}
|
|
693
|
+
if code := RunContext(root, args, stdout, stderr); code != 0 {
|
|
694
|
+
t.Fatalf("code=%d\n%s%s", code, stdout, stderr)
|
|
695
|
+
}
|
|
696
|
+
if !strings.Contains(stdout.String(), "scope: slice SLICE-002 (1 whole, 2 by-reference)") {
|
|
697
|
+
t.Fatalf("scope line missing:\n%s", stdout)
|
|
698
|
+
}
|
|
699
|
+
raw, err := os.ReadFile(filepath.Join(featureDir, "ctx", "build-slice-wright.SLICE-002.bundle.md"))
|
|
700
|
+
if err != nil {
|
|
701
|
+
t.Fatalf("bundle: %v", err)
|
|
702
|
+
}
|
|
703
|
+
text := string(raw)
|
|
704
|
+
for _, want := range []string{
|
|
705
|
+
"Goal: second body",
|
|
706
|
+
"workspace/decisions.md (by reference)",
|
|
707
|
+
"/work/feat/decisions.md sha256: ",
|
|
708
|
+
"- L252 ## DEC-010 Use the store",
|
|
709
|
+
"- DEC-010: L252\n",
|
|
710
|
+
"- SLICE-001: L3 L10\n",
|
|
711
|
+
"- AC-001: L8\n",
|
|
712
|
+
"small spec body",
|
|
713
|
+
"===== root-only (not inlined for role slice-wright) =====\nrite-build/reference/root-procedure.md",
|
|
714
|
+
} {
|
|
715
|
+
if !strings.Contains(text, want) {
|
|
716
|
+
t.Fatalf("bundle missing %q:\n%s", want, text)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
for _, absent := range []string{"Goal: first body", "filler decision row", "root procedure body"} {
|
|
720
|
+
if strings.Contains(text, absent) {
|
|
721
|
+
t.Fatalf("bundle inlined %q:\n%s", absent, text)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if strings.Index(text, "# wright") > strings.Index(text, "# core") {
|
|
725
|
+
t.Fatalf("role contract must lead the bundle:\n%s", text)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Without --role the root still gets its procedure inline.
|
|
729
|
+
stdout.Reset()
|
|
730
|
+
if code := RunContext(root, []string{"feat", "--phase", "build", "--skills-root", skills}, stdout, stderr); code != 0 {
|
|
731
|
+
t.Fatalf("phase bundle code=%d\n%s", code, stderr)
|
|
732
|
+
}
|
|
733
|
+
phase, err := os.ReadFile(filepath.Join(featureDir, "ctx", "build.bundle.md"))
|
|
734
|
+
if err != nil || !strings.Contains(string(phase), "root procedure body") || !strings.Contains(string(phase), "Goal: first body") {
|
|
735
|
+
t.Fatalf("phase bundle must stay whole: err=%v\n%s", err, phase)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
func TestContextSliceRejectsBadOrUnknownID(t *testing.T) {
|
|
740
|
+
root, featureDir := newWorkspace(t, "build")
|
|
741
|
+
skills := newSkillsRoot(t)
|
|
742
|
+
writeFile(t, filepath.Join(featureDir, "tasks.md"), "# Tasks\n\n## SLICE-001 First\nGoal: x\n")
|
|
743
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
744
|
+
if code := RunContext(root, []string{"feat", "--phase", "build", "--slice", "slice-1", "--skills-root", skills}, stdout, stderr); code != 2 {
|
|
745
|
+
t.Fatalf("malformed id: code=%d want 2\n%s", code, stderr)
|
|
746
|
+
}
|
|
747
|
+
if code := RunContext(root, []string{"--skill", "rite-build", "--slice", "SLICE-001", "--out", "x.md", "--skills-root", skills}, stdout, stderr); code != 2 {
|
|
748
|
+
t.Fatalf("slice without slug: code=%d want 2\n%s", code, stderr)
|
|
749
|
+
}
|
|
750
|
+
stdout.Reset()
|
|
751
|
+
if code := RunContext(root, []string{"feat", "--phase", "build", "--slice", "SLICE-009", "--skills-root", skills}, stdout, stderr); code != 3 || !strings.Contains(stdout.String(), "BLOCKED: SLICE-009 not found") {
|
|
752
|
+
t.Fatalf("unknown slice: code=%d\n%s", code, stdout)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
func TestDispatchWrightRoundCap(t *testing.T) {
|
|
757
|
+
root, featureDir := newWorkspace(t, "build")
|
|
758
|
+
run := func(args ...string) (int, string) {
|
|
759
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
760
|
+
code := RunDispatch(root, append([]string{"feat"}, args...), stdout, stderr)
|
|
761
|
+
return code, stdout.String() + stderr.String()
|
|
762
|
+
}
|
|
763
|
+
open := func(wave string, extra ...string) (int, string) {
|
|
764
|
+
return run(append([]string{"open", "--phase", "build", "--wave", wave, "--role", "slice-wright", "--slice", "SLICE-001"}, extra...)...)
|
|
765
|
+
}
|
|
766
|
+
if code, out := run("open", "--phase", "build", "--wave", "w0", "--role", "slice-wright"); code != 2 || !strings.Contains(out, "needs --slice") {
|
|
767
|
+
t.Fatalf("missing --slice: code=%d\n%s", code, out)
|
|
768
|
+
}
|
|
769
|
+
if code, out := run("open", "--phase", "vet", "--wave", "v1", "--role", "plan-reviewer"); code != 0 {
|
|
770
|
+
t.Fatalf("non-build wave must not need --slice: code=%d\n%s", code, out)
|
|
771
|
+
}
|
|
772
|
+
for i := 1; i <= wrightRoundsPerReview; i++ {
|
|
773
|
+
code, out := open("r" + string(rune('0'+i)))
|
|
774
|
+
if code != 0 || !strings.Contains(out, "SLICE-001 wright round "+string(rune('0'+i))) {
|
|
775
|
+
t.Fatalf("round %d: code=%d\n%s", i, code, out)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if code, out := run("abandon", "--wave", "r5", "--reason", "orphaned"); !strings.Contains(out, "wave r5 abandoned") {
|
|
779
|
+
t.Fatalf("abandon: code=%d\n%s", code, out)
|
|
780
|
+
}
|
|
781
|
+
if code, out := open("r5b"); code != 0 || !strings.Contains(out, "wright round 5") {
|
|
782
|
+
t.Fatalf("abandoned wave must not count: code=%d\n%s", code, out)
|
|
783
|
+
}
|
|
784
|
+
if code, out := open("r6"); code != 3 || !strings.Contains(out, "BLOCKED: SLICE-001 reached 5 wright rounds") {
|
|
785
|
+
t.Fatalf("round 6 must block: code=%d\n%s", code, out)
|
|
786
|
+
}
|
|
787
|
+
writeFile(t, filepath.Join(featureDir, "drift.md"), "# Drift\n\n## D-010 — SLICE-002 unrelated\n\n## D-011 — SLICE-001 reslice review: no smaller AC-preserving split\n")
|
|
788
|
+
if code, out := open("r6", "--reslice-review", "D-099"); code != 3 {
|
|
789
|
+
t.Fatalf("unknown drift id: code=%d\n%s", code, out)
|
|
790
|
+
}
|
|
791
|
+
if code, out := open("r6", "--reslice-review", "D-010"); code != 3 || !strings.Contains(out, "naming SLICE-001") {
|
|
792
|
+
t.Fatalf("drift heading for another slice: code=%d\n%s", code, out)
|
|
793
|
+
}
|
|
794
|
+
if code, out := open("r6", "--reslice-review", "D-011"); code != 0 || !strings.Contains(out, "wright round 6") {
|
|
795
|
+
t.Fatalf("valid review must admit: code=%d\n%s", code, out)
|
|
796
|
+
}
|
|
797
|
+
for i := 7; i <= 2*wrightRoundsPerReview; i++ {
|
|
798
|
+
if code, out := open("r" + string(rune('a'+i))); code != 0 {
|
|
799
|
+
t.Fatalf("round %d inside the reviewed window: code=%d\n%s", i, code, out)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if code, out := open("r11", "--reslice-review", "D-011"); code != 3 || !strings.Contains(out, "already admitted") {
|
|
803
|
+
t.Fatalf("reused review: code=%d\n%s", code, out)
|
|
804
|
+
}
|
|
805
|
+
if code, out := run("open", "--phase", "build", "--wave", "s2", "--role", "slice-wright", "--slice", "SLICE-002", "--reslice-review", "D-010"); code != 2 {
|
|
806
|
+
t.Fatalf("review before the cap must be refused: code=%d\n%s", code, out)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func TestDispatchVetRoundsCountSeparately(t *testing.T) {
|
|
811
|
+
root, featureDir := newWorkspace(t, "build")
|
|
812
|
+
run := func(args ...string) (int, string) {
|
|
813
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
814
|
+
code := RunDispatch(root, append([]string{"feat"}, args...), stdout, stderr)
|
|
815
|
+
return code, stdout.String() + stderr.String()
|
|
816
|
+
}
|
|
817
|
+
for i := 0; i < 7; i++ {
|
|
818
|
+
if code, out := run("open", "--phase", "vet", "--wave", "full"+string(rune('a'+i)), "--role", "plan-reviewer"); code != 0 {
|
|
819
|
+
t.Fatalf("full Vet without --slice stays uncapped: code=%d\n%s", code, out)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if code, out := run("open", "--phase", "build", "--wave", "w1", "--role", "slice-wright", "--slice", "SLICE-001"); code != 0 || !strings.Contains(out, "wright round 1") {
|
|
823
|
+
t.Fatalf("wright round must not see vet waves: code=%d\n%s", code, out)
|
|
824
|
+
}
|
|
825
|
+
for i := 1; i <= wrightRoundsPerReview; i++ {
|
|
826
|
+
if code, out := run("open", "--phase", "vet", "--wave", "v"+string(rune('0'+i)), "--role", "plan-reviewer", "--slice", "SLICE-001"); code != 0 || !strings.Contains(out, "vet round "+string(rune('0'+i))) {
|
|
827
|
+
t.Fatalf("vet round %d: code=%d\n%s", i, code, out)
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if code, out := run("open", "--phase", "vet", "--wave", "v6", "--role", "plan-reviewer", "--slice", "SLICE-001"); code != 3 || !strings.Contains(out, "reached 5 vet rounds") {
|
|
831
|
+
t.Fatalf("vet round 6 must block: code=%d\n%s", code, out)
|
|
832
|
+
}
|
|
833
|
+
writeFile(t, filepath.Join(featureDir, "drift.md"), "## D-001 — SLICE-001 reslice review: proof seam already minimal\n")
|
|
834
|
+
if code, out := run("open", "--phase", "vet", "--wave", "v6", "--role", "plan-reviewer", "--slice", "SLICE-001", "--reslice-review", "D-001"); code != 0 {
|
|
835
|
+
t.Fatalf("reviewed vet round 6: code=%d\n%s", code, out)
|
|
836
|
+
}
|
|
837
|
+
if code, out := run("open", "--phase", "plan", "--wave", "p1", "--role", "plan-drafter", "--slice", "SLICE-001"); code != 2 {
|
|
838
|
+
t.Fatalf("--slice on a plan wave: code=%d\n%s", code, out)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
func TestContextRepeatedSliceUnion(t *testing.T) {
|
|
843
|
+
root, featureDir := newWorkspace(t, "build")
|
|
844
|
+
skills := newSkillsRoot(t)
|
|
845
|
+
writeFile(t, filepath.Join(skills, "rite-build", "SKILL.md"), "---\nname: rite-build\n---\n<!-- loads: {\"always\":[\"devrites-lib/reference/standards/core.md\"],\"workspace\":[\"tasks.md\"]} -->\n# build\n")
|
|
846
|
+
writeFile(t, filepath.Join(featureDir, "tasks.md"), "# Tasks\n\n## SLICE-001 A\nGoal: alpha body\n\n## SLICE-002 B\nGoal: beta body\n\n## SLICE-003 C\nGoal: gamma body\n")
|
|
847
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
848
|
+
args := []string{"feat", "--phase", "build", "--slice", "SLICE-001", "--slice", "SLICE-003", "--skills-root", skills}
|
|
849
|
+
if code := RunContext(root, args, stdout, stderr); code != 0 {
|
|
850
|
+
t.Fatalf("code=%d\n%s%s", code, stdout, stderr)
|
|
851
|
+
}
|
|
852
|
+
raw, err := os.ReadFile(filepath.Join(featureDir, "ctx", "build.SLICE-001+SLICE-003.bundle.md"))
|
|
853
|
+
if err != nil {
|
|
854
|
+
t.Fatal(err)
|
|
855
|
+
}
|
|
856
|
+
text := string(raw)
|
|
857
|
+
if !strings.Contains(text, "alpha body") || !strings.Contains(text, "gamma body") || strings.Contains(text, "beta body") {
|
|
858
|
+
t.Fatalf("union of slice blocks wrong:\n%s", text)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
func TestContextRootBundleAnchorsOverBudgetArtifacts(t *testing.T) {
|
|
863
|
+
root, featureDir := newWorkspace(t, "build")
|
|
864
|
+
skills := newSkillsRoot(t)
|
|
865
|
+
writeFile(t, filepath.Join(skills, "rite-build", "SKILL.md"), "---\nname: rite-build\n---\n<!-- loads: {\"always\":[\"devrites-lib/reference/standards/core.md\"],\"workspace\":[\"decisions.md\",\"spec.md\"]} -->\n# build\n")
|
|
866
|
+
writeFile(t, filepath.Join(featureDir, "decisions.md"), "# Decisions\n"+strings.Repeat("filler decision row\n", 250)+"## DEC-010 Use the store\n")
|
|
867
|
+
writeFile(t, filepath.Join(featureDir, "spec.md"), "# spec\nsmall spec body\n")
|
|
868
|
+
run := func(args ...string) string {
|
|
869
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
870
|
+
if code := RunContext(root, append([]string{"feat", "--phase", "build", "--skills-root", skills}, args...), stdout, stderr); code != 0 {
|
|
871
|
+
t.Fatalf("code=%d\n%s%s", code, stdout, stderr)
|
|
872
|
+
}
|
|
873
|
+
return stdout.String()
|
|
874
|
+
}
|
|
875
|
+
out := run()
|
|
876
|
+
if !strings.Contains(out, "scope: root (1 whole, 1 by-reference over budget)") {
|
|
877
|
+
t.Fatalf("root scope line missing:\n%s", out)
|
|
878
|
+
}
|
|
879
|
+
rootBundle, _ := os.ReadFile(filepath.Join(featureDir, "ctx", "build.bundle.md"))
|
|
880
|
+
if text := string(rootBundle); !strings.Contains(text, "workspace/decisions.md (by reference)") || strings.Contains(text, "filler decision row") || !strings.Contains(text, "small spec body") {
|
|
881
|
+
t.Fatalf("root bundle must anchor only the over-budget artifact:\n%s", text)
|
|
882
|
+
}
|
|
883
|
+
run("--role", "code-reviewer")
|
|
884
|
+
roleBundle, _ := os.ReadFile(filepath.Join(featureDir, "ctx", "build-code-reviewer.bundle.md"))
|
|
885
|
+
if !strings.Contains(string(roleBundle), "filler decision row") {
|
|
886
|
+
t.Fatalf("a role bundle without --slice must stay whole:\n%s", roleBundle)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
func TestMetricsSummaryBySlice(t *testing.T) {
|
|
891
|
+
root, featureDir := newWorkspace(t, "build")
|
|
892
|
+
writeFile(t, filepath.Join(featureDir, "dispatch.json"), `{"waves":{
|
|
893
|
+
"w1":{"phase":"build","state":"complete","slices":["SLICE-001"],"roles":{"slice-wright":{"handle":"a","started":"2026-10-15T10:00:00Z","returned":true,"returned_at":"2026-10-15T10:30:00Z"}}},
|
|
894
|
+
"w2":{"phase":"build","state":"complete","slices":["SLICE-001","SLICE-002"],"roles":{"slice-wright":{"handle":"b","started":"2026-10-15T11:00:00Z","returned":true,"returned_at":"2026-10-15T11:20:00Z"}}},
|
|
895
|
+
"w3":{"phase":"build","state":"abandoned","slices":["SLICE-001"],"roles":{"slice-wright":{"handle":"c"}}},
|
|
896
|
+
"v1":{"phase":"vet","state":"complete","slices":["SLICE-002"],"roles":{"plan-reviewer":{"handle":"d","started":"2026-10-15T12:00:00Z","returned":true}}},
|
|
897
|
+
"f1":{"phase":"vet","state":"complete","roles":{"plan-reviewer":{"handle":"e"}}}
|
|
898
|
+
}}`)
|
|
899
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
900
|
+
if code := RunMetrics(root, []string{"summary", "feat", "--by-slice"}, stdout, stderr); code != 0 {
|
|
901
|
+
t.Fatalf("code=%d\n%s", code, stderr)
|
|
902
|
+
}
|
|
903
|
+
out := stdout.String()
|
|
904
|
+
for _, want := range []string{
|
|
905
|
+
"metrics: SLICE-001 wright_rounds=2 vet_rechecks=0 agent_minutes=40\n",
|
|
906
|
+
"metrics: SLICE-002 wright_rounds=1 vet_rechecks=1 agent_minutes=10\n",
|
|
907
|
+
} {
|
|
908
|
+
if !strings.Contains(out, want) {
|
|
909
|
+
t.Fatalf("missing %q:\n%s", want, out)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
@@ -6,6 +6,8 @@ import (
|
|
|
6
6
|
"io"
|
|
7
7
|
"os"
|
|
8
8
|
"path/filepath"
|
|
9
|
+
"regexp"
|
|
10
|
+
"slices"
|
|
9
11
|
"sort"
|
|
10
12
|
"strings"
|
|
11
13
|
"time"
|
|
@@ -19,30 +21,45 @@ import (
|
|
|
19
21
|
// fan-out cannot silently degrade to serial (open -> every role started ->
|
|
20
22
|
// seal -> returns -> complete). start/return also auto-record metrics events.
|
|
21
23
|
//
|
|
22
|
-
// devrites-engine dispatch <slug> <sub> [--phase p] [--wave w] [--role r] [--handle h] [--reason s]
|
|
24
|
+
// devrites-engine dispatch <slug> <sub> [--phase p] [--wave w] [--role r] [--handle h] [--reason s] [--slice SLICE-###] [--reslice-review <DRIFT-ID>]
|
|
23
25
|
|
|
24
|
-
const dispatchUsage = `usage: devrites-engine dispatch <slug> <open|start|seal|return|status|abandon> [--phase p] [--wave w] [--role r] [--handle h] [--reason s]
|
|
26
|
+
const dispatchUsage = `usage: devrites-engine dispatch <slug> <open|start|seal|return|status|abandon> [--phase p] [--wave w] [--role r] [--handle h] [--reason s] [--slice SLICE-###] [--reslice-review <DRIFT-ID>]
|
|
25
27
|
|
|
26
28
|
Wave barrier for parallel agent dispatch: seal fails until every declared
|
|
27
29
|
role has a distinct handle; return fails before seal; status exits nonzero
|
|
28
30
|
while a wave is open, sealed, or abandoned.
|
|
31
|
+
|
|
32
|
+
A build wave with a wright role must name its slice(s) with --slice; a vet
|
|
33
|
+
wave may (slice-bound recheck). Every 5 rounds of one kind on one slice, the
|
|
34
|
+
next open is blocked until --reslice-review names a drift.md
|
|
35
|
+
"## <DRIFT-ID>" heading for that slice.
|
|
29
36
|
`
|
|
30
37
|
|
|
38
|
+
// wrightRoundsPerReview is how many build wright rounds (or slice-bound Vet
|
|
39
|
+
// rechecks) one slice may take before (and after each) reslice review.
|
|
40
|
+
// Recovery itself stays unbounded; the cap only forces the split question
|
|
41
|
+
// onto the record.
|
|
42
|
+
const wrightRoundsPerReview = 5
|
|
43
|
+
|
|
31
44
|
type dispatchFile struct {
|
|
32
45
|
Waves map[string]*dispatchWave `json:"waves"`
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
type dispatchWave struct {
|
|
36
|
-
Phase
|
|
37
|
-
State
|
|
38
|
-
Reason
|
|
39
|
-
Roles
|
|
49
|
+
Phase string `json:"phase"`
|
|
50
|
+
State string `json:"state"` // open, sealed, abandoned, complete
|
|
51
|
+
Reason string `json:"reason,omitempty"`
|
|
52
|
+
Roles map[string]*dispatchRole `json:"roles"`
|
|
53
|
+
Slices []string `json:"slices,omitempty"`
|
|
54
|
+
ResliceReview string `json:"reslice_review,omitempty"`
|
|
55
|
+
ResliceReviewed []string `json:"reslice_reviewed,omitempty"`
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
type dispatchRole struct {
|
|
43
|
-
Handle
|
|
44
|
-
StartedAt
|
|
45
|
-
Returned
|
|
59
|
+
Handle string `json:"handle,omitempty"`
|
|
60
|
+
StartedAt string `json:"started,omitempty"`
|
|
61
|
+
Returned bool `json:"returned,omitempty"`
|
|
62
|
+
ReturnedAt string `json:"returned_at,omitempty"`
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
func dispatchPath(root, slug string) string {
|
|
@@ -89,8 +106,8 @@ func nowStamp() string { return time.Now().UTC().Format(time.RFC3339) }
|
|
|
89
106
|
|
|
90
107
|
// RunDispatch implements the dispatch wave state machine.
|
|
91
108
|
func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
92
|
-
var slug, sub, phase, wave, role, handle, reason string
|
|
93
|
-
var roles []string
|
|
109
|
+
var slug, sub, phase, wave, role, handle, reason, review string
|
|
110
|
+
var roles, sliceIDs []string
|
|
94
111
|
var positional []string
|
|
95
112
|
for i := 0; i < len(args); i++ {
|
|
96
113
|
value := argAt(args, i+1)
|
|
@@ -106,6 +123,10 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
106
123
|
handle = value
|
|
107
124
|
case "--reason":
|
|
108
125
|
reason = value
|
|
126
|
+
case "--slice":
|
|
127
|
+
sliceIDs = append(sliceIDs, value)
|
|
128
|
+
case "--reslice-review":
|
|
129
|
+
review = value
|
|
109
130
|
default:
|
|
110
131
|
if strings.HasPrefix(args[i], "-") {
|
|
111
132
|
fmt.Fprintf(stderr, "dispatch: unknown flag %q\n", args[i])
|
|
@@ -129,7 +150,7 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
129
150
|
// an update and strand the wave short of complete.
|
|
130
151
|
code := 0
|
|
131
152
|
if err := state.WithFeatureLock(root, slug, func() error {
|
|
132
|
-
code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, stdout, stderr)
|
|
153
|
+
code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, stdout, stderr)
|
|
133
154
|
return nil
|
|
134
155
|
}); err != nil {
|
|
135
156
|
fmt.Fprintf(stderr, "dispatch: %v\n", err)
|
|
@@ -138,7 +159,7 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
138
159
|
return code
|
|
139
160
|
}
|
|
140
161
|
|
|
141
|
-
func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles []string, stdout, stderr io.Writer) int {
|
|
162
|
+
func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles, sliceIDs []string, review string, stdout, stderr io.Writer) int {
|
|
142
163
|
f, path, err := loadDispatch(root, slug)
|
|
143
164
|
if err != nil {
|
|
144
165
|
fmt.Fprintf(stderr, "dispatch: %v\n", err)
|
|
@@ -166,18 +187,25 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
166
187
|
if w := getWave(); w != nil && w.State != "abandoned" {
|
|
167
188
|
return fail("wave %q already exists (state %s)", wave, w.State)
|
|
168
189
|
}
|
|
169
|
-
w := &dispatchWave{Phase: phase, State: "open", Roles: map[string]*dispatchRole{}}
|
|
190
|
+
w := &dispatchWave{Phase: phase, State: "open", Roles: map[string]*dispatchRole{}, Slices: sliceIDs}
|
|
170
191
|
for _, r := range roles {
|
|
171
192
|
if w.Roles[r] != nil {
|
|
172
193
|
return fail("duplicate role %q in wave", r)
|
|
173
194
|
}
|
|
174
195
|
w.Roles[r] = &dispatchRole{}
|
|
175
196
|
}
|
|
197
|
+
rounds, code := admitWrightRounds(root, slug, f, w, review, stderr)
|
|
198
|
+
if code != 0 {
|
|
199
|
+
return code
|
|
200
|
+
}
|
|
176
201
|
f.Waves[wave] = w
|
|
177
202
|
if err := saveDispatch(path, f); err != nil {
|
|
178
203
|
return fail("%v", err)
|
|
179
204
|
}
|
|
180
205
|
fmt.Fprintf(stdout, "dispatch: wave %s open, %d roles declared\n", wave, len(roles))
|
|
206
|
+
for _, line := range rounds {
|
|
207
|
+
fmt.Fprintln(stdout, line)
|
|
208
|
+
}
|
|
181
209
|
return 0
|
|
182
210
|
|
|
183
211
|
case "start":
|
|
@@ -243,7 +271,7 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
243
271
|
if r.Returned {
|
|
244
272
|
return fail("role %q already returned", role)
|
|
245
273
|
}
|
|
246
|
-
r.Returned = true
|
|
274
|
+
r.Returned, r.ReturnedAt = true, nowStamp()
|
|
247
275
|
all := true
|
|
248
276
|
for _, o := range w.Roles {
|
|
249
277
|
if !o.Returned {
|
|
@@ -322,6 +350,152 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
|
|
|
322
350
|
return 2
|
|
323
351
|
}
|
|
324
352
|
|
|
353
|
+
// roundKind names the capped loop a wave belongs to: build wright rounds or
|
|
354
|
+
// slice-bound Vet rechecks. Other waves are uncapped.
|
|
355
|
+
func roundKind(w *dispatchWave) string {
|
|
356
|
+
switch {
|
|
357
|
+
case isBuildWrightWave(w):
|
|
358
|
+
return "wright"
|
|
359
|
+
case strings.EqualFold(w.Phase, "vet") && len(w.Slices) > 0:
|
|
360
|
+
return "vet"
|
|
361
|
+
}
|
|
362
|
+
return ""
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
func isBuildWrightWave(w *dispatchWave) bool {
|
|
366
|
+
if w.Phase != "build" {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
for r := range w.Roles {
|
|
370
|
+
if strings.Contains(r, "wright") {
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// admitWrightRounds enforces the per-slice round cap for a new build wright
|
|
378
|
+
// or slice-bound Vet wave and returns the round lines to print. A slice may
|
|
379
|
+
// take wrightRoundsPerReview rounds of each kind per reslice review (the first
|
|
380
|
+
// window is free); beyond that the open needs --reslice-review naming an
|
|
381
|
+
// unused drift.md heading for every slice over the cap.
|
|
382
|
+
func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, review string, stderr io.Writer) ([]string, int) {
|
|
383
|
+
kind := roundKind(w)
|
|
384
|
+
if kind == "" {
|
|
385
|
+
if review != "" || (len(w.Slices) > 0 && !strings.EqualFold(w.Phase, "vet")) {
|
|
386
|
+
fmt.Fprintln(stderr, "dispatch: --slice/--reslice-review apply only to build wright waves and slice-bound vet waves")
|
|
387
|
+
return nil, 2
|
|
388
|
+
}
|
|
389
|
+
return nil, 0
|
|
390
|
+
}
|
|
391
|
+
if len(w.Slices) == 0 {
|
|
392
|
+
fmt.Fprintln(stderr, "dispatch: a build wright wave needs --slice SLICE-### for each slice it builds")
|
|
393
|
+
return nil, 2
|
|
394
|
+
}
|
|
395
|
+
for _, id := range w.Slices {
|
|
396
|
+
if !state.ValidSliceID(id) {
|
|
397
|
+
fmt.Fprintf(stderr, "dispatch: %q is not a SLICE-### id\n", id)
|
|
398
|
+
return nil, 2
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
var lines, overCap []string
|
|
402
|
+
for _, id := range w.Slices {
|
|
403
|
+
prior, reviews := 0, 0
|
|
404
|
+
var unreturned []string
|
|
405
|
+
for name, other := range f.Waves {
|
|
406
|
+
if other.State == "abandoned" || roundKind(other) != kind {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if slices.Contains(other.Slices, id) {
|
|
410
|
+
prior++
|
|
411
|
+
if other.State == "open" || other.State == "sealed" {
|
|
412
|
+
unreturned = append(unreturned, name)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if slices.Contains(other.ResliceReviewed, id) {
|
|
416
|
+
reviews++
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
round := prior + 1
|
|
420
|
+
if round > wrightRoundsPerReview*(1+reviews) {
|
|
421
|
+
overCap = append(overCap, id)
|
|
422
|
+
}
|
|
423
|
+
lines = append(lines, fmt.Sprintf("dispatch: %s %s round %d (reslice review every %d)", id, kind, round, wrightRoundsPerReview))
|
|
424
|
+
if len(unreturned) > 0 {
|
|
425
|
+
sort.Strings(unreturned)
|
|
426
|
+
lines = append(lines, fmt.Sprintf("dispatch: WARNING: %s earlier wave(s) %s never returned; record `dispatch return` or `dispatch abandon --reason`", id, strings.Join(unreturned, ",")))
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if len(overCap) == 0 {
|
|
430
|
+
if review != "" {
|
|
431
|
+
fmt.Fprintf(stderr, "dispatch: --reslice-review given but no slice has reached its %d-round cap\n", wrightRoundsPerReview)
|
|
432
|
+
return nil, 2
|
|
433
|
+
}
|
|
434
|
+
return lines, 0
|
|
435
|
+
}
|
|
436
|
+
if review == "" {
|
|
437
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: %s reached %d %s rounds; run a reslice review (acceptance-preserving split or simpler proof seam, or a drift.md entry recording why neither applies) and reopen with --reslice-review <DRIFT-ID>\n",
|
|
438
|
+
strings.Join(overCap, ","), wrightRoundsPerReview, kind)
|
|
439
|
+
return nil, 3
|
|
440
|
+
}
|
|
441
|
+
for _, other := range f.Waves {
|
|
442
|
+
if other.State != "abandoned" && other.ResliceReview == review && roundKind(other) == kind {
|
|
443
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: reslice review %s already admitted another round window\n", review)
|
|
444
|
+
return nil, 3
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
drift, err := readWorkspaceArtifact(root, slug, "drift.md")
|
|
448
|
+
if err != nil {
|
|
449
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: reslice review %s: %v\n", review, err)
|
|
450
|
+
return nil, 3
|
|
451
|
+
}
|
|
452
|
+
heading := driftHeading(string(drift), review)
|
|
453
|
+
for _, id := range overCap {
|
|
454
|
+
if !regexp.MustCompile(`\b` + regexp.QuoteMeta(id) + `\b`).MatchString(heading) {
|
|
455
|
+
fmt.Fprintf(stderr, "dispatch: BLOCKED: drift.md has no \"## %s\" heading naming %s\n", review, id)
|
|
456
|
+
return nil, 3
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
w.ResliceReview, w.ResliceReviewed = review, overCap
|
|
460
|
+
return lines, 0
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// driftHeading returns the drift.md level-2 heading line for id, or "".
|
|
464
|
+
func driftHeading(drift, id string) string {
|
|
465
|
+
re := regexp.MustCompile(`(?m)^## ` + regexp.QuoteMeta(id) + `\b.*$`)
|
|
466
|
+
return re.FindString(drift)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// InflightWave is a dispatch wave whose agents have not all returned.
|
|
470
|
+
type InflightWave struct {
|
|
471
|
+
Wave string `json:"wave"`
|
|
472
|
+
Phase string `json:"phase"`
|
|
473
|
+
State string `json:"state"`
|
|
474
|
+
Pending []string `json:"pending"`
|
|
475
|
+
Slices []string `json:"slices,omitempty"`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// inflightWaves lists open or sealed waves with the roles still awaiting a
|
|
479
|
+
// return, sorted by wave name.
|
|
480
|
+
func inflightWaves(f *dispatchFile) []InflightWave {
|
|
481
|
+
var out []InflightWave
|
|
482
|
+
for name, w := range f.Waves {
|
|
483
|
+
if w.State != "open" && w.State != "sealed" {
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
var pending []string
|
|
487
|
+
for role, r := range w.Roles {
|
|
488
|
+
if !r.Returned {
|
|
489
|
+
pending = append(pending, role)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
sort.Strings(pending)
|
|
493
|
+
out = append(out, InflightWave{Wave: name, Phase: w.Phase, State: w.State, Pending: pending, Slices: w.Slices})
|
|
494
|
+
}
|
|
495
|
+
sort.Slice(out, func(i, j int) bool { return out[i].Wave < out[j].Wave })
|
|
496
|
+
return out
|
|
497
|
+
}
|
|
498
|
+
|
|
325
499
|
func waveState(w *dispatchWave) string {
|
|
326
500
|
if w == nil {
|
|
327
501
|
return "absent"
|
|
@@ -48,7 +48,7 @@ type driftBaseline struct {
|
|
|
48
48
|
// driftDiff is one input whose state differs from the baseline.
|
|
49
49
|
type driftDiff struct {
|
|
50
50
|
Path string `json:"path"`
|
|
51
|
-
Kind string `json:"kind"` // changed | missing | added
|
|
51
|
+
Kind string `json:"kind"` // changed | missing | added | reviewed-identity-only
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// RunCheckDrift implements `devrites-engine check drift`.
|
|
@@ -130,6 +130,10 @@ func compareDrift(workspace, baselinePath string, digests []gate.ReadinessInputD
|
|
|
130
130
|
if baseline.Binding != binding && len(diffs) == 0 {
|
|
131
131
|
fmt.Fprintln(stdout, "DRIFT: aggregate binding changed without a recorded input diff; re-record the baseline")
|
|
132
132
|
}
|
|
133
|
+
if len(diffs) == 1 && diffs[0].Kind == "reviewed-identity-only" {
|
|
134
|
+
fmt.Fprintln(stdout, "check drift: reviewed-identity-only — only the Workflow Artifact reviewed identity moved; /rite-vet may use its bytes-only recheck")
|
|
135
|
+
return 0
|
|
136
|
+
}
|
|
133
137
|
fmt.Fprintf(stdout, "check drift: %d input(s) differ — a change is legal only as a dated delta through the owning rite; otherwise restore the input or rerun /rite-vet and --record\n", len(diffs))
|
|
134
138
|
return 0
|
|
135
139
|
}
|
|
@@ -189,7 +193,11 @@ func diffInputs(base, current []gate.ReadinessInputDigest) []driftDiff {
|
|
|
189
193
|
}
|
|
190
194
|
diffs = append(diffs, driftDiff{Path: b.Path, Kind: kind})
|
|
191
195
|
case b.Present && b.SHA256 != c.SHA256:
|
|
192
|
-
|
|
196
|
+
kind := "changed"
|
|
197
|
+
if b.Normalized != "" && b.Normalized == c.Normalized {
|
|
198
|
+
kind = "reviewed-identity-only"
|
|
199
|
+
}
|
|
200
|
+
diffs = append(diffs, driftDiff{Path: b.Path, Kind: kind})
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
sort.Slice(diffs, func(i, j int) bool { return diffs[i].Path < diffs[j].Path })
|
|
@@ -104,3 +104,32 @@ func TestDriftCorruptBaselineBlocks(t *testing.T) {
|
|
|
104
104
|
t.Fatalf("code=%d out=%s", code, out)
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
func TestDriftClassifiesReviewedIdentityOnlyChange(t *testing.T) {
|
|
109
|
+
root, featureDir := driftWorkspace(t)
|
|
110
|
+
if code, out := checkDrift(t, root, "feat", "--record"); code != 0 {
|
|
111
|
+
t.Fatalf("record code=%d out=%s", code, out)
|
|
112
|
+
}
|
|
113
|
+
plan := filepath.Join(featureDir, "test-plan.md")
|
|
114
|
+
line := "Workflow Artifact reviewed identity: " + strings.Repeat("ab", 32) + "\n"
|
|
115
|
+
writeFile(t, plan, "# test-plan.md\n\ncontent\n"+line)
|
|
116
|
+
code, out := checkDrift(t, root, "feat")
|
|
117
|
+
if code != 0 || !strings.Contains(out, "test-plan.md reviewed-identity-only") || !strings.Contains(out, "bytes-only recheck") {
|
|
118
|
+
t.Fatalf("identity-only change not classified: code=%d\n%s", code, out)
|
|
119
|
+
}
|
|
120
|
+
// Any other edit, including a malformed identity line, is ordinary drift.
|
|
121
|
+
for _, body := range []string{
|
|
122
|
+
"# test-plan.md\n\nchanged\n" + line,
|
|
123
|
+
"# test-plan.md\n\ncontent\nWorkflow Artifact reviewed identity: not-a-digest\n",
|
|
124
|
+
} {
|
|
125
|
+
writeFile(t, plan, body)
|
|
126
|
+
if _, out := checkDrift(t, root, "feat"); !strings.Contains(out, "test-plan.md changed") || strings.Contains(out, "bytes-only") {
|
|
127
|
+
t.Fatalf("non-identity edit hidden:\n%s", out)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
writeFile(t, plan, "# test-plan.md\n\ncontent\n"+line)
|
|
131
|
+
writeFile(t, filepath.Join(featureDir, "plan.md"), "# plan.md\n\nrewritten\n")
|
|
132
|
+
if _, out := checkDrift(t, root, "feat"); strings.Contains(out, "bytes-only") {
|
|
133
|
+
t.Fatalf("a second changed input must void the bytes-only route:\n%s", out)
|
|
134
|
+
}
|
|
135
|
+
}
|