Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/specs/intent-record-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
115 changes: 94 additions & 21 deletions internal/engine/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
130 changes: 130 additions & 0 deletions internal/engine/preflight_test.go
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
40 changes: 22 additions & 18 deletions internal/engine/worktree_drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
})
}
}
Expand Down
Loading