diff --git a/cmd/guild/main.go b/cmd/guild/main.go index c78a6b7..5d5169e 100644 --- a/cmd/guild/main.go +++ b/cmd/guild/main.go @@ -1,7 +1,7 @@ // Package main is the entry point for the guild binary. // // guild bundles the lore CLI, quest CLI, and MCP stdio server in one -// static binary. See https://github.com/mathomhaus/guild for docs. +// single binary. See https://github.com/mathomhaus/guild for docs. package main import ( diff --git a/internal/cli/hints.go b/internal/cli/hints.go index 5ee1493..6fdfb97 100644 --- a/internal/cli/hints.go +++ b/internal/cli/hints.go @@ -19,8 +19,9 @@ import ( // lets operators inspect the rule table, read per-rule stats, and // manually enable/disable/prune rules. var hintsCmd = &cobra.Command{ - Use: "hints", - Short: "hint engine inspection + administration", + Use: "hints", + Short: "hint engine inspection + administration", + GroupID: "inspection", Long: `Inspect and administer the SQL-backed hint engine (QUEST-58). The engine fires advisory lines on top of tool responses. The launch-9 diff --git a/internal/cli/init_cmd.go b/internal/cli/init_cmd.go index 6d8f4e4..3673be5 100644 --- a/internal/cli/init_cmd.go +++ b/internal/cli/init_cmd.go @@ -10,8 +10,9 @@ import ( ) var initCmd = &cobra.Command{ - Use: "init", - Short: "scaffold AGENTS.md and register this repo with guild", + Use: "init", + Short: "scaffold AGENTS.md and register this repo with guild", + GroupID: "core", Long: `guild init — per-project scaffold Run inside a git repository. Detects the project name, shows a plan diff --git a/internal/cli/lore_read.go b/internal/cli/lore_read.go index f0a9fe1..85ed4dc 100644 --- a/internal/cli/lore_read.go +++ b/internal/cli/lore_read.go @@ -39,7 +39,7 @@ func openLoreDB(ctx context.Context) (*sql.DB, error) { if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("cli: ensure ~/.guild: %w", err) } db, err := storage.Open(ctx, path) @@ -479,7 +479,8 @@ func bindLoreRegistryVerb[I, O any](parent *cobra.Command, spec *command.Command // tolerates a nil Embed pointer per ADR-003 nil-safety. func buildCLILoreDeps() command.Deps { d := command.Deps{ - OpenDB: openLoreDB, + OpenDB: openLoreDB, + OpenQuestDB: openQuestDB, ResolveProj: func(ctx context.Context, argProject string) (string, error) { db, err := openLoreDB(ctx) if err != nil { diff --git a/internal/cli/quest.go b/internal/cli/quest.go index 1c18c2f..a22b5ec 100644 --- a/internal/cli/quest.go +++ b/internal/cli/quest.go @@ -52,7 +52,7 @@ func openQuestDB(ctx context.Context) (*sql.DB, error) { } if path != ":memory:" && !strings.HasPrefix(path, ":memory:") { if dir := filepath.Dir(path); dir != "." && dir != "/" { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("create %s: %w", dir, err) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index c840b62..beb731f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,14 +30,14 @@ func SetVersion(version, commit, date string) { var rootCmd = &cobra.Command{ Use: "guild", Short: "persistent cognition for AI agents — task + knowledge lifecycle", - Long: `guild bundles three modes in one static binary: + Long: `guild bundles two domains, two surfaces, and one store in a single binary: guild lore knowledge lifecycle (inscribe, appraise, study, ...) guild quest task lifecycle (post, accept, clear, ...) - guild mcp serve MCP stdio server for AI agents + guild mcp serve MCP stdio surface for AI agents -The lore, quest, and MCP surfaces share one SQLite-backed store under -~/.guild/. See https://github.com/mathomhaus/guild for docs. +The CLI and MCP surfaces share lore and quest data in one SQLite-backed store +under ~/.guild/. See https://github.com/mathomhaus/guild for docs. Next step — if you haven't yet: @@ -54,32 +54,36 @@ Environment variables: } var versionCmd = &cobra.Command{ - Use: "version", - Short: "print guild version information", + Use: "version", + Short: "print guild version information", + GroupID: "inspection", Run: func(_ *cobra.Command, _ []string) { fmt.Printf("guild version=%s commit=%s date=%s\n", buildVersion, buildCommit, buildDate) }, } var loreCmd = &cobra.Command{ - Use: "lore", - Short: "knowledge lifecycle (read/write/decay/supersede)", + Use: "lore", + Short: "knowledge lifecycle (inscribe/appraise/study/reforge)", + GroupID: "core", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } var questCmd = &cobra.Command{ - Use: "quest", - Short: "task lifecycle (post/accept/journal/clear/coordinate)", + Use: "quest", + Short: "task lifecycle (post/accept/journal/clear/coordinate)", + GroupID: "core", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } var mcpCmd = &cobra.Command{ - Use: "mcp", - Short: "MCP server subcommands", + Use: "mcp", + Short: "MCP server subcommands", + GroupID: "core", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, @@ -121,6 +125,11 @@ func SetUpgradeNudge(fn func() string) { } func init() { + rootCmd.AddGroup( + &cobra.Group{ID: "core", Title: "Core"}, + &cobra.Group{ID: "inspection", Title: "Inspection"}, + ) + // PersistentPreRun fires before every subcommand. We use it to emit // an upgrade-available nudge when a newer guild release exists and // stderr is a TTY. The isatty check happens here (not in SetUpgradeNudge) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index cb7812b..6bafacb 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -24,6 +24,26 @@ func TestRootHelp(t *testing.T) { t.Errorf("root --help output missing sub-command %q\n%s", want, out) } } + for _, want := range []string{ + "Core", + "Inspection", + "knowledge lifecycle (inscribe/appraise/study/reforge)", + "dashboard: last briefing, oath, top bounty, and parallelism", + } { + if !strings.Contains(out, want) { + t.Errorf("root --help output missing grouped help text %q\n%s", want, out) + } + } + for _, unwanted := range []string{ + "three modes", + "static binary", + "read/write/decay/supersede", + "alias of quest bounties", + } { + if strings.Contains(out, unwanted) { + t.Errorf("root --help output still contains stale phrasing %q\n%s", unwanted, out) + } + } // QUEST-10: the root help should nudge the user toward the // natural next step after installing guild. Check for two // action-phrases that should appear in the Long description. diff --git a/internal/cli/status_cmd.go b/internal/cli/status_cmd.go index 7731a07..8f36511 100644 --- a/internal/cli/status_cmd.go +++ b/internal/cli/status_cmd.go @@ -9,8 +9,9 @@ import ( ) var statusCmd = &cobra.Command{ - Use: "status", - Short: "dashboard: last briefing, oath, top bounty, parallelism (alias of quest bounties)", + Use: "status", + Short: "dashboard: last briefing, oath, top bounty, and parallelism", + GroupID: "core", Long: `Mid-session reorientation — shows the same snapshot the session-start briefing prints, on demand. diff --git a/internal/cli/telemetry_cmd.go b/internal/cli/telemetry_cmd.go index cb8e601..6b47e0e 100644 --- a/internal/cli/telemetry_cmd.go +++ b/internal/cli/telemetry_cmd.go @@ -19,8 +19,9 @@ import ( const optInHint = "telemetry is disabled — enable with [telemetry]\n usage_log = true in ~/.guild/config.toml to start recording resp_bytes" var telemetryCmd = &cobra.Command{ - Use: "telemetry", - Short: "telemetry analytics (usage log, token estimates)", + Use: "telemetry", + Short: "telemetry analytics (usage log, token estimates)", + GroupID: "inspection", RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, diff --git a/internal/command/cobra.go b/internal/command/cobra.go index b05fac6..c206185 100644 --- a/internal/command/cobra.go +++ b/internal/command/cobra.go @@ -45,6 +45,7 @@ func (c *Command[I, O]) BindCobra(parent *cobra.Command, d Deps) { Aliases: c.CLIAliases, Short: c.Short, Long: c.Long, + Example: c.CLIExample, Args: cobraPositionalValidator(c.Args), SilenceUsage: true, } diff --git a/internal/command/command.go b/internal/command/command.go index ba15b35..8789273 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -30,6 +30,9 @@ type Command[I, O any] struct { // Long is the extended help for cobra's Long field and the MCP // tool's Description. If empty, Short is used for both. Long string + // CLIExample is an optional Cobra-only examples block. It is kept + // out of MCP descriptions so tool schemas stay inside token budgets. + CLIExample string // MCPOnly suppresses CLI registration (e.g. guild_session_start). MCPOnly bool // CLIOnly suppresses MCP registration (e.g. guild mcp-install). @@ -116,6 +119,10 @@ type Deps struct { // (e.g. quest_post with spec=). Nil means the feature is unavailable // for that surface / test setup. OpenLoreDB func(ctx context.Context) (*sql.DB, error) + // OpenQuestDB, when non-nil, opens the quest SQLite database. Lore-side + // diagnostics use this to report quest-corpus embedding health alongside + // lore-corpus health. + OpenQuestDB func(ctx context.Context) (*sql.DB, error) // EvaluateHints, when non-nil, is called by the MCP handler wrapper // after each successful tool invocation. Returns a HintFire the // wrapper formats and prepends/appends to the tool's output body. diff --git a/internal/install/clients.go b/internal/install/clients.go index 67b8859..6e79fed 100644 --- a/internal/install/clients.go +++ b/internal/install/clients.go @@ -98,8 +98,6 @@ var Clients = []Client{ InstallArgv: func(b string) []string { return []string{"codex", "mcp", "add", "guild", "--", b, "mcp", "serve"} }, - // ListArgv left nil until `codex mcp list` output shape is verified - // against a real run; nil disables the pre-check and the install - // attempt proceeds as before. + ListArgv: func() []string { return []string{"codex", "mcp", "list"} }, }, } diff --git a/internal/install/clients_test.go b/internal/install/clients_test.go index 0521c09..68f0dd0 100644 --- a/internal/install/clients_test.go +++ b/internal/install/clients_test.go @@ -100,6 +100,9 @@ func TestClients_WellFormed(t *testing.T) { if c.CLIProbe == "" && c.ConfigProbe == "" { t.Errorf("client %q has neither CLIProbe nor ConfigProbe", c.Name) } + if c.ListArgv == nil { + t.Errorf("client %q has nil ListArgv; repeat init cannot pre-check registration", c.Name) + } } } diff --git a/internal/install/init.go b/internal/install/init.go index 88a3d73..8c5159f 100644 --- a/internal/install/init.go +++ b/internal/install/init.go @@ -87,6 +87,9 @@ type InitOptions struct { // execCmdFn is passed through to MCPInstall when invoking MCP registration. // Nil → real exec.Command. Injected in tests to capture the registration call. execCmdFn func(name string, arg ...string) *exec.Cmd + // lookPathFn is passed through to MCPInstall when checking detected client + // CLI binaries. Nil → exec.LookPath. + lookPathFn func(file string) (string, error) // executableFn is passed through to MCPInstall for binary-path resolution. // Nil → os.Executable. Injected in tests so CI runners (which have no // durable guild binary installed) can resolve to a temp-file path and @@ -297,6 +300,7 @@ func Init(ctx context.Context, repoRoot string, opts InitOptions) (*InitResult, clients: detected, executableFn: execFn, execCmdFn: opts.execCmdFn, + lookPathFn: opts.lookPathFn, } if _, err := MCPInstall(ctx, mcpOpts); err != nil { return nil, fmt.Errorf("install: mcp register: %w", err) @@ -422,7 +426,7 @@ func resolveDBPaths(opts InitOptions) (loreDB, questDB string, err error) { return "", "", fmt.Errorf("install: resolve home dir: %w", err) } guildDir := filepath.Join(home, ".guild") - if err := os.MkdirAll(guildDir, 0o755); err != nil { + if err := os.MkdirAll(guildDir, 0o700); err != nil { return "", "", fmt.Errorf("install: create ~/.guild: %w", err) } if loreDB == "" { diff --git a/internal/install/init_test.go b/internal/install/init_test.go index 06cb996..faca50e 100644 --- a/internal/install/init_test.go +++ b/internal/install/init_test.go @@ -467,6 +467,51 @@ func TestInit_MCPRegistration_YesFlagInvokesExec(t *testing.T) { } } +func TestInit_MCPRegistration_SkipsDetectedClientWhenCLIMissing(t *testing.T) { + ctx := context.Background() + dir := makeRepo(t, "missingcli") + loreDB, questDB := testDBPaths(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".missing-client.json"), []byte("{}"), 0o600); err != nil { + t.Fatalf("write config probe: %v", err) + } + + var out bytes.Buffer + var calls [][]string + client := Client{ + Name: "Missing CLI", + CLIProbe: "missing-client-cli", + ConfigProbe: "~/.missing-client.json", + InstallArgv: func(b string) []string { + return []string{"missing-client-cli", "mcp", "add", "guild", "--", b, "mcp", "serve"} + }, + } + + _, err := Init(ctx, dir, InitOptions{ + Yes: true, + Out: &out, + In: &bytes.Buffer{}, + LoreDBPath: loreDB, + QuestDBPath: questDB, + clients: []Client{client}, + execCmdFn: recordingExec(&calls), + executableFn: fakeExecutable(t), + lookPathFn: func(string) (string, error) { + return "", exec.ErrNotFound + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + if len(calls) != 0 { + t.Fatalf("registration exec calls = %v, want none", calls) + } + if !strings.Contains(out.String(), "skipping Missing CLI: missing-client-cli binary not found on PATH") { + t.Fatalf("output missing missing-CLI skip notice:\n%s", out.String()) + } +} + // Interactive path: user types "y" — init must invoke registration. func TestInit_MCPRegistration_InteractiveYes(t *testing.T) { ctx := context.Background() diff --git a/internal/install/mcp_install.go b/internal/install/mcp_install.go index edd72ea..e23bc4f 100644 --- a/internal/install/mcp_install.go +++ b/internal/install/mcp_install.go @@ -6,7 +6,7 @@ // // Default UX (no flags): // -// guild binary: /usr/local/bin/guild +// guild binary: /path/to/guild // // Detected agent clients: // ✓ Claude Code @@ -15,7 +15,7 @@ // Run the command for each agent you use: // // # Claude Code -// claude mcp add guild --scope user -- /usr/local/bin/guild mcp serve +// claude mcp add guild --scope user -- /path/to/guild mcp serve // // With --run: shells out to each detected client's CLI with a per-command // confirmation prompt. @@ -264,7 +264,7 @@ func MCPInstall(ctx context.Context, opts MCPInstallOptions) (*MCPInstallResult, // user knows which CLI to install (issue #48). binaryName := instr.Argv[0] if _, err := opts.lookPathFn(binaryName); err != nil { - fmt.Fprintf(opts.Out, "skipping %s: %s not on PATH\n", instr.Name, binaryName) + fmt.Fprintf(opts.Out, "skipping %s: %s binary not found on PATH\n", instr.Name, binaryName) result.SkippedMissingCLI = append(result.SkippedMissingCLI, instr.Name) continue } diff --git a/internal/install/mcp_install_test.go b/internal/install/mcp_install_test.go index 5845525..e9b8e57 100644 --- a/internal/install/mcp_install_test.go +++ b/internal/install/mcp_install_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "os/exec" + "path/filepath" "strings" "testing" ) @@ -544,7 +545,7 @@ func TestMCPInstall_Run_SkipsWhenCLIMissing(t *testing.T) { if len(result.Ran) != 0 { t.Errorf("Ran = %v, want empty", result.Ran) } - if !strings.Contains(buf.String(), "skipping Bogus: "+badBinary+" not on PATH") { + if !strings.Contains(buf.String(), "skipping Bogus: "+badBinary+" binary not found on PATH") { t.Errorf("expected missing-CLI notice; got:\n%s", buf.String()) } } @@ -669,13 +670,14 @@ func TestMCPInstall_Run_RegistersWhenAbsent(t *testing.T) { // pre-check. It accepts the common CLI output formats and rejects // incidental mentions of "guild" inside command-value strings. func TestScanForGuildEntry(t *testing.T) { + guildBin := filepath.Join(t.TempDir(), "bin", "guild") cases := []struct { name string in string want bool wantPath string }{ - {"claude human", "guild: /usr/local/bin/guild mcp serve\n", true, "/usr/local/bin/guild"}, + {"claude human", "guild: " + guildBin + " mcp serve\n", true, guildBin}, {"list marker", "- guild\n- other\n", true, ""}, {"bare token", "guild\n", true, ""}, {"mixed list", " * other\n * guild: /bin/guild\n", true, "/bin/guild"}, diff --git a/internal/lore/catalog.go b/internal/lore/catalog.go index ddfb13c..d46fdca 100644 --- a/internal/lore/catalog.go +++ b/internal/lore/catalog.go @@ -62,6 +62,9 @@ func Catalog(ctx context.Context, db *sql.DB, p *CatalogParams) (*CatalogResult, if strings.TrimSpace(p.Dir) == "" { return nil, fmt.Errorf("lore: catalog: dir required") } + if p.Kind != "" && !isValidKind(p.Kind) { + return nil, fmt.Errorf("lore: catalog: invalid kind %q (valid kinds: %s)", p.Kind, validKindList()) + } info, err := os.Stat(p.Dir) if err != nil { @@ -222,3 +225,12 @@ func inferKindFromPath(path string) Kind { } return KindResearch } + +func validKindList() string { + kinds := AllKinds() + names := make([]string, 0, len(kinds)) + for _, kind := range kinds { + names = append(names, string(kind)) + } + return strings.Join(names, ", ") +} diff --git a/internal/lore/catalog_cmd.go b/internal/lore/catalog_cmd.go index bb72f3b..bed2776 100644 --- a/internal/lore/catalog_cmd.go +++ b/internal/lore/catalog_cmd.go @@ -30,7 +30,7 @@ var CatalogCommand = &command.Command[CatalogInput, CatalogCmdOutput]{ Args: []command.ArgSpec{ {Name: "dir", Kind: command.ArgPositional, Type: command.ArgString, Required: true, Help: "directory to walk for .md files"}, {Name: "topic", Kind: command.ArgFlag, Type: command.ArgString, Help: "override per-file topic; default: file stem"}, - {Name: "kind", Kind: command.ArgFlag, Type: command.ArgString, Help: "override kind"}, + {Name: "kind", Kind: command.ArgFlag, Type: command.ArgString, Help: "override kind: idea|research|decision|observation|principle"}, {Name: "tags", Kind: command.ArgFlag, Type: command.ArgString, Help: "comma-separated tags"}, {Name: "project", Short: "p", Kind: command.ArgFlag, Type: command.ArgString, Help: "project override"}, }, diff --git a/internal/lore/catalog_test.go b/internal/lore/catalog_test.go index 02d7a89..6f38f6a 100644 --- a/internal/lore/catalog_test.go +++ b/internal/lore/catalog_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" ) @@ -146,6 +147,29 @@ func TestCatalog_KindInference(t *testing.T) { } } +func TestCatalog_RejectsInvalidKindOverride(t *testing.T) { + ctx := context.Background() + db := openTestDB(t, "catalog-invalid-kind") + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "note.md"), []byte("# Note\n\nSome content."), 0o600); err != nil { + t.Fatalf("write note.md: %v", err) + } + + _, err := Catalog(ctx, db, &CatalogParams{ + Dir: dir, + ProjectID: "catalog-invalid-kind", + Kind: Kind("not-a-real-kind"), + }) + if err == nil { + t.Fatal("Catalog with invalid kind should return error") + } + msg := err.Error() + if !strings.Contains(msg, "not-a-real-kind") || !strings.Contains(msg, "idea") || !strings.Contains(msg, "principle") { + t.Fatalf("error %q should include invalid and valid kinds", msg) + } +} + func TestCatalog_SkipsNonMD(t *testing.T) { ctx := context.Background() db := openTestDB(t, "catalog-ext") diff --git a/internal/lore/embedder_health_cmd.go b/internal/lore/embedder_health_cmd.go index f13854b..3020ca4 100644 --- a/internal/lore/embedder_health_cmd.go +++ b/internal/lore/embedder_health_cmd.go @@ -17,7 +17,8 @@ type EmbedderHealthInput struct { // EmbedderHealthCmdOutput wraps the HealthReport for the command registry. type EmbedderHealthCmdOutput struct { - Report *embed.HealthReport `json:"report"` + Report *embed.HealthReport `json:"report"` + QuestReport *embed.HealthReport `json:"quest_report,omitempty"` } // EmbedderHealthCommand is the registry spec for `guild lore health`. @@ -53,7 +54,19 @@ var EmbedderHealthCommand = &command.Command[EmbedderHealthInput, EmbedderHealth if err != nil { return EmbedderHealthCmdOutput{}, fmt.Errorf("lore: health: %w", err) } - return EmbedderHealthCmdOutput{Report: report}, nil + var questReport *embed.HealthReport + if d.OpenQuestDB != nil { + questDB, err := d.OpenQuestDB(ctx) + if err != nil { + return EmbedderHealthCmdOutput{}, fmt.Errorf("lore: health: open quest db: %w", err) + } + defer func() { _ = questDB.Close() }() + questReport, err = embed.ReadHealthReport(ctx, questDB, embed.QuestCorpus{}) + if err != nil { + return EmbedderHealthCmdOutput{}, fmt.Errorf("lore: health: quest corpus: %w", err) + } + } + return EmbedderHealthCmdOutput{Report: report, QuestReport: questReport}, nil }, CLIFormat: func(s command.CLISink, o EmbedderHealthCmdOutput) string { return formatEmbedderHealth(s, o) @@ -66,56 +79,62 @@ var EmbedderHealthCommand = &command.Command[EmbedderHealthInput, EmbedderHealth // formatEmbedderHealth renders the embedder health section. // Works for both CLI and MCP sinks (both satisfy the lineSink interface). func formatEmbedderHealth(s lineSink, o EmbedderHealthCmdOutput) string { - r := o.Report - if r == nil { + if o.Report == nil { return strings.TrimRight(s.Line("🔮", "[health]", "embedder: no data available"), "\n") } var b strings.Builder b.WriteString(s.Line("🔮", "[health]", "embedder section")) + writeHealthReport(&b, "lore corpus", o.Report) + if o.QuestReport != nil { + b.WriteString("\n") + writeHealthReport(&b, "quest corpus", o.QuestReport) + } + return strings.TrimRight(b.String(), "\n") +} +func writeHealthReport(b *strings.Builder, label string, r *embed.HealthReport) { + b.WriteString(fmt.Sprintf(" %s:\n", label)) // State line. stateStr := string(r.State) sessionLine := r.SessionLine() if sessionLine != "" { stateStr += fmt.Sprintf(": %s", sessionLine) } - b.WriteString(fmt.Sprintf(" state: %s\n", stateStr)) + b.WriteString(fmt.Sprintf(" state: %s\n", stateStr)) // Identity fields. - b.WriteString(fmt.Sprintf(" model_id: %s\n", orNA(r.ModelID))) - b.WriteString(fmt.Sprintf(" tokenizer_hash: %s\n", orNA(r.TokenizerHash))) - b.WriteString(fmt.Sprintf(" runtime_version: %s\n", orNA(r.RuntimeVersion))) - b.WriteString(fmt.Sprintf(" dim: %d\n", r.Dim)) + b.WriteString(fmt.Sprintf(" model_id: %s\n", orNA(r.ModelID))) + b.WriteString(fmt.Sprintf(" tokenizer_hash: %s\n", orNA(r.TokenizerHash))) + b.WriteString(fmt.Sprintf(" runtime_version: %s\n", orNA(r.RuntimeVersion))) + b.WriteString(fmt.Sprintf(" dim: %d\n", r.Dim)) // Coverage. - b.WriteString(fmt.Sprintf(" coverage: %d/%d (%.1f%%)\n", + b.WriteString(fmt.Sprintf(" coverage: %d/%d (%.1f%%)\n", r.CoverageNum, r.CoverageDen, r.CoveragePct)) - b.WriteString(fmt.Sprintf(" pending: %d\n", r.PendingCount)) - b.WriteString(fmt.Sprintf(" stale: %d\n", r.StaleCount)) - b.WriteString(fmt.Sprintf(" vector_epoch: %d\n", r.VectorEpoch)) + b.WriteString(fmt.Sprintf(" pending: %d\n", r.PendingCount)) + b.WriteString(fmt.Sprintf(" stale: %d\n", r.StaleCount)) + b.WriteString(fmt.Sprintf(" vector_epoch: %d\n", r.VectorEpoch)) // Error tracking. - b.WriteString(fmt.Sprintf(" embed_errors: %d (rolling)\n", r.EmbedErrorCount)) + b.WriteString(fmt.Sprintf(" embed_errors: %d (rolling)\n", r.EmbedErrorCount)) if r.LastEncodeError != "" { errLine := r.LastEncodeError if r.LastEncodeErrAt != nil { errLine += fmt.Sprintf(" (at %s)", r.LastEncodeErrAt.Format(time.RFC3339)) } - b.WriteString(fmt.Sprintf(" last_error: %s\n", errLine)) + b.WriteString(fmt.Sprintf(" last_error: %s\n", errLine)) } if r.LastEncodeOKAt != nil { - b.WriteString(fmt.Sprintf(" last_ok_at: %s\n", r.LastEncodeOKAt.Format(time.RFC3339))) + b.WriteString(fmt.Sprintf(" last_ok_at: %s\n", r.LastEncodeOKAt.Format(time.RFC3339))) } // Session-start line preview (only when non-healthy). if sessionLine != "" { - b.WriteString(fmt.Sprintf(" session_line: %s\n", sessionLine)) + b.WriteString(fmt.Sprintf(" session_line: %s\n", sessionLine)) } - - return strings.TrimRight(b.String(), "\n") } // orNA returns s if non-empty, otherwise "(n/a)". diff --git a/internal/lore/embedder_health_cmd_test.go b/internal/lore/embedder_health_cmd_test.go new file mode 100644 index 0000000..b962f35 --- /dev/null +++ b/internal/lore/embedder_health_cmd_test.go @@ -0,0 +1,37 @@ +package lore + +import ( + "strings" + "testing" + + "github.com/mathomhaus/guild/internal/command" + "github.com/mathomhaus/guild/internal/lore/embed" +) + +func TestFormatEmbedderHealthIncludesQuestCorpus(t *testing.T) { + body := formatEmbedderHealth(command.CLISink{}, EmbedderHealthCmdOutput{ + Report: &embed.HealthReport{ + State: embed.EmbedderStateEnabled, + CoverageNum: 3, + CoverageDen: 4, + CoveragePct: 75, + }, + QuestReport: &embed.HealthReport{ + State: embed.EmbedderStateEnabled, + CoverageNum: 1, + CoverageDen: 2, + CoveragePct: 50, + }, + }) + + for _, want := range []string{ + "lore corpus", + "coverage: 3/4 (75.0%)", + "quest corpus", + "coverage: 1/2 (50.0%)", + } { + if !strings.Contains(body, want) { + t.Fatalf("health output missing %q:\n%s", want, body) + } + } +} diff --git a/internal/lore/inscribe_cmd.go b/internal/lore/inscribe_cmd.go index c6970a8..bcc806c 100644 --- a/internal/lore/inscribe_cmd.go +++ b/internal/lore/inscribe_cmd.go @@ -33,7 +33,8 @@ var InscribeCommand = &command.Command[InscribeInput, InscribeCmdOutput]{ CLIPath: []string{"lore", "inscribe"}, CLIAliases: []string{"add"}, Short: "inscribe a new knowledge entry into the lore", - Long: "Store knowledge that transcends the current task — patterns, decisions, research that outlive the quest. Call lore_appraise first; pass informs=[IDs] for entries that informed this one to create provenance edges at write-time. Cross-project dedup and principle-hygiene warnings are built in.", + Long: "Store durable knowledge. Call lore_appraise first; use informs=[IDs] for provenance.", + CLIExample: `guild lore inscribe "Use WAL for concurrent writes" -k decision -t sqlite -s "Use WAL for concurrent writes."`, Args: []command.ArgSpec{ {Name: "title", Kind: command.ArgPositional, Type: command.ArgString, Required: true, Variadic: true, Help: "short distinctive title"}, {Name: "kind", Short: "k", Kind: command.ArgFlag, Type: command.ArgString, Required: true, Help: "entry kind (required): idea|research|decision|observation|principle"}, diff --git a/internal/lore/specs_test.go b/internal/lore/specs_test.go index a18ba30..ca6cdce 100644 --- a/internal/lore/specs_test.go +++ b/internal/lore/specs_test.go @@ -1,11 +1,14 @@ package lore_test import ( + "encoding/json" "reflect" + "strings" "testing" "github.com/mathomhaus/guild/internal/command" "github.com/mathomhaus/guild/internal/lore" + "github.com/spf13/cobra" ) // TestAllCommandSpecs_ArgFieldKindAlignment is the lore-side sibling of @@ -42,3 +45,56 @@ func TestAllCommandSpecs_ArgFieldKindAlignment(t *testing.T) { }) } } + +func TestInscribeCommand_ExposesStrictProjectOnCLIAndMCP(t *testing.T) { + parent := &cobra.Command{Use: "lore"} + lore.InscribeCommand.BindCobra(parent, command.Deps{}) + sub := findSubcommand(parent, "inscribe") + if sub == nil { + t.Fatal("inscribe subcommand not registered") + } + if sub.Flags().Lookup("strict-project") == nil { + t.Fatal("inscribe command missing --strict-project flag") + } + + tool := lore.InscribeCommand.BuildMCPForTest(command.Deps{}) + buf, _ := json.Marshal(tool.InputSchema) + schema := string(buf) + if !strings.Contains(schema, `"strict_project"`) { + t.Fatalf("lore_inscribe schema missing strict_project:\n%s", schema) + } +} + +func TestInscribeCommand_CLIHelpLeadsWithExampleAndShortFlags(t *testing.T) { + parent := &cobra.Command{Use: "lore"} + lore.InscribeCommand.BindCobra(parent, command.Deps{}) + sub := findSubcommand(parent, "inscribe") + if sub == nil { + t.Fatal("inscribe subcommand not registered") + } + if !strings.HasPrefix(sub.Example, "guild lore inscribe") { + t.Fatalf("inscribe help should expose a working example, got:\n%s", sub.Example) + } + for flag, shorthand := range map[string]string{ + "kind": "k", + "topic": "t", + "summary": "s", + } { + f := sub.Flags().Lookup(flag) + if f == nil { + t.Fatalf("missing --%s flag", flag) + } + if f.Shorthand != shorthand { + t.Fatalf("--%s shorthand = %q, want %q", flag, f.Shorthand, shorthand) + } + } +} + +func findSubcommand(parent *cobra.Command, name string) *cobra.Command { + for _, cmd := range parent.Commands() { + if cmd.Name() == name { + return cmd + } + } + return nil +} diff --git a/internal/lore/tx.go b/internal/lore/tx.go index d6bddd8..5c420d3 100644 --- a/internal/lore/tx.go +++ b/internal/lore/tx.go @@ -4,8 +4,9 @@ import ( "context" "database/sql" "fmt" - "strings" "time" + + "github.com/mathomhaus/guild/internal/storage" ) // beginImmediate is the lore-package-local helper matching the one in @@ -38,7 +39,7 @@ func beginImmediate(ctx context.Context, db *sql.DB, opName string) (*sql.Conn, if beginErr == nil { break } - if !isBusyErr(beginErr.Error()) { + if !storage.IsBusyErr(beginErr) { _ = conn.Close() return nil, nil, fmt.Errorf("%s: begin immediate: %w", opName, beginErr) } @@ -66,12 +67,3 @@ func beginImmediate(ctx context.Context, db *sql.DB, opName string) (*sql.Conn, } return conn, rollback, nil } - -// isBusyErr reports whether err looks like a SQLITE_BUSY from the -// modernc driver. Matches on the substring rather than a typed error -// because modernc returns a plain error whose string contains -// "database is locked (5) (SQLITE_BUSY)". -func isBusyErr(msg string) bool { - return strings.Contains(msg, "SQLITE_BUSY") || - strings.Contains(msg, "database is locked") -} diff --git a/internal/mcp/db.go b/internal/mcp/db.go index f4170f5..47c4481 100644 --- a/internal/mcp/db.go +++ b/internal/mcp/db.go @@ -62,7 +62,7 @@ func openQuestDB(ctx context.Context) (*sql.DB, error) { func openDB(ctx context.Context, path, description string) (*sql.DB, error) { if path != ":memory:" && !strings.HasPrefix(path, ":memory:") { if dir := filepath.Dir(path); dir != "." && dir != "/" { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("mcp: ensure %s: %w", dir, err) } } diff --git a/internal/mcp/register.go b/internal/mcp/register.go index a83f94c..a93bd1b 100644 --- a/internal/mcp/register.go +++ b/internal/mcp/register.go @@ -93,6 +93,7 @@ func buildMCPCommandDeps() command.Deps { func buildMCPLoreDeps() command.Deps { d := command.Deps{ OpenDB: openLoreDB, + OpenQuestDB: openQuestDB, ResolveProj: resolveProjectAutoBootstrap, Now: time.Now, RecordTelemetry: recordMCPTelemetry, diff --git a/internal/quest/accept.go b/internal/quest/accept.go index a6acaa3..bef2a1a 100644 --- a/internal/quest/accept.go +++ b/internal/quest/accept.go @@ -8,6 +8,8 @@ import ( "log/slog" "strings" "time" + + "github.com/mathomhaus/guild/internal/storage" ) // trailWriterFunc is the signature for post-claim observability writes. @@ -152,8 +154,7 @@ func Accept(ctx context.Context, db *sql.DB, projectID, taskID, owner string) (* // // For non-BUSY errors we return the raw error wrapped. func toAlreadyClaimedOrErr(ctx context.Context, db *sql.DB, projectID, taskID string, err error) error { - msg := err.Error() - if !isBusyErr(msg) { + if !storage.IsBusyErr(err) { return fmt.Errorf("quest: accept: update: %w", err) } var curStatus, curOwner sql.NullString @@ -169,15 +170,6 @@ func toAlreadyClaimedOrErr(ctx context.Context, db *sql.DB, projectID, taskID st } } -// isBusyErr reports whether err looks like a SQLITE_BUSY from the -// modernc driver. We match on the substring rather than unwrapping a -// typed error because the driver returns a plain error whose string -// contains "database is locked (5) (SQLITE_BUSY)". -func isBusyErr(msg string) bool { - return strings.Contains(msg, "SQLITE_BUSY") || - strings.Contains(msg, "database is locked") -} - // writeAcceptTrail writes the `claimed` event and the auto-checkpoint // note into a small follow-up transaction. Non-critical for atomicity. // Retries on SQLITE_BUSY because these writes are still subject to @@ -188,7 +180,7 @@ func writeAcceptTrail(ctx context.Context, db *sql.DB, projectID, taskID, owner, tx, err := db.BeginTx(ctx, nil) if err != nil { lastErr = err - if !isBusyErr(err.Error()) { + if !storage.IsBusyErr(err) { return fmt.Errorf("quest: accept: begin trail tx: %w", err) } continue @@ -196,7 +188,7 @@ func writeAcceptTrail(ctx context.Context, db *sql.DB, projectID, taskID, owner, if err := emitEvent(ctx, tx, projectID, taskID, EventClaimed, owner, "", createdAt); err != nil { _ = tx.Rollback() lastErr = err - if !isBusyErr(err.Error()) { + if !storage.IsBusyErr(err) { return err } continue @@ -209,14 +201,14 @@ func writeAcceptTrail(ctx context.Context, db *sql.DB, projectID, taskID, owner, ); err != nil { _ = tx.Rollback() lastErr = err - if !isBusyErr(err.Error()) { + if !storage.IsBusyErr(err) { return fmt.Errorf("quest: accept: write checkpoint: %w", err) } continue } if err := tx.Commit(); err != nil { lastErr = err - if !isBusyErr(err.Error()) { + if !storage.IsBusyErr(err) { return fmt.Errorf("quest: accept: commit trail: %w", err) } continue diff --git a/internal/quest/accept_cmd.go b/internal/quest/accept_cmd.go index 40d63e6..f29f4eb 100644 --- a/internal/quest/accept_cmd.go +++ b/internal/quest/accept_cmd.go @@ -140,6 +140,10 @@ func formatAccepted(s lineListSink, o AcceptOutput) string { } func formatAcceptError(s lineListSink, err error) (string, bool) { + if strings.Contains(err.Error(), "no active project set") { + msg := "quest_accept: no active project set; call guild_session_start before accepting a quest" + return strings.TrimRight(s.Line("❌", "[err]", msg), "\n"), true + } var claimed *AlreadyClaimedError if errors.As(err, &claimed) { msg := fmt.Sprintf("already accepted: %s is held by %s (status=%s)", diff --git a/internal/quest/accept_cmd_test.go b/internal/quest/accept_cmd_test.go index 5df2d8d..3f7200f 100644 --- a/internal/quest/accept_cmd_test.go +++ b/internal/quest/accept_cmd_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "reflect" "strings" "testing" @@ -138,6 +139,22 @@ func TestAcceptCommand_SpecAlignsWithInput(t *testing.T) { } } +func TestAcceptCommand_NoActiveProjectGuidesSessionStart(t *testing.T) { + msg, ok := quest.AcceptCommand.MCPErrorFormat( + command.MCPSink{}, + errors.New("no active project set"), + ) + if !ok { + t.Fatal("MCPErrorFormat did not handle no-active-project error") + } + if !strings.Contains(msg, "guild_session_start") { + t.Fatalf("message should guide caller to guild_session_start; got %q", msg) + } + if !strings.Contains(msg, "quest_accept") { + t.Fatalf("message should name quest_accept; got %q", msg) + } +} + // fakeDeps produces a Deps bundle with no-op DB and pass-through // project resolver — sufficient for surface-shape tests that never // actually execute the handler. diff --git a/internal/quest/active.go b/internal/quest/active.go index 63814e6..909bd27 100644 --- a/internal/quest/active.go +++ b/internal/quest/active.go @@ -6,20 +6,38 @@ import ( "fmt" ) -// Active returns all quests currently in status='in_progress' across -// every project registered in the DB. The result is sorted by -// project_id ascending, then claimed_at ascending (oldest first within -// each project). +// Active returns all quests currently in status='in_progress' across every +// project registered in the DB. func Active(ctx context.Context, db *sql.DB) ([]*Quest, error) { + return active(ctx, db, "") +} + +// ActiveForProject returns all quests currently in status='in_progress' for one +// project only. +func ActiveForProject(ctx context.Context, db *sql.DB, projectID string) ([]*Quest, error) { + if projectID == "" { + return nil, fmt.Errorf("quest: active: empty project_id") + } + return active(ctx, db, projectID) +} + +// active is sorted by project_id ascending, then claimed_at ascending (oldest +// first within each project). Passing projectID filters to that project. +func active(ctx context.Context, db *sql.DB, projectID string) ([]*Quest, error) { if db == nil { return nil, fmt.Errorf("quest: active: nil db") } - rows, err := db.QueryContext(ctx, - `SELECT project_id, task_id FROM task_status - WHERE status = 'in_progress' - ORDER BY project_id ASC, claimed_at ASC`, - ) + query := `SELECT project_id, task_id FROM task_status + WHERE status = 'in_progress'` + var args []any + if projectID != "" { + query += ` AND project_id = ?` + args = append(args, projectID) + } + query += ` ORDER BY project_id ASC, claimed_at ASC` + + rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("quest: active: query: %w", err) } diff --git a/internal/quest/active_cmd.go b/internal/quest/active_cmd.go index b6d6d34..c157afa 100644 --- a/internal/quest/active_cmd.go +++ b/internal/quest/active_cmd.go @@ -28,10 +28,8 @@ var ActiveCommand = &command.Command[ActiveInput, ActiveOutput]{ {Name: "project", Short: "p", Kind: command.ArgFlag, Type: command.ArgString, Help: "project override"}, }, Handler: func(ctx context.Context, d command.Deps, in ActiveInput) (ActiveOutput, error) { - // Active() doesn't need a resolved project, but the MCP path - // requires an active session — call ResolveProj for the side - // effect of validating bootstrap, discard the result. - if _, err := d.ResolveProj(ctx, in.Project); err != nil { + pid, err := d.ResolveProj(ctx, in.Project) + if err != nil { return ActiveOutput{}, err } db, err := d.OpenDB(ctx) @@ -39,7 +37,12 @@ var ActiveCommand = &command.Command[ActiveInput, ActiveOutput]{ return ActiveOutput{}, err } defer func() { _ = db.Close() }() - qs, err := Active(ctx, db) + var qs []*Quest + if strings.TrimSpace(in.Project) != "" { + qs, err = ActiveForProject(ctx, db, pid) + } else { + qs, err = Active(ctx, db) + } if err != nil { return ActiveOutput{}, err } diff --git a/internal/quest/active_test.go b/internal/quest/active_test.go index 639b0ca..7c8858d 100644 --- a/internal/quest/active_test.go +++ b/internal/quest/active_test.go @@ -2,7 +2,10 @@ package quest import ( "context" + "database/sql" "testing" + + "github.com/mathomhaus/guild/internal/command" ) func TestActive_None(t *testing.T) { @@ -56,6 +59,83 @@ func TestActive_CrossProject(t *testing.T) { } } +func TestActiveForProject_FiltersToProject(t *testing.T) { + db, pid1 := newTestDB(t) + ctx := context.Background() + + pid2 := "testproj2" + if _, err := db.ExecContext(ctx, + `INSERT INTO projects (id, path, tasks_file) VALUES (?, ?, ?)`, + pid2, t.TempDir(), "TASKS.md", + ); err != nil { + t.Fatalf("register project2: %v", err) + } + + q1 := mustPost(t, db, pid1, PostParams{Subject: "proj1 task"}) + q2 := mustPost(t, db, pid2, PostParams{Subject: "proj2 task"}) + if _, err := Accept(ctx, db, pid1, q1.ID, "agent-a"); err != nil { + t.Fatalf("accept1: %v", err) + } + if _, err := Accept(ctx, db, pid2, q2.ID, "agent-b"); err != nil { + t.Fatalf("accept2: %v", err) + } + + qs, err := ActiveForProject(ctx, db, pid2) + if err != nil { + t.Fatalf("ActiveForProject: %v", err) + } + if len(qs) != 1 { + t.Fatalf("want 1 active in %s, got %d", pid2, len(qs)) + } + if qs[0].ID != q2.ID || qs[0].Subject != "proj2 task" { + t.Fatalf("got %#v, want project2 quest %s", qs[0], q2.ID) + } +} + +func TestActiveCommand_ProjectFlagFiltersToProject(t *testing.T) { + db, pid1 := newTestDB(t) + ctx := context.Background() + + pid2 := "testproj2" + if _, err := db.ExecContext(ctx, + `INSERT INTO projects (id, path, tasks_file) VALUES (?, ?, ?)`, + pid2, t.TempDir(), "TASKS.md", + ); err != nil { + t.Fatalf("register project2: %v", err) + } + + q1 := mustPost(t, db, pid1, PostParams{Subject: "proj1 task"}) + q2 := mustPost(t, db, pid2, PostParams{Subject: "proj2 task"}) + if _, err := Accept(ctx, db, pid1, q1.ID, "agent-a"); err != nil { + t.Fatalf("accept1: %v", err) + } + if _, err := Accept(ctx, db, pid2, q2.ID, "agent-b"); err != nil { + t.Fatalf("accept2: %v", err) + } + + out, err := ActiveCommand.Handler(ctx, fakeQuestDeps(db, pid2), ActiveInput{Project: pid2}) + if err != nil { + t.Fatalf("ActiveCommand: %v", err) + } + if len(out.Quests) != 1 { + t.Fatalf("want 1 active in %s, got %d", pid2, len(out.Quests)) + } + if out.Quests[0].ID != q2.ID { + t.Fatalf("got %s, want %s", out.Quests[0].ID, q2.ID) + } +} + +func fakeQuestDeps(db *sql.DB, projectID string) command.Deps { + return command.Deps{ + OpenDB: func(context.Context) (*sql.DB, error) { + return db, nil + }, + ResolveProj: func(_ context.Context, _ string) (string, error) { + return projectID, nil + }, + } +} + func TestActive_ExcludesNonInProgress(t *testing.T) { db, pid := newTestDB(t) ctx := context.Background() diff --git a/internal/quest/post.go b/internal/quest/post.go index f5b21ba..0357ac9 100644 --- a/internal/quest/post.go +++ b/internal/quest/post.go @@ -101,15 +101,15 @@ func Post(ctx context.Context, db *sql.DB, projectID string, params PostParams) // Combined [spec] for scalars. Subject is always present (validated // above); the rest are conditional. - scalarParts := []string{fmt.Sprintf("subject: %s", params.Subject)} + scalarParts := []string{fmt.Sprintf("subject: %s", encodeSpecValue(params.Subject))} if p := strings.TrimSpace(string(params.Priority)); p != "" { scalarParts = append(scalarParts, "priority: "+p) } if e := strings.TrimSpace(params.Epic); e != "" { - scalarParts = append(scalarParts, "epic: "+e) + scalarParts = append(scalarParts, "epic: "+encodeSpecValue(e)) } if e := strings.TrimSpace(params.Effort); e != "" { - scalarParts = append(scalarParts, "effort: "+e) + scalarParts = append(scalarParts, "effort: "+encodeSpecValue(e)) } if err := insertSpecNote(ctx, conn, projectID, newID, agent, now, NotePrefixSpec+strings.Join(scalarParts, "; ")); err != nil { @@ -136,7 +136,7 @@ func Post(ctx context.Context, db *sql.DB, projectID string, params PostParams) continue } if err := insertSpecNote(ctx, conn, projectID, newID, agent, now, - NotePrefixSpec+"acceptance: "+crit); err != nil { + NotePrefixSpec+"acceptance: "+encodeSpecValue(crit)); err != nil { return nil, err } } diff --git a/internal/quest/post_test.go b/internal/quest/post_test.go index 0c903a6..487b9a7 100644 --- a/internal/quest/post_test.go +++ b/internal/quest/post_test.go @@ -3,6 +3,8 @@ package quest import ( "context" "errors" + "fmt" + "sync" "testing" ) @@ -22,6 +24,52 @@ func TestPost_MonotonicID(t *testing.T) { } } +func TestPost_ConcurrentWritersAllocateUniqueIDs(t *testing.T) { + db, pid := newTestDB(t) + ctx := context.Background() + const writers = 32 + + var wg sync.WaitGroup + errs := make(chan error, writers) + ids := make(chan string, writers) + for i := 0; i < writers; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + q, err := Post(ctx, db, pid, PostParams{Subject: fmt.Sprintf("concurrent-%02d", i)}) + if err != nil { + errs <- err + return + } + ids <- q.ID + }() + } + wg.Wait() + close(errs) + close(ids) + + for err := range errs { + t.Errorf("Post returned error under contention: %v", err) + } + seen := map[string]bool{} + for id := range ids { + if seen[id] { + t.Fatalf("duplicate id allocated: %s", id) + } + seen[id] = true + } + if len(seen) != writers { + t.Fatalf("allocated %d IDs, want %d", len(seen), writers) + } + for i := 1; i <= writers; i++ { + id := fmt.Sprintf("QUEST-%d", i) + if !seen[id] { + t.Fatalf("missing allocated id %s; got %v", id, seen) + } + } +} + func TestPost_RoundTrip(t *testing.T) { db, pid := newTestDB(t) q := mustPost(t, db, pid, PostParams{ @@ -122,6 +170,56 @@ func TestPost_AcceptancePreservesCommas(t *testing.T) { } } +func TestPost_SpecValuesPreserveSemicolons(t *testing.T) { + db, pid := newTestDB(t) + q := mustPost(t, db, pid, PostParams{ + Subject: "split; but keep me together", + Epic: "campaign; phase one", + Effort: "review; verify", + Acceptance: []string{ + "first; second; third", + }, + }) + + got := mustLoad(t, db, pid, q.ID) + if got.Subject != "split; but keep me together" { + t.Errorf("subject = %q", got.Subject) + } + if got.Epic != "campaign; phase one" { + t.Errorf("epic = %q", got.Epic) + } + if got.Effort != "review; verify" { + t.Errorf("effort = %q", got.Effort) + } + if len(got.Acceptance) != 1 || got.Acceptance[0] != "first; second; third" { + t.Errorf("acceptance = %v", got.Acceptance) + } +} + +func TestPost_SemicolonFieldsRoundTrip(t *testing.T) { + db, pid := newTestDB(t) + q := mustPost(t, db, pid, PostParams{ + Subject: "subject before; subject after", + Epic: "epic before; epic after", + Effort: "medium; weird but preserved", + Acceptance: []string{"criterion before; criterion after"}, + }) + + got := mustLoad(t, db, pid, q.ID) + if got.Subject != "subject before; subject after" { + t.Errorf("subject = %q", got.Subject) + } + if got.Epic != "epic before; epic after" { + t.Errorf("epic = %q", got.Epic) + } + if got.Effort != "medium; weird but preserved" { + t.Errorf("effort = %q", got.Effort) + } + if len(got.Acceptance) != 1 || got.Acceptance[0] != "criterion before; criterion after" { + t.Errorf("acceptance = %v", got.Acceptance) + } +} + func TestLoad_NotFound(t *testing.T) { db, pid := newTestDB(t) _, err := Load(context.Background(), db, pid, "QUEST-404") diff --git a/internal/quest/pulse.go b/internal/quest/pulse.go index f4ab80f..5b89457 100644 --- a/internal/quest/pulse.go +++ b/internal/quest/pulse.go @@ -298,7 +298,7 @@ func Pulse(ctx context.Context, db *sql.DB, projectID string, window time.Durati payload = note[len(NotePrefixSpec):] } // Payload format: "files: a.go, b.go" - for _, part := range strings.Split(payload, "; ") { + for _, part := range splitSpecParts(payload) { k, v, ok := splitKV(part) if !ok || k != "files" { continue diff --git a/internal/quest/scroll.go b/internal/quest/scroll.go index 0e62d63..81e91ba 100644 --- a/internal/quest/scroll.go +++ b/internal/quest/scroll.go @@ -3,6 +3,7 @@ package quest import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -23,12 +24,23 @@ type EventEntry struct { CreatedAt time.Time } +// DependencyState is the status snapshot for one dependency named by a +// quest's depends_on list. +type DependencyState struct { + ID string `json:"id"` + Status Status `json:"status,omitempty"` + Subject string `json:"subject,omitempty"` + Done bool `json:"done"` + Missing bool `json:"missing,omitempty"` +} + // ScrollResult is the full history view of a quest: resolved spec, // current status, all notes, and all events in chronological order. type ScrollResult struct { - Quest *Quest - Notes []NoteEntry - Events []EventEntry + Quest *Quest + Dependencies []DependencyState + Notes []NoteEntry + Events []EventEntry } // Scroll returns the full history of questID: current status, all notes, @@ -62,13 +74,47 @@ func Scroll(ctx context.Context, db *sql.DB, projectID, questID string) (*Scroll return nil, err } + deps, err := loadDependencyStates(ctx, db, projectID, q.DependsOn) + if err != nil { + return nil, err + } + return &ScrollResult{ - Quest: q, - Notes: notes, - Events: events, + Quest: q, + Dependencies: deps, + Notes: notes, + Events: events, }, nil } +func loadDependencyStates(ctx context.Context, db *sql.DB, projectID string, depIDs []string) ([]DependencyState, error) { + if len(depIDs) == 0 { + return nil, nil + } + out := make([]DependencyState, 0, len(depIDs)) + for _, depID := range depIDs { + depID = strings.ToUpper(strings.TrimSpace(depID)) + if depID == "" { + continue + } + q, err := Load(ctx, db, projectID, depID) + if errors.Is(err, ErrNotFound) { + out = append(out, DependencyState{ID: depID, Missing: true}) + continue + } + if err != nil { + return nil, fmt.Errorf("quest: scroll: load dependency %s: %w", depID, err) + } + out = append(out, DependencyState{ + ID: q.ID, + Status: q.Status, + Subject: q.Subject, + Done: q.Status == StatusDone, + }) + } + return out, nil +} + // loadNotes returns all task_notes rows for questID, oldest first. func loadNotes(ctx context.Context, db *sql.DB, projectID, questID string) ([]NoteEntry, error) { rows, err := db.QueryContext(ctx, diff --git a/internal/quest/scroll_cmd.go b/internal/quest/scroll_cmd.go index 38616d3..420a188 100644 --- a/internal/quest/scroll_cmd.go +++ b/internal/quest/scroll_cmd.go @@ -93,6 +93,13 @@ func formatScrollCLI(s command.CLISink, o ScrollOutput) string { q.Owner, q.ClaimedAt.UTC().Format("2006-01-02T15:04"))) } b.WriteString("\n") + if len(r.Dependencies) > 0 { + b.WriteString(s.Section("🔎", "[deps]", "dependency state")) + for _, dep := range r.Dependencies { + b.WriteString(s.Row("%s %s", dependencyStateMarker(dep), dependencyStateLine(dep))) + } + b.WriteString("\n") + } if len(r.Notes) > 0 { b.WriteString(s.Section("📝", "[notes]", "notes")) for _, n := range r.Notes { @@ -127,6 +134,12 @@ func formatScrollMCP(s command.MCPSink, o ScrollOutput) string { if q.Owner != "" { b.WriteString(s.Indented("owner", q.Owner)) } + if len(r.Dependencies) > 0 { + b.WriteString(" dependencies:\n") + for _, dep := range r.Dependencies { + fmt.Fprintf(&b, " - %s %s\n", dependencyStateMarker(dep), dependencyStateLine(dep)) + } + } if len(q.Files) > 0 { b.WriteString(s.Indented("files", strings.Join(q.Files, ", "))) } @@ -144,6 +157,24 @@ func formatScrollMCP(s command.MCPSink, o ScrollOutput) string { return strings.TrimRight(b.String(), "\n") } +func dependencyStateMarker(dep DependencyState) string { + if dep.Done { + return "✓" + } + return "×" +} + +func dependencyStateLine(dep DependencyState) string { + if dep.Missing { + return fmt.Sprintf("%s [missing]", dep.ID) + } + subject := strings.TrimSpace(dep.Subject) + if subject == "" { + return fmt.Sprintf("%s [%s]", dep.ID, dep.Status) + } + return fmt.Sprintf("%s [%s] %s", dep.ID, dep.Status, subject) +} + func scrollStatusIcon(status Status) string { switch status { case StatusInProgress: diff --git a/internal/quest/scroll_test.go b/internal/quest/scroll_test.go index 1978553..581971d 100644 --- a/internal/quest/scroll_test.go +++ b/internal/quest/scroll_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/mathomhaus/guild/internal/command" ) func TestScroll_FullHistory(t *testing.T) { @@ -93,6 +95,75 @@ func TestScroll_NotFound(t *testing.T) { } } +func TestScroll_DependencyStateExplainsBlockedQuest(t *testing.T) { + db, pid := newTestDB(t) + ctx := context.Background() + + done := mustPost(t, db, pid, PostParams{Subject: "finished setup"}) + open := mustPost(t, db, pid, PostParams{Subject: "remaining API work"}) + if _, err := Fulfill(ctx, db, pid, done.ID, "done"); err != nil { + t.Fatalf("Fulfill done dep: %v", err) + } + blocked := mustPost(t, db, pid, PostParams{ + Subject: "blocked integration", + DependsOn: []string{done.ID, open.ID, "QUEST-404"}, + }) + if blocked.Status != StatusBlocked { + t.Fatalf("blocked status = %s, want blocked", blocked.Status) + } + + res, err := Scroll(ctx, db, pid, blocked.ID) + if err != nil { + t.Fatalf("Scroll: %v", err) + } + if len(res.Dependencies) != 3 { + t.Fatalf("dependencies = %v, want 3", res.Dependencies) + } + checks := []struct { + index int + id string + status Status + done bool + missing bool + subject string + }{ + {0, done.ID, StatusDone, true, false, "finished setup"}, + {1, open.ID, StatusNext, false, false, "remaining API work"}, + {2, "QUEST-404", "", false, true, ""}, + } + for _, chk := range checks { + got := res.Dependencies[chk.index] + if got.ID != chk.id || got.Status != chk.status || got.Done != chk.done || + got.Missing != chk.missing || got.Subject != chk.subject { + t.Fatalf("dependency[%d] = %+v, want id=%s status=%s done=%v missing=%v subject=%q", + chk.index, got, chk.id, chk.status, chk.done, chk.missing, chk.subject) + } + } +} + +func TestFormatScrollIncludesDependencyState(t *testing.T) { + out := formatScrollMCP(command.MCPSink{}, ScrollOutput{Result: &ScrollResult{ + Quest: &Quest{ + ID: "QUEST-3", + Subject: "blocked integration", + Status: StatusBlocked, + Priority: "P1", + DependsOn: []string{"QUEST-1", "QUEST-2"}, + }, + Dependencies: []DependencyState{ + {ID: "QUEST-1", Status: StatusDone, Done: true, Subject: "finished setup"}, + {ID: "QUEST-2", Status: StatusNext, Subject: "remaining API work"}, + }, + }}) + if !strings.Contains(out, "dependencies:") { + t.Fatalf("MCP scroll missing dependency section:\n%s", out) + } + if !strings.Contains(out, "QUEST-1 [done] finished setup") || + !strings.Contains(out, "QUEST-2 [next] remaining API work") { + t.Fatalf("MCP scroll missing dependency detail:\n%s", out) + } +} + func TestScroll_OrderChronological(t *testing.T) { db, pid := newTestDB(t) ctx := context.Background() @@ -129,3 +200,36 @@ func TestScroll_OrderChronological(t *testing.T) { t.Errorf("notes not in chronological order: %v", found) } } + +func TestScroll_OrderChronologicalUsesIDTieBreaker(t *testing.T) { + db, pid := newTestDB(t) + ctx := context.Background() + + q := mustPost(t, db, pid, PostParams{Subject: "same timestamp ordering"}) + stamp := "2026-05-11T00:00:00Z" + for _, note := range []string{"same-time-1", "same-time-2", "same-time-3"} { + if _, err := db.ExecContext(ctx, + `INSERT INTO task_notes (project_id, task_id, agent_id, note, created_at) + VALUES (?, ?, ?, ?, ?)`, + pid, q.ID, "agent", note, stamp, + ); err != nil { + t.Fatalf("insert note %q: %v", note, err) + } + } + + res, err := Scroll(ctx, db, pid, q.ID) + if err != nil { + t.Fatalf("Scroll: %v", err) + } + + var found []string + for _, n := range res.Notes { + if strings.HasPrefix(n.Note, "same-time-") { + found = append(found, n.Note) + } + } + want := []string{"same-time-1", "same-time-2", "same-time-3"} + if strings.Join(found, ",") != strings.Join(want, ",") { + t.Fatalf("same-timestamp notes = %v, want %v", found, want) + } +} diff --git a/internal/quest/spec.go b/internal/quest/spec.go index 4fd9c00..ef85c16 100644 --- a/internal/quest/spec.go +++ b/internal/quest/spec.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "net/url" "strings" "time" ) @@ -93,7 +94,7 @@ func applyNote(q *Quest, note string) { // replace=true means list fields reset BEFORE append. Scalar fields are // last-value-wins regardless of replace mode. func applyPayload(q *Quest, payload string, replace bool) { - for _, part := range strings.Split(payload, "; ") { + for _, part := range splitSpecParts(payload) { k, v, ok := splitKV(part) if !ok { continue @@ -104,7 +105,7 @@ func applyPayload(q *Quest, payload string, replace bool) { // Each acceptance note carries exactly one criterion — // don't comma-split the value. Preserves boundaries. if v != "" { - items = []string{v} + items = []string{decodeSpecValue(v)} } } else { items = splitCommaList(v) @@ -116,6 +117,33 @@ func applyPayload(q *Quest, payload string, replace bool) { } } +func splitSpecParts(payload string) []string { + var parts []string + start := 0 + for i := 0; i < len(payload)-1; i++ { + if payload[i] != ';' || payload[i+1] != ' ' { + continue + } + if !startsSpecField(payload[i+2:]) { + continue + } + parts = append(parts, payload[start:i]) + start = i + 2 + i++ + } + parts = append(parts, payload[start:]) + return parts +} + +func startsSpecField(s string) bool { + for _, key := range []string{"subject", "priority", "epic", "effort", "files", "acceptance", "depends_on", "blocks"} { + if strings.HasPrefix(s, key+":") { + return true + } + } + return false +} + // splitKV splits "key: value" into (key, value, ok). Returns ok=false // on blank keys or missing colons. func splitKV(part string) (key, value string, ok bool) { @@ -153,13 +181,14 @@ func splitCommaList(s string) []string { for _, p := range parts { p = strings.TrimSpace(p) if p != "" { - out = append(out, p) + out = append(out, decodeSpecValue(p)) } } return out } func assignScalarField(q *Quest, k, v string) { + v = decodeSpecValue(v) switch k { case "subject": q.Subject = v @@ -172,6 +201,23 @@ func assignScalarField(q *Quest, k, v string) { } } +const specEncodedPrefix = "url:" + +func encodeSpecValue(v string) string { + return specEncodedPrefix + url.QueryEscape(v) +} + +func decodeSpecValue(v string) string { + if !strings.HasPrefix(v, specEncodedPrefix) { + return v + } + decoded, err := url.QueryUnescape(strings.TrimPrefix(v, specEncodedPrefix)) + if err != nil { + return v + } + return decoded +} + func assignListField(q *Quest, k string, items []string, replace bool) { switch k { case "files": diff --git a/internal/quest/tx.go b/internal/quest/tx.go index 07848e5..0727052 100644 --- a/internal/quest/tx.go +++ b/internal/quest/tx.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "time" + + "github.com/mathomhaus/guild/internal/storage" ) // beginImmediate acquires a dedicated *sql.Conn from db, then issues @@ -43,7 +45,7 @@ func beginImmediate(ctx context.Context, db *sql.DB, opName string) (*sql.Conn, if beginErr == nil { break } - if !isBusyErr(beginErr.Error()) { + if !storage.IsBusyErr(beginErr) { _ = conn.Close() return nil, nil, fmt.Errorf("quest: %s: begin immediate: %w", opName, beginErr) } diff --git a/internal/quest/update.go b/internal/quest/update.go index e325ad1..1d2b6ba 100644 --- a/internal/quest/update.go +++ b/internal/quest/update.go @@ -103,16 +103,16 @@ func Update(ctx context.Context, db *sql.DB, projectID, taskID string, params Up // one [spec] note so read-time replay applies them atomically. var appendParts []string if v := strings.TrimSpace(params.Subject); v != "" { - appendParts = append(appendParts, "subject: "+v) + appendParts = append(appendParts, "subject: "+encodeSpecValue(v)) } if v := strings.TrimSpace(string(params.Priority)); v != "" { appendParts = append(appendParts, "priority: "+v) } if v := strings.TrimSpace(params.Epic); v != "" { - appendParts = append(appendParts, "epic: "+v) + appendParts = append(appendParts, "epic: "+encodeSpecValue(v)) } if v := strings.TrimSpace(params.Effort); v != "" { - appendParts = append(appendParts, "effort: "+v) + appendParts = append(appendParts, "effort: "+encodeSpecValue(v)) } var newDepIDs []string @@ -201,12 +201,12 @@ func Update(ctx context.Context, db *sql.DB, projectID, taskID string, params Up } else { // First criterion [spec-replace] resets; rest [spec] append. if err := insertSpecNote(ctx, conn, projectID, taskID, agent, now, - NotePrefixSpecReplace+"acceptance: "+crits[0]); err != nil { + NotePrefixSpecReplace+"acceptance: "+encodeSpecValue(crits[0])); err != nil { return nil, err } for _, c := range crits[1:] { if err := insertSpecNote(ctx, conn, projectID, taskID, agent, now, - NotePrefixSpec+"acceptance: "+c); err != nil { + NotePrefixSpec+"acceptance: "+encodeSpecValue(c)); err != nil { return nil, err } } @@ -219,7 +219,7 @@ func Update(ctx context.Context, db *sql.DB, projectID, taskID string, params Up continue } if err := insertSpecNote(ctx, conn, projectID, taskID, agent, now, - NotePrefixSpec+"acceptance: "+c); err != nil { + NotePrefixSpec+"acceptance: "+encodeSpecValue(c)); err != nil { return nil, err } } diff --git a/internal/quest/update_cmd.go b/internal/quest/update_cmd.go index b4c9e43..0961e78 100644 --- a/internal/quest/update_cmd.go +++ b/internal/quest/update_cmd.go @@ -47,6 +47,10 @@ var UpdateCommand = &command.Command[UpdateInput, UpdateOutput]{ CLIPath: []string{"quest", "update"}, Short: "modify a quest's spec after post", Long: "Modify a quest's spec after post. Append lists via --files/-a/--depends-on/--blocks; replace via --replace-*; clear via --clear-*.", + CLIExample: strings.TrimSpace(` +guild quest update QUEST-7 --clear-depends-on +guild quest update QUEST-7 --replace-depends-on "" +`), Args: []command.ArgSpec{ {Name: "quest_id", Kind: command.ArgPositional, Type: command.ArgString, Required: true, Help: "QUEST-NNN"}, {Name: "subject", Kind: command.ArgFlag, Type: command.ArgString, Help: "new subject"}, diff --git a/internal/quest/update_test.go b/internal/quest/update_test.go index 653d028..1e8f765 100644 --- a/internal/quest/update_test.go +++ b/internal/quest/update_test.go @@ -101,6 +101,36 @@ func TestUpdate_ListAppend_Acceptance_PreservesCommas(t *testing.T) { if got.Acceptance[0] != "foo, bar, baz" { t.Errorf("acc[0] = %q", got.Acceptance[0]) } + if got.Acceptance[1] != "one; two" { + t.Errorf("acc[1] = %q", got.Acceptance[1]) + } +} + +func TestUpdate_SpecValuesPreserveSemicolons(t *testing.T) { + db, pid := newTestDB(t) + q := mustPost(t, db, pid, PostParams{Subject: "original"}) + if _, err := Update(context.Background(), db, pid, q.ID, UpdateParams{ + Subject: "updated; subject", + Epic: "updated; epic", + Effort: "updated; effort", + Acceptance: []string{"updated; acceptance"}, + }); err != nil { + t.Fatalf("Update: %v", err) + } + + got := mustLoad(t, db, pid, q.ID) + if got.Subject != "updated; subject" { + t.Errorf("subject = %q", got.Subject) + } + if got.Epic != "updated; epic" { + t.Errorf("epic = %q", got.Epic) + } + if got.Effort != "updated; effort" { + t.Errorf("effort = %q", got.Effort) + } + if len(got.Acceptance) != 1 || got.Acceptance[0] != "updated; acceptance" { + t.Errorf("acceptance = %v", got.Acceptance) + } } func TestUpdate_ConflictingAppendAndReplace(t *testing.T) { @@ -263,6 +293,33 @@ func TestUpdate_AutoUnblock_ReplaceDependsOnPath(t *testing.T) { } } +func TestUpdate_ReplaceDependsOnEmptyValueClearsDeps(t *testing.T) { + db, pid := newTestDB(t) + ctx := context.Background() + a := mustPost(t, db, pid, PostParams{Subject: "A"}) + b := mustPost(t, db, pid, PostParams{ + Subject: "B", + DependsOn: []string{a.ID}, + }) + if b.Status != StatusBlocked { + t.Fatalf("B status = %s, want blocked before replace", b.Status) + } + + if _, err := Update(ctx, db, pid, b.ID, UpdateParams{ + ReplaceDependsOn: []string{""}, + }); err != nil { + t.Fatalf("Update replace empty dep: %v", err) + } + + got := mustLoad(t, db, pid, b.ID) + if got.Status != StatusNext { + t.Errorf("B status = %s, want next after empty replace clears deps", got.Status) + } + if len(got.DependsOn) != 0 { + t.Errorf("B deps = %v, want empty after empty replace", got.DependsOn) + } +} + func TestUpdate_ClearAcceptance(t *testing.T) { db, pid := newTestDB(t) q := mustPost(t, db, pid, PostParams{ diff --git a/internal/storage/db.go b/internal/storage/db.go index a5d0253..5029350 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net/url" + "os" "strings" // modernc.org/sqlite is a pure-Go SQLite driver (no CGO). Registering @@ -72,10 +73,27 @@ func Open(ctx context.Context, path string) (*sql.DB, error) { _ = db.Close() return nil, fmt.Errorf("storage: ping %s: %w", path, err) } + if err := tightenSQLiteFileModes(path); err != nil { + _ = db.Close() + return nil, fmt.Errorf("storage: secure %s: %w", path, err) + } return db, nil } +func tightenSQLiteFileModes(path string) error { + if path == ":memory:" || strings.HasPrefix(path, ":memory:") { + return nil + } + for _, suffix := range []string{"", "-wal", "-shm", "-journal"} { + p := path + suffix + if err := os.Chmod(p, 0o600); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + // buildDSN turns a filesystem path into a modernc.org/sqlite DSN with the // four required pragmas encoded as _pragma query parameters. It is // exported-through-test (via dsnForTest) so db_test.go can assert the diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 8585af1..d062e73 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "os" "path/filepath" "strings" "sync" @@ -11,6 +12,11 @@ import ( "time" ) +type codedSQLiteErr int + +func (e codedSQLiteErr) Error() string { return "sqlite coded error" } +func (e codedSQLiteErr) Code() int { return int(e) } + // openTempDB is a test helper that opens a fresh sqlite DB under t.TempDir // and runs the canonical 001_init migration. Callers get a ready-to-use // *sql.DB that auto-closes at test end. @@ -50,6 +56,58 @@ func TestOpen_ReturnsUsableHandle(t *testing.T) { } } +func TestOpen_TightensSQLiteFileModes(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "private.db") + + db, err := Open(ctx, path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("%s mode = %o, want 600", path, got) + } + + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + if err := os.WriteFile(path+suffix, []byte("x"), 0o644); err != nil { + t.Fatalf("write sidecar %s: %v", suffix, err) + } + } + if err := tightenSQLiteFileModes(path); err != nil { + t.Fatalf("tighten sidecars: %v", err) + } + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + info, err := os.Stat(path + suffix) + if err != nil { + t.Fatalf("stat %s: %v", path+suffix, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("%s mode = %o, want 600", path+suffix, got) + } + } +} + +func TestIsBusyErr_UsesSQLiteCodes(t *testing.T) { + if !IsBusyErr(codedSQLiteErr(sqliteBusyCode)) { + t.Fatal("busy code was not recognized") + } + if !IsBusyErr(fmt.Errorf("wrapped: %w", codedSQLiteErr(0x100|sqliteLockedCode))) { + t.Fatal("extended locked code was not recognized") + } + if IsBusyErr(fmt.Errorf("SQLITE_BUSY string only")) { + t.Fatal("string-only error should not be treated as busy") + } + if IsBusyErr(codedSQLiteErr(1)) { + t.Fatal("non-busy sqlite code should not be treated as busy") + } +} + // TestBuildDSN_IncludesAllFourPragmas asserts the DSN shape carries // every required pragma, independent of whether sql.Open picks them up. // This is the first line of defense against a regression that drops one diff --git a/internal/storage/sqlite_error.go b/internal/storage/sqlite_error.go new file mode 100644 index 0000000..c87ee22 --- /dev/null +++ b/internal/storage/sqlite_error.go @@ -0,0 +1,24 @@ +package storage + +import "errors" + +const ( + sqliteBusyCode = 5 + sqliteLockedCode = 6 +) + +// IsBusyErr reports whether err is a SQLite busy/locked condition that may be +// retried. +func IsBusyErr(err error) bool { + if err == nil { + return false + } + + var sqliteErr interface{ Code() int } + if !errors.As(err, &sqliteErr) { + return false + } + + baseCode := sqliteErr.Code() & 0xFF + return baseCode == sqliteBusyCode || baseCode == sqliteLockedCode +}