diff --git a/cmd/debug_fix.go b/cmd/debug_fix.go new file mode 100644 index 0000000..931d0ae --- /dev/null +++ b/cmd/debug_fix.go @@ -0,0 +1,2 @@ +//go:build ignore +package main diff --git a/cmd/fix_responses.go b/cmd/fix_responses.go new file mode 100644 index 0000000..d8696dd --- /dev/null +++ b/cmd/fix_responses.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/pcr-developers/cli/internal/projects" + "github.com/pcr-developers/cli/internal/sources/claudecode" + "github.com/pcr-developers/cli/internal/store" +) + +var fixResponsesCmd = &cobra.Command{ + Use: "fix-responses", + Short: "Backfill response_text for drafts captured without Claude's response", + RunE: func(cmd *cobra.Command, args []string) error { + repush, _ := cmd.Flags().GetBool("repush") + + home, _ := os.UserHomeDir() + claudeProjectsDir := filepath.Join(home, ".claude", "projects") + + // Walk all Claude project directories, match each JSONL to a registered project + // (same ancestor-matching logic the watcher uses), and backfill response_text. + // Group prompts by session_id for the fuzzy updater. + type sessionEntry struct { + prompts map[string]string // promptText → responseText + } + sessions := map[string]*sessionEntry{} + + projDirs, err := os.ReadDir(claudeProjectsDir) + if err != nil { + return fmt.Errorf("cannot read %s: %w", claudeProjectsDir, err) + } + + for _, projDir := range projDirs { + if !projDir.IsDir() { + continue + } + slug := projDir.Name() + project := projects.GetProjectForClaudeSlug(slug) + if project == nil { + continue + } + + sessionDir := filepath.Join(claudeProjectsDir, slug) + entries, err := os.ReadDir(sessionDir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + filePath := filepath.Join(sessionDir, entry.Name()) + content, err := os.ReadFile(filePath) + if err != nil { + continue + } + + session := claudecode.ParseClaudeCodeSession(string(content), project.Name, filePath) + for _, p := range session.Prompts { + if p.ResponseText == "" { + continue + } + e, ok := sessions[p.SessionID] + if !ok { + e = &sessionEntry{prompts: map[string]string{}} + sessions[p.SessionID] = e + } + e.prompts[p.PromptText] = p.ResponseText + } + } + } + + updated := 0 + for sessionID, e := range sessions { + n, err := store.UpdateDraftResponseFuzzy(sessionID, e.prompts) + if err != nil { + fmt.Fprintf(os.Stderr, "error updating session %s: %v\n", sessionID, err) + } + updated += n + } + + fmt.Printf("Updated response text for %d drafts.\n", updated) + + if repush { + pushed, err := store.ListPushedCommits() + if err != nil { + return fmt.Errorf("could not list pushed bundles: %w", err) + } + if len(pushed) == 0 { + fmt.Println("No pushed bundles to reset.") + return nil + } + for _, c := range pushed { + if err := store.UnmarkPushed(c.ID); err != nil { + fmt.Fprintf(os.Stderr, "error resetting bundle %q: %v\n", c.Message, err) + } else { + fmt.Printf("Reset bundle %q — run `pcr push` to re-push with updated responses.\n", c.Message) + } + } + } else { + fmt.Println("Run with --repush to reset pushed bundles so `pcr push` re-sends them.") + } + + return nil + }, +} + +func init() { + fixResponsesCmd.Flags().Bool("repush", false, "Reset pushed bundles so `pcr push` re-sends them with updated response text") + rootCmd.AddCommand(fixResponsesCmd) +} diff --git a/internal/sources/claudecode/parser.go b/internal/sources/claudecode/parser.go index dcd2131..cdcbbc3 100644 --- a/internal/sources/claudecode/parser.go +++ b/internal/sources/claudecode/parser.go @@ -193,7 +193,11 @@ func ParseClaudeCodeSession(fileContent, projectName, filePath string) ParsedSes } continue } - break + // Skip auxiliary message types (attachment, file-history-snapshot, etc.) + // that don't represent conversation boundaries. + if next.Type == "system" { + break + } } fileContext := map[string]any{} diff --git a/internal/sources/claudecode/watcher.go b/internal/sources/claudecode/watcher.go index 5315c16..b61d0ab 100644 --- a/internal/sources/claudecode/watcher.go +++ b/internal/sources/claudecode/watcher.go @@ -162,10 +162,12 @@ func (w *Watcher) processFile(filePath string, forceFullScan bool) { for _, p := range session.Prompts { hash := supabase.PromptContentHash(p.SessionID, p.PromptText, "") if w.dedup.IsDuplicate(p.SessionID, hash) { + _ = store.UpdateDraftResponse(p.SessionID, p.PromptText, p.ResponseText) continue } if store.IsDraftSaved(p.SessionID, p.PromptText) { w.dedup.Mark(p.SessionID, hash) + _ = store.UpdateDraftResponse(p.SessionID, p.PromptText, p.ResponseText) continue } w.dedup.Mark(p.SessionID, hash) diff --git a/internal/store/commits.go b/internal/store/commits.go index a022db0..4c6bd2d 100644 --- a/internal/store/commits.go +++ b/internal/store/commits.go @@ -308,6 +308,27 @@ func GetCommitBySha(headSha string) (*PromptCommit, error) { return &commits[0], nil } +// UnmarkPushed resets a pushed commit back to unpushed so it can be re-pushed. +func UnmarkPushed(commitID string) error { + db := Open() + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec("UPDATE prompt_commits SET pushed_at = NULL, remote_id = NULL WHERE id = ?", commitID); err != nil { + return err + } + if _, err := tx.Exec(` + UPDATE drafts SET status = 'committed' + WHERE id IN (SELECT draft_id FROM prompt_commit_items WHERE prompt_commit_id = ?) + AND status = 'pushed' + `, commitID); err != nil { + return err + } + return tx.Commit() +} + // RelinkCommit updates a commit's HEAD SHA (for git amend). func RelinkCommit(commitID, newHeadSha string) error { db := Open() diff --git a/internal/store/drafts.go b/internal/store/drafts.go index fadad56..d9acf40 100644 --- a/internal/store/drafts.go +++ b/internal/store/drafts.go @@ -111,6 +111,77 @@ func IsDraftSaved(sessionID, promptText string) bool { return exists == 1 } +// UpdateDraftResponse fills in response_text for an existing draft that has none. +// Uses exact content hash match. +func UpdateDraftResponse(sessionID, promptText, responseText string) error { + if responseText == "" { + return nil + } + db := Open() + hash := supabase.PromptContentHash(sessionID, promptText, "") + _, err := db.Exec( + "UPDATE drafts SET response_text = ? WHERE content_hash = ? AND (response_text IS NULL OR response_text = '')", + responseText, hash, + ) + return err +} + +// UpdateDraftResponseFuzzy fills in response_text by matching session_id + prompt prefix +// in Go (not SQL). Handles cases where the prompt was captured from a partially-written +// JSONL line, meaning the stored prompt_text is a prefix of the full parsed prompt. +// Returns the number of rows updated. +func UpdateDraftResponseFuzzy(sessionID string, prompts map[string]string) (int, error) { + if len(prompts) == 0 { + return 0, nil + } + db := Open() + + // Fetch all drafts for the session with missing response_text + rows, err := db.Query( + "SELECT id, prompt_text FROM drafts WHERE session_id = ? AND (response_text IS NULL OR response_text = '')", + sessionID, + ) + if err != nil { + return 0, err + } + type row struct{ id, text string } + var drafts []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.text); err != nil { + rows.Close() + return 0, err + } + drafts = append(drafts, r) + } + rows.Close() + + // For each draft, find the JSONL prompt that starts with (or equals) the stored text. + updated := 0 + for _, d := range drafts { + for promptText, responseText := range prompts { + if responseText == "" { + continue + } + // Match: JSONL prompt starts with stored text, or exact match. + if strings.HasPrefix(promptText, d.text) || promptText == d.text { + res, err := db.Exec( + "UPDATE drafts SET response_text = ? WHERE id = ? AND (response_text IS NULL OR response_text = '')", + responseText, d.id, + ) + if err != nil { + return updated, err + } + if n, _ := res.RowsAffected(); n > 0 { + updated++ + } + break + } + } + } + return updated, nil +} + // GetDraftsByStatus returns drafts filtered by status and optionally by project. // projectIDs and projectNames are OR-combined; pass nil slices to return all. func GetDraftsByStatus(status DraftStatus, projectIDs, projectNames []string) ([]DraftRecord, error) {