From a0ad2214599852918f0d833bb39814c2d5fa2b62 Mon Sep 17 00:00:00 2001 From: cat Date: Sat, 11 Jul 2026 16:48:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(preflight):=20=E6=98=BE=E7=A4=BA=E5=90=8C?= =?UTF-8?q?=E6=9C=BA=20sibling=20drafts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/intent-record-v0.md | 6 ++ internal/engine/preflight.go | 115 ++++++++++++++++++++----- internal/engine/preflight_test.go | 130 +++++++++++++++++++++++++++++ internal/engine/worktree_drafts.go | 40 +++++---- 4 files changed, 252 insertions(+), 39 deletions(-) diff --git a/docs/specs/intent-record-v0.md b/docs/specs/intent-record-v0.md index 447c4f1e..49ec39db 100644 --- a/docs/specs/intent-record-v0.md +++ b/docs/specs/intent-record-v0.md @@ -79,6 +79,12 @@ drafting → sealed_local → proposed → merged | `superseded` | Replaced by a newer intent. The newer intent's `supersedes` field references this one. | | `reverted` | The merged code was reverted on main. | +`drafting` remains unpublished and cannot be discovered by another clone. +Within one clone, however, `status` and `preflight` may scan linked worktrees +and surface bounded sibling-draft summaries plus partial fingerprints. This is +an advisory same-machine coordination signal, not a substitute for a shared +claim protocol or a `proposed` intent. + **Transitions are one-way.** A merged intent cannot go back to proposed. An abandoned intent is not re-opened; new work starts a new intent. diff --git a/internal/engine/preflight.go b/internal/engine/preflight.go index 51287b9e..6654ccab 100644 --- a/internal/engine/preflight.go +++ b/internal/engine/preflight.go @@ -23,6 +23,7 @@ const ( PreflightOverlapProposed = "proposed_overlap" PreflightOverlapUpstreamMerged = "upstream_merged_overlap" + PreflightOverlapSiblingDraft = "sibling_draft_overlap" // PreflightOverlapGoalText fires when the active draft's goal text // shares enough keywords with another proposed intent's title or // goal. Catches duplicate-work-in-flight before any code is written @@ -46,28 +47,30 @@ const preflightGoalOverlapMinKeywords = 2 const preflightGoalOverlapMinHitRate = 0.5 type PreflightResult struct { - Level string `json:"level"` - OKToContinue bool `json:"ok_to_continue"` - Facts PreflightFacts `json:"facts"` - AgentAuthority *AgentAuthority `json:"agent_authority,omitempty"` - Findings []PreflightFinding `json:"findings,omitempty"` - Overlaps []PreflightOverlap `json:"overlaps,omitempty"` - RecommendedNext []string `json:"recommended_next,omitempty"` + Level string `json:"level"` + OKToContinue bool `json:"ok_to_continue"` + Facts PreflightFacts `json:"facts"` + AgentAuthority *AgentAuthority `json:"agent_authority,omitempty"` + SiblingWorktreeDrafts []WorktreeDraft `json:"sibling_worktree_drafts,omitempty"` + Findings []PreflightFinding `json:"findings,omitempty"` + Overlaps []PreflightOverlap `json:"overlaps,omitempty"` + RecommendedNext []string `json:"recommended_next,omitempty"` } type PreflightFacts struct { - Branch string `json:"branch,omitempty"` - ActiveIntentID string `json:"active_intent_id,omitempty"` - ActiveBase string `json:"active_base,omitempty"` - LocalHead string `json:"local_head,omitempty"` - MainHead string `json:"main_head,omitempty"` - SyncStale bool `json:"sync_stale,omitempty"` - WorktreeStatus string `json:"worktree_status,omitempty"` - DirtyFiles []string `json:"dirty_files,omitempty"` - UntrackedFiles []string `json:"untracked_files,omitempty"` - CurrentFiles []string `json:"current_files,omitempty"` - CommitDiffFiles []string `json:"commit_diff_files,omitempty"` - ProposedCount int `json:"proposed_count,omitempty"` + Branch string `json:"branch,omitempty"` + ActiveIntentID string `json:"active_intent_id,omitempty"` + ActiveBase string `json:"active_base,omitempty"` + LocalHead string `json:"local_head,omitempty"` + MainHead string `json:"main_head,omitempty"` + SyncStale bool `json:"sync_stale,omitempty"` + WorktreeStatus string `json:"worktree_status,omitempty"` + DirtyFiles []string `json:"dirty_files,omitempty"` + UntrackedFiles []string `json:"untracked_files,omitempty"` + CurrentFiles []string `json:"current_files,omitempty"` + CommitDiffFiles []string `json:"commit_diff_files,omitempty"` + ProposedCount int `json:"proposed_count,omitempty"` + SiblingDraftCount int `json:"sibling_draft_count,omitempty"` NotesRewriteDrift bool `json:"notes_rewrite_drift,omitempty"` UnreachableMainlineNotes int `json:"unreachable_mainline_notes,omitempty"` @@ -98,8 +101,11 @@ type PreflightOverlap struct { // Only filled for goal_text_overlap and proposed_overlap kinds — // upstream_merged_overlap intents are already on main and the // committer is shown in git log. - AuthorName string `json:"author_name,omitempty"` - AuthorID string `json:"author_id,omitempty"` + AuthorName string `json:"author_name,omitempty"` + AuthorID string `json:"author_id,omitempty"` + GitBranch string `json:"git_branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + GoalMatch bool `json:"goal_match,omitempty"` } type preflightInput struct { @@ -191,6 +197,8 @@ func buildPreflightResult(in preflightInput) *PreflightResult { res.Facts.MainHead = in.status.MainHead res.Facts.SyncStale = in.status.SyncStale res.Facts.ProposedCount = in.status.ProposedCount + res.Facts.SiblingDraftCount = len(in.status.SiblingWorktreeDrafts) + res.SiblingWorktreeDrafts = compactPreflightSiblingDrafts(in.status.SiblingWorktreeDrafts) if in.status.NotesHealth != nil && in.status.NotesHealth.LikelyHistoryRewrite { res.Facts.NotesRewriteDrift = true res.Facts.UnreachableMainlineNotes = in.status.NotesHealth.UnreachableMainlineNotes @@ -246,6 +254,11 @@ func buildPreflightResult(in preflightInput) *PreflightResult { } currentFiles := compactSortedStrings(in.currentFiles) + for _, draft := range res.SiblingWorktreeDrafts { + if overlap := preflightSiblingDraftOverlap(in.status, currentFiles, draft); overlap != nil { + res.Overlaps = append(res.Overlaps, *overlap) + } + } if len(currentFiles) > 0 { for _, iv := range in.proposed { if iv.Status != domain.StatusProposed || iv.Fingerprint == nil { @@ -348,6 +361,63 @@ func preflightOverlapFromIntent(kind, level string, iv domain.IntentView, curren return o } +func compactPreflightSiblingDrafts(in []WorktreeDraft) []WorktreeDraft { + if len(in) == 0 { + return nil + } + out := append([]WorktreeDraft(nil), in...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].LastModifiedAt != out[j].LastModifiedAt { + return out[i].LastModifiedAt > out[j].LastModifiedAt + } + if out[i].WorktreePath != out[j].WorktreePath { + return out[i].WorktreePath < out[j].WorktreePath + } + return out[i].IntentID < out[j].IntentID + }) + if len(out) > preflightOverlapLimit { + out = out[:preflightOverlapLimit] + } + return out +} + +func preflightSiblingDraftOverlap(status *StatusResult, currentFiles []string, draft WorktreeDraft) *PreflightOverlap { + if status != nil && status.ActiveIntent != nil && status.ActiveIntent.IntentID == draft.IntentID { + return nil + } + var siblingFiles []string + if draft.PartialFingerprint != nil { + siblingFiles = draft.PartialFingerprint.FilesTouched + } + matchedFiles := matchedOverlapFiles(currentFiles, siblingFiles) + goalMatch := status != nil && status.ActiveIntent != nil && preflightGoalsEqual(status.ActiveIntent.Goal, draft.Goal) + if len(matchedFiles) == 0 && !goalMatch { + return nil + } + score := len(matchedFiles) + if goalMatch { + score++ + } + return &PreflightOverlap{ + Kind: PreflightOverlapSiblingDraft, + Level: PreflightLevelWarn, + IntentID: draft.IntentID, + Title: draft.Goal, + Status: draft.Status, + MatchedFiles: matchedFiles, + Score: score, + GitBranch: draft.GitBranch, + WorktreePath: draft.WorktreePath, + GoalMatch: goalMatch, + } +} + +func preflightGoalsEqual(a, b string) bool { + a = strings.Join(strings.Fields(a), " ") + b = strings.Join(strings.Fields(b), " ") + return a != "" && b != "" && strings.EqualFold(a, b) +} + func compactPreflightOverlaps(in []PreflightOverlap) []PreflightOverlap { if len(in) == 0 { return nil @@ -436,6 +506,9 @@ func preflightRecommendations(res *PreflightResult) []string { } for _, o := range res.Overlaps { add("mainline show " + o.IntentID + " --json") + if o.Kind == PreflightOverlapSiblingDraft { + add("inspect the sibling worktree before editing overlapping scope") + } } if len(res.Overlaps) > 0 { add("if overlap is real, run mainline check or ask for human judgment before continuing") diff --git a/internal/engine/preflight_test.go b/internal/engine/preflight_test.go index 47ca710b..e90a2df9 100644 --- a/internal/engine/preflight_test.go +++ b/internal/engine/preflight_test.go @@ -1,12 +1,142 @@ package engine import ( + "path/filepath" "testing" "github.com/mainline-org/mainline/internal/core" "github.com/mainline-org/mainline/internal/domain" ) +func TestPreflightSurfacesSiblingWorktreeDraftBeforeSeal(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("agent"); err != nil { + t.Fatalf("init: %v", err) + } + markSyncedToHead(t, svc) + + linked := filepath.Join(t.TempDir(), "linked-preflight-draft") + gitCmd(t, dir, "worktree", "add", "-b", "feature/preflight-sibling", linked) + linkedSvc := NewServiceFromRoot(linked) + start, err := linkedSvc.Start("sibling draft remains local", "") + if err != nil { + t.Fatalf("start sibling: %v", err) + } + + res, err := svc.Preflight() + if err != nil { + t.Fatalf("preflight: %v", err) + } + if res.Facts.SiblingDraftCount != 1 || len(res.SiblingWorktreeDrafts) != 1 { + t.Fatalf("expected one sibling draft in preflight, facts=%+v drafts=%+v", res.Facts, res.SiblingWorktreeDrafts) + } + draft := res.SiblingWorktreeDrafts[0] + if draft.IntentID != start.IntentID || draft.GitBranch != "feature/preflight-sibling" { + t.Fatalf("unexpected sibling draft: %+v", draft) + } + if draft.PartialFingerprint == nil { + t.Fatalf("preflight sibling draft should include a partial fingerprint: %+v", draft) + } + if !res.OKToContinue { + t.Fatalf("unmatched sibling draft should remain advisory: %+v", res) + } +} + +func TestPreflightWarnsOnSiblingDraftFileOverlap(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("agent"); err != nil { + t.Fatalf("init: %v", err) + } + markSyncedToHead(t, svc) + gitCmd(t, dir, "checkout", "-b", "feature/current") + if _, err := svc.Start("current draft", ""); err != nil { + t.Fatalf("start current: %v", err) + } + writeFile(t, dir, "shared.go", "package shared\n") + + linked := filepath.Join(t.TempDir(), "linked-preflight-overlap") + gitCmd(t, dir, "worktree", "add", "-b", "feature/sibling-overlap", linked, "main") + linkedSvc := NewServiceFromRoot(linked) + start, err := linkedSvc.Start("sibling draft", "") + if err != nil { + t.Fatalf("start sibling: %v", err) + } + if err := linkedSvc.Store.AppendTurn(&domain.Turn{ + IntentID: start.IntentID, + Index: 0, + CreatedAt: "2026-07-11T00:00:00Z", + Description: "edit shared file", + FilesChanged: []domain.FileChange{{Path: "shared.go", Status: "modified"}}, + }); err != nil { + t.Fatalf("append sibling turn: %v", err) + } + + res, err := svc.Preflight() + if err != nil { + t.Fatalf("preflight: %v", err) + } + var overlap *PreflightOverlap + for i := range res.Overlaps { + if res.Overlaps[i].Kind == PreflightOverlapSiblingDraft && res.Overlaps[i].IntentID == start.IntentID { + overlap = &res.Overlaps[i] + break + } + } + if overlap == nil { + t.Fatalf("expected sibling draft overlap, got %+v", res.Overlaps) + } + if overlap.Level != PreflightLevelWarn || len(overlap.MatchedFiles) != 1 || overlap.MatchedFiles[0] != "shared.go" { + t.Fatalf("unexpected sibling overlap: %+v", overlap) + } + if overlap.GitBranch != "feature/sibling-overlap" || overlap.WorktreePath == "" { + t.Fatalf("sibling location should be actionable: %+v", overlap) + } + if !res.OKToContinue { + t.Fatalf("partial sibling evidence should warn, not block: %+v", res) + } +} + +func TestPreflightWarnsOnExactSiblingDraftGoal(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("agent"); err != nil { + t.Fatalf("init: %v", err) + } + markSyncedToHead(t, svc) + gitCmd(t, dir, "checkout", "-b", "feature/current-goal") + goal := "让预检看见同机草稿" + if _, err := svc.Start(goal, ""); err != nil { + t.Fatalf("start current: %v", err) + } + + linked := filepath.Join(t.TempDir(), "linked-preflight-goal") + gitCmd(t, dir, "worktree", "add", "-b", "feature/sibling-goal", linked, "main") + linkedSvc := NewServiceFromRoot(linked) + start, err := linkedSvc.Start(" 让预检看见同机草稿 ", "") + if err != nil { + t.Fatalf("start sibling: %v", err) + } + + res, err := svc.Preflight() + if err != nil { + t.Fatalf("preflight: %v", err) + } + for _, overlap := range res.Overlaps { + if overlap.Kind == PreflightOverlapSiblingDraft && overlap.IntentID == start.IntentID { + if !overlap.GoalMatch || overlap.Level != PreflightLevelWarn { + t.Fatalf("unexpected exact-goal overlap: %+v", overlap) + } + return + } + } + t.Fatalf("expected exact-goal sibling overlap, got %+v", res.Overlaps) +} + func TestPreflightCleanRepoNoActiveIntentIsOK(t *testing.T) { dir, cleanup := testRepo(t) defer cleanup() diff --git a/internal/engine/worktree_drafts.go b/internal/engine/worktree_drafts.go index 32166f28..98319597 100644 --- a/internal/engine/worktree_drafts.go +++ b/internal/engine/worktree_drafts.go @@ -11,15 +11,16 @@ import ( // WorktreeDraft is a local draft discovered in a sibling git worktree. // It is local diagnostic state, not shared/proposed Mainline state. type WorktreeDraft struct { - IntentID string `json:"intent_id"` - Goal string `json:"goal,omitempty"` - Status string `json:"status,omitempty"` - GitBranch string `json:"git_branch,omitempty"` - Thread string `json:"thread,omitempty"` - WorktreePath string `json:"worktree_path"` - DraftPath string `json:"draft_path"` - TurnCount int `json:"turn_count,omitempty"` - LastModifiedAt string `json:"last_modified_at,omitempty"` + IntentID string `json:"intent_id"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + GitBranch string `json:"git_branch,omitempty"` + Thread string `json:"thread,omitempty"` + WorktreePath string `json:"worktree_path"` + DraftPath string `json:"draft_path"` + TurnCount int `json:"turn_count,omitempty"` + LastModifiedAt string `json:"last_modified_at,omitempty"` + PartialFingerprint *domain.PartialFingerprint `json:"partial_fingerprint,omitempty"` } // SiblingDraftsForCLI exposes local draft visibility for status/hub callers. @@ -80,20 +81,23 @@ func (s *Service) collectSiblingDrafts(intentID string, skipIDs map[string]bool) continue } turns, _ := st.ReadTurns(id) + draftWithTurns := *d + draftWithTurns.Turns = turns updated := d.LastModifiedAt if updated == "" { updated = d.CreatedAt } out = append(out, WorktreeDraft{ - IntentID: d.IntentID, - Goal: d.Goal, - Status: string(d.Status), - GitBranch: d.GitBranch, - Thread: d.Thread, - WorktreePath: wtPath, - DraftPath: filepath.Join(wtPath, ".ml-cache", "drafts", d.IntentID+".json"), - TurnCount: len(turns), - LastModifiedAt: updated, + IntentID: d.IntentID, + Goal: d.Goal, + Status: string(d.Status), + GitBranch: d.GitBranch, + Thread: d.Thread, + WorktreePath: wtPath, + DraftPath: filepath.Join(wtPath, ".ml-cache", "drafts", d.IntentID+".json"), + TurnCount: len(turns), + LastModifiedAt: updated, + PartialFingerprint: PartialFingerprintFromDraft(&draftWithTurns), }) } }