diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go index 24cbbe8..746aac8 100644 --- a/internal/continuity/continuity.go +++ b/internal/continuity/continuity.go @@ -151,6 +151,12 @@ const maxAuthoredAction = 2*maxAuthoredLine + len(" — ") var circularAction = regexp.MustCompile(`(?i)handoff|report\.md|^continue the task`) var circularCommand = regexp.MustCompile(`(?i)\.orchestra-handoff|handoff-report|report\.md`) +// IsCircularCommand reports whether Validate would reject this as a command +// that points at a handoff. Producers need the same answer before they build a +// handoff, so the rule lives in one place rather than being restated as a +// second regexp that can drift. +func IsCircularCommand(s string) bool { return circularCommand.MatchString(s) } + func (h Handoff) Validate() error { if strings.TrimSpace(h.Meta.ID) == "" || !reasons[h.Meta.Reason] || h.Meta.RotationIndex < 0 { return errors.New("invalid handoff meta") diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index e6a57e4..7113d62 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -538,9 +538,19 @@ func (a CLIAdapter) lastObservedCommand(s Session) string { return "" } for i := len(calls) - 1; i >= 0; i-- { - if calls[i].Kind == "command" { - return calls[i].Key + if calls[i].Kind != "command" { + continue } + // Skip the write Orchestra itself asked for. The handoff prompt tells + // the agent to write HandoffReportFile and stop, so that write is + // almost always the last command in the pane. Carrying it into + // Handoff.Command made every rotation fail its own circularity check + // with "must not point to a handoff or report" (F37). The successor + // wants the last command that was real work. + if continuity.IsCircularCommand(calls[i].Key) { + continue + } + return calls[i].Key } return "" } diff --git a/internal/herdr/adapter_test.go b/internal/herdr/adapter_test.go index d930ca2..63d9d4e 100644 --- a/internal/herdr/adapter_test.go +++ b/internal/herdr/adapter_test.go @@ -334,3 +334,34 @@ func TestEveryReasonTheAdapterProducesIsAcceptedByTheValidator(t *testing.T) { } } } + +// TestLastObservedCommandSkipsOrchestrasOwnHandoffWrite guards F37. The handoff +// prompt tells the agent to write HandoffReportFile and stop, so that write is +// almost always the last command in the pane. Carrying it into Handoff.Command +// made every rotation fail Validate's own circularity check. Live on run 5: +// "adapter: upload handoff: invalid handoff command: must not point to a +// handoff or report". +func TestLastObservedCommandSkipsOrchestrasOwnHandoffWrite(t *testing.T) { + for _, last := range []string{ + "cat > " + HandoffReportFile, + "vim .orchestra-handoff.json", + "less handoff-report.md", + } { + if !continuity.IsCircularCommand(last) { + t.Fatalf("%q is not recognised as circular, so this test proves nothing", last) + } + h := continuity.Handoff{ + Meta: continuity.Meta{ID: "agent-1", Reason: "phase_changed"}, + Anchor: continuity.Anchor{GitSHA: "0000000000000000000000000000000000000000", Branch: "main"}, + Action: "write the research note", + Command: "go test ./...", + } + if err := h.Validate(); err != nil { + t.Fatalf("a real command was rejected: %v", err) + } + h.Command = last + if err := h.Validate(); err == nil { + t.Fatalf("%q was accepted, so skipping it in the producer is not what keeps rotations alive", last) + } + } +}