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.
Files changed (161) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0034-workflow-artifact-byte-corrections-get-code-review.md +60 -0
  4. package/docs/adr/README.md +2 -1
  5. package/docs/engine/commands.md +8 -8
  6. package/engine/help.go +12 -7
  7. package/engine/help_test.go +1 -0
  8. package/engine/internal/gate/readiness_binding.go +19 -0
  9. package/engine/internal/lib/cli_observe.go +16 -0
  10. package/engine/internal/lib/context.go +197 -19
  11. package/engine/internal/lib/context_next_test.go +238 -0
  12. package/engine/internal/lib/dispatch.go +189 -15
  13. package/engine/internal/lib/drift.go +10 -2
  14. package/engine/internal/lib/drift_test.go +29 -0
  15. package/engine/internal/lib/metrics.go +74 -1
  16. package/engine/internal/lib/observe_budgets_test.go +32 -0
  17. package/engine/internal/lib/observe_summary.go +8 -0
  18. package/engine/internal/lib/slicecheck.go +19 -0
  19. package/engine/internal/lib/slicecheck_test.go +41 -0
  20. package/engine/internal/lib/windows.go +109 -13
  21. package/engine/internal/lib/windows_test.go +85 -0
  22. package/engine/main.go +3 -3
  23. package/pack/.claude/agents/devrites-slice-wright.md +7 -2
  24. package/pack/.claude/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  25. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  26. package/pack/.claude/skills/devrites-lib/reference/standards/README.md +1 -1
  27. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  28. package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +3 -0
  29. package/pack/.claude/skills/devrites-lib/reference/standards/glossary.md +1 -1
  30. package/pack/.claude/skills/devrites-lib/reference/standards/security.md +1 -0
  31. package/pack/.claude/skills/devrites-lib/reference/standards/windows.md +11 -3
  32. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  33. package/pack/.claude/skills/rite-build/SKILL.md +1 -1
  34. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +3 -1
  35. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
  36. package/pack/.claude/skills/rite-build/reference/output.md +1 -1
  37. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +5 -3
  38. package/pack/.claude/skills/rite-build/reference/phase-contract.md +7 -3
  39. package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +3 -1
  40. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +23 -9
  41. package/pack/.claude/skills/rite-define/SKILL.md +2 -1
  42. package/pack/.claude/skills/rite-plan/SKILL.md +2 -1
  43. package/pack/.claude/skills/rite-pressure-test/SKILL.md +6 -3
  44. package/pack/.claude/skills/rite-vet/SKILL.md +9 -1
  45. package/pack/.claude/skills/rite-vet/reference/review-axes.md +6 -2
  46. package/pack/generated/claude/agents/devrites-slice-wright.md +7 -2
  47. package/pack/generated/claude/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  48. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  49. package/pack/generated/claude/skills/devrites-lib/reference/standards/README.md +1 -1
  50. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  51. package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +3 -0
  52. package/pack/generated/claude/skills/devrites-lib/reference/standards/glossary.md +1 -1
  53. package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +1 -0
  54. package/pack/generated/claude/skills/devrites-lib/reference/standards/windows.md +11 -3
  55. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  56. package/pack/generated/claude/skills/rite-build/SKILL.md +1 -1
  57. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +3 -1
  58. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +3 -2
  59. package/pack/generated/claude/skills/rite-build/reference/output.md +1 -1
  60. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +5 -3
  61. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +7 -3
  62. package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +3 -1
  63. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +23 -9
  64. package/pack/generated/claude/skills/rite-define/SKILL.md +2 -1
  65. package/pack/generated/claude/skills/rite-plan/SKILL.md +2 -1
  66. package/pack/generated/claude/skills/rite-pressure-test/SKILL.md +6 -3
  67. package/pack/generated/claude/skills/rite-vet/SKILL.md +9 -1
  68. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +6 -2
  69. package/pack/generated/codex/agents/devrites-slice-wright.toml +7 -2
  70. package/pack/generated/codex/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  71. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  72. package/pack/generated/codex/skills/devrites-lib/reference/standards/README.md +1 -1
  73. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  74. package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +3 -0
  75. package/pack/generated/codex/skills/devrites-lib/reference/standards/glossary.md +1 -1
  76. package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +1 -0
  77. package/pack/generated/codex/skills/devrites-lib/reference/standards/windows.md +11 -3
  78. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  79. package/pack/generated/codex/skills/rite-build/SKILL.md +1 -1
  80. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +3 -1
  81. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +3 -2
  82. package/pack/generated/codex/skills/rite-build/reference/output.md +1 -1
  83. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +5 -3
  84. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +7 -3
  85. package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +3 -1
  86. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +23 -9
  87. package/pack/generated/codex/skills/rite-define/SKILL.md +2 -1
  88. package/pack/generated/codex/skills/rite-plan/SKILL.md +2 -1
  89. package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +6 -3
  90. package/pack/generated/codex/skills/rite-vet/SKILL.md +9 -1
  91. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +6 -2
  92. package/pack/generated/devin/agents/devrites-slice-wright.md +7 -2
  93. package/pack/generated/devin/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  94. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  95. package/pack/generated/devin/skills/devrites-lib/reference/standards/README.md +1 -1
  96. package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  97. package/pack/generated/devin/skills/devrites-lib/reference/standards/code-review.md +3 -0
  98. package/pack/generated/devin/skills/devrites-lib/reference/standards/glossary.md +1 -1
  99. package/pack/generated/devin/skills/devrites-lib/reference/standards/security.md +1 -0
  100. package/pack/generated/devin/skills/devrites-lib/reference/standards/windows.md +11 -3
  101. package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  102. package/pack/generated/devin/skills/rite-build/SKILL.md +1 -1
  103. package/pack/generated/devin/skills/rite-build/reference/afk-discipline.md +3 -1
  104. package/pack/generated/devin/skills/rite-build/reference/one-slice-cycle.md +3 -2
  105. package/pack/generated/devin/skills/rite-build/reference/output.md +1 -1
  106. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +5 -3
  107. package/pack/generated/devin/skills/rite-build/reference/phase-contract.md +7 -3
  108. package/pack/generated/devin/skills/rite-build/reference/spec-drift-guard.md +3 -1
  109. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +23 -9
  110. package/pack/generated/devin/skills/rite-define/SKILL.md +2 -1
  111. package/pack/generated/devin/skills/rite-plan/SKILL.md +2 -1
  112. package/pack/generated/devin/skills/rite-pressure-test/SKILL.md +6 -3
  113. package/pack/generated/devin/skills/rite-vet/SKILL.md +9 -1
  114. package/pack/generated/devin/skills/rite-vet/reference/review-axes.md +6 -2
  115. package/pack/generated/omp/agents/devrites-slice-wright.md +7 -2
  116. package/pack/generated/omp/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  117. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  118. package/pack/generated/omp/skills/devrites-lib/reference/standards/README.md +1 -1
  119. package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  120. package/pack/generated/omp/skills/devrites-lib/reference/standards/code-review.md +3 -0
  121. package/pack/generated/omp/skills/devrites-lib/reference/standards/glossary.md +1 -1
  122. package/pack/generated/omp/skills/devrites-lib/reference/standards/security.md +1 -0
  123. package/pack/generated/omp/skills/devrites-lib/reference/standards/windows.md +11 -3
  124. package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  125. package/pack/generated/omp/skills/rite-build/SKILL.md +1 -1
  126. package/pack/generated/omp/skills/rite-build/reference/afk-discipline.md +3 -1
  127. package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +3 -2
  128. package/pack/generated/omp/skills/rite-build/reference/output.md +1 -1
  129. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +5 -3
  130. package/pack/generated/omp/skills/rite-build/reference/phase-contract.md +7 -3
  131. package/pack/generated/omp/skills/rite-build/reference/spec-drift-guard.md +3 -1
  132. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +23 -9
  133. package/pack/generated/omp/skills/rite-define/SKILL.md +2 -1
  134. package/pack/generated/omp/skills/rite-plan/SKILL.md +2 -1
  135. package/pack/generated/omp/skills/rite-pressure-test/SKILL.md +6 -3
  136. package/pack/generated/omp/skills/rite-vet/SKILL.md +9 -1
  137. package/pack/generated/omp/skills/rite-vet/reference/review-axes.md +6 -2
  138. package/pack/generated/pi/agents/devrites-slice-wright.md +7 -2
  139. package/pack/generated/pi/skills/devrites-debug-recovery/reference/hypotheses.md +6 -0
  140. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +1 -0
  141. package/pack/generated/pi/skills/devrites-lib/reference/standards/README.md +1 -1
  142. package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +2 -0
  143. package/pack/generated/pi/skills/devrites-lib/reference/standards/code-review.md +3 -0
  144. package/pack/generated/pi/skills/devrites-lib/reference/standards/glossary.md +1 -1
  145. package/pack/generated/pi/skills/devrites-lib/reference/standards/security.md +1 -0
  146. package/pack/generated/pi/skills/devrites-lib/reference/standards/windows.md +11 -3
  147. package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -2
  148. package/pack/generated/pi/skills/rite-build/SKILL.md +1 -1
  149. package/pack/generated/pi/skills/rite-build/reference/afk-discipline.md +3 -1
  150. package/pack/generated/pi/skills/rite-build/reference/one-slice-cycle.md +3 -2
  151. package/pack/generated/pi/skills/rite-build/reference/output.md +1 -1
  152. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +5 -3
  153. package/pack/generated/pi/skills/rite-build/reference/phase-contract.md +7 -3
  154. package/pack/generated/pi/skills/rite-build/reference/spec-drift-guard.md +3 -1
  155. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +23 -9
  156. package/pack/generated/pi/skills/rite-define/SKILL.md +2 -1
  157. package/pack/generated/pi/skills/rite-plan/SKILL.md +2 -1
  158. package/pack/generated/pi/skills/rite-pressure-test/SKILL.md +6 -3
  159. package/pack/generated/pi/skills/rite-vet/SKILL.md +9 -1
  160. package/pack/generated/pi/skills/rite-vet/reference/review-axes.md +6 -2
  161. 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 string `json:"phase"`
37
- State string `json:"state"` // open, sealed, abandoned, complete
38
- Reason string `json:"reason,omitempty"`
39
- Roles map[string]*dispatchRole `json:"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 string `json:"handle,omitempty"`
44
- StartedAt string `json:"started,omitempty"`
45
- Returned bool `json:"returned,omitempty"`
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
- diffs = append(diffs, driftDiff{Path: b.Path, Kind: "changed"})
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
+ }