diff --git a/README.md b/README.md index 67d673de..3c2b4793 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ spektacular publish --format json # the result as struct A spec's children are its plan's tasks when it has a task-format plan, else its `## Requirements` entries with the acceptance criteria traced to each; an epic's children are its specs, in epic order, with `depends_on` carried over. Each child is a native sub-issue of the RFC and each dependency a "blocked by" relation. The `epic` label (or `publish.github.parent_label`) goes on the RFC only when the repository already has it; publish never creates labels. Every filed item carries a hidden marker, `` on the RFC and the same with `task= rfc=` on each child, so a re-run finds what it filed: an unchanged publication is a no-op, one that would change filed items is refused with `publish_conflict` and a list of the changes unless `--update` is given. The result is written back to the artifact's frontmatter as `published: [{target, rfc_url, children: [{id, url}]}]`, which `status` reports. On Jira the RFC is an Epic and each child a Story under it (`publish.jira.parent_issue_type` / `child_issue_type`), dependencies are `Blocks` links, labels get their whitespace replaced with `-`, the marker is also kept in the `spek.publish` issue property (set in the same request that creates the issue) and the description carries a `spekpub` search token that a re-run's text JQL finds, with the recorded `published:` URLs read directly when the search index has not caught up; a re-run also puts every child back under the Epic and refiles any missing `Blocks` link, reporting them as `linked`; Jira Cloud (REST v3, `JIRA_EMAIL` + `JIRA_API_TOKEN`) and Data Center (`deployment: datacenter`, REST v2, `JIRA_PAT`, `JIRA_CA_BUNDLE`) are both supported, with the site in `JIRA_BASE_URL`. On Linear the RFC is an issue in `publish.linear.team` and each child a sub-issue of it, created in Todo (or `publish.linear.state`) and assigned to `publish.linear.assignee` when set; dependencies are `blocks` relations, put back on every run like the parent; labels are applied only when the team or workspace has them; the description carries a body digest before the marker so re-runs compare digests, archived issues are searched too, and the API key is `LINEAR_API_KEY`. A non-final artifact is refused with `publish_not_final` (`--allow-draft` files it as `RFC: [DRAFT] …`); an unknown target with `publish_target_unconfigured`, as is a jira run with no `publish.jira.project_key` or site and a linear run with no `publish.linear.team`; a github run with no repository to file in with `publish_repo_unknown`, and one with no credential (`GITHUB_TOKEN`, `GH_TOKEN` or `gh auth token`; `JIRA_EMAIL`/`JIRA_API_TOKEN` or `JIRA_PAT`; `LINEAR_API_KEY`) with `publish_auth_missing`; a body a hive's clarity gate would park — under 80 characters, an unsubstituted ``, an unchosen `high/medium/low` list — with `publish_body_hygiene`. See [docs/publish-hive-handshake.md](docs/publish-hive-handshake.md) for the contract a hive reads. +### Voice → spec → hive + +A spoken note can start a spec, or — when its summary already has the spec's shape — become an RFC with child issues in one command. `voice` reads one note from a voice interface (`--from plaud` through Plaud's free MCP server, `--from wisprflow` from Wispr Flow's local history, `--from file` for any `.md`/`.txt`/`.srt`/`.vtt` transcript, `--from stdin` for text piped in — a non-interactive agent in a hive pod seeds from stdin or a file), drafts it onto the spec scaffold, and either starts the normal interview over the gaps or, with `--publish`, writes the spec as final and runs `publish`: + +```bash +npx --loglevel=info -y @plaud-ai/mcp@latest install # once: sign in to Plaud (free) +spektacular init copilot # once, in the repo the hive watches +spektacular voice --from plaud --latest # start the spec interview from the newest recording +spektacular voice --from plaud --title "drift" --publish # spec-shaped recording → spec → RFC + one child issue per requirement +spektacular voice --from file --path memo.srt --publish --dry-run # preview the children; writes nothing +``` + +`--publish` needs a note whose summary carries `Requirements` and `Acceptance Criteria` (give the recorder a summary template whose headings are the scaffold's: Overview, Requirements, Constraints, Acceptance Criteria, Technical Approach, Success Metrics, Non-Goals); otherwise it refuses with `voice_not_spec_shaped` and the plain `voice` interview is the path. The note's summary, transcript and first draft are staged under `.spektacular/tmp/voice//` (or `.spektacular/work//voice/` once published), the recording's URI (`plaud://recordings/`, `wisprflow://history/`, `file://…`) is recorded as the spec's source, and the RFC and child URLs are printed (or returned under `voice` with `--format json`). Transcripts fetched through Plaud or Wispr pass through those vendors' clouds; `--from file` with an exported transcript keeps everything local. + ### Addressing specs, plans and changelog records Every document a feature produces is addressed by the feature's bare name, the same name the workflow records in `data.name`, with no file extension: diff --git a/cmd/publish.go b/cmd/publish.go index e6ad716b..4445ca5f 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -782,12 +782,18 @@ func replacePublication(list []metadata.Publication, entry metadata.Publication) return append(out, entry) } +// publishFlags defines publish's flags on cmd; voice reuses them to run +// publish on the spec it drafted. +func publishFlags(cmd *cobra.Command) { + cmd.Flags().Bool("schema", false, "Print the input/output schema and exit") + cmd.Flags().String("target", "", "Work source to publish into: github (default), jira or linear") + cmd.Flags().String("repo", "", "GitHub owner/repo to file in; defaults to publish.github.repo, then the project's source, then the cwd's origin") + cmd.Flags().Bool("dry-run", false, "Render what would be filed and make no network calls") + cmd.Flags().Bool("update", false, "Update items already filed for this artifact in place") + cmd.Flags().Bool("allow-draft", false, "Publish an artifact that is not final, titled RFC: [DRAFT] …") + cmd.Flags().String("format", publishFormatPretty, "Output format: pretty or json") +} + func init() { - publishCmd.Flags().Bool("schema", false, "Print the input/output schema and exit") - publishCmd.Flags().String("target", "", "Work source to publish into: github (default), jira or linear") - publishCmd.Flags().String("repo", "", "GitHub owner/repo to file in; defaults to publish.github.repo, then the project's source, then the cwd's origin") - publishCmd.Flags().Bool("dry-run", false, "Render what would be filed and make no network calls") - publishCmd.Flags().Bool("update", false, "Update items already filed for this artifact in place") - publishCmd.Flags().Bool("allow-draft", false, "Publish an artifact that is not final, titled RFC: [DRAFT] …") - publishCmd.Flags().String("format", publishFormatPretty, "Output format: pretty or json") + publishFlags(publishCmd) } diff --git a/cmd/spec.go b/cmd/spec.go index 11b650dc..b46cbfa1 100644 --- a/cmd/spec.go +++ b/cmd/spec.go @@ -151,6 +151,7 @@ func runSpecNew(cmd *cobra.Command, _ []string) error { "sources": {Type: "array", Items: specSourceItemSchema, Description: "what directly seeded the spec; each needs a uri, and the CLI stamps retrieved_date with today"}, "epic": {Type: "string", Description: "an existing epic the spec joins from the start; omit for a standalone spec"}, + "material": {Type: "string", Description: "a folder, relative to the project root, where the sources' content has already been fetched; the interview reads it instead of fetching"}, "confirm_completed_epic": {Type: "boolean", Description: "true only after the user agrees to add the spec to an epic whose specs are all implemented"}, }, Required: []string{"name"}, @@ -206,6 +207,7 @@ func runSpecNew(cmd *cobra.Command, _ []string) error { URI string `json:"uri"` } `json:"sources"` Epic string `json:"epic"` + Material string `json:"material"` ConfirmCompletedEpic bool `json:"confirm_completed_epic"` } if err := json.Unmarshal([]byte(dataStr), &input); err != nil { @@ -307,6 +309,9 @@ func runSpecNew(cmd *cobra.Command, _ []string) error { if joinEpic != "" { wf.SetData("epic", joinEpic) } + if input.Material != "" { + wf.SetData("material", input.Material) + } if err := wf.Next(); err != nil { return err diff --git a/cmd/voice.go b/cmd/voice.go new file mode 100644 index 00000000..cafa2e89 --- /dev/null +++ b/cmd/voice.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/hivecommons/spektacular/internal/metadata" + "github.com/hivecommons/spektacular/internal/output" + "github.com/hivecommons/spektacular/internal/steps/spec" + "github.com/hivecommons/spektacular/internal/store" + "github.com/hivecommons/spektacular/internal/voice" +) + +// voiceFetchTimeout bounds one fetch from a voice source, MCP handshake +// included. +const voiceFetchTimeout = 2 * time.Minute + +var voiceCmd = &cobra.Command{ + Use: "voice", + Short: "Start a spec from a voice note (Plaud, Wispr Flow, a transcript file, or stdin), optionally publishing it as an RFC", + Long: `Voice fetches one spoken note from a voice interface, drafts it onto the spec +scaffold and hands it on. Without --publish it starts the normal spec workflow +seeded with the note, so the driving agent runs the interview over the gaps. +With --publish it writes the drafted spec as final and files it as an RFC issue +with one child issue per requirement — the same hand-off ` + "`publish`" + ` makes — so a +hive can pick the work up without an interview. That needs a note whose summary +already carries Requirements and Acceptance Criteria sections (a recorder's +custom summary template shaped like the spec scaffold does this); otherwise +voice refuses and names what is missing. + +Sources: + plaud Plaud's official MCP server (npx -y @plaud-ai/mcp@latest; sign in once with "install") + wisprflow Wispr Flow's local history (flow.sqlite via sqlite3) or a folder of exported notes + file a .md/.txt/.srt/.vtt note, or the newest one in a folder (--path) + stdin the note text piped or pasted in + +Examples: + spektacular voice --from plaud --latest + spektacular voice --from plaud --title "cluster drift" --publish --repo acme/demo + spektacular voice --from file --path memo.srt + pbpaste | spektacular voice --from stdin --publish --dry-run`, + Args: cobra.NoArgs, + RunE: runVoice, +} + +type voiceResult struct { + Source string `json:"source"` + Title string `json:"title"` + URI string `json:"uri"` + Material string `json:"material"` + SpecName string `json:"spec_name,omitempty"` + SpecPath string `json:"spec_path,omitempty"` + Missing []string `json:"missing_sections,omitempty"` + RFCURL string `json:"rfc_url,omitempty"` + Children []string `json:"children,omitempty"` + NextAction string `json:"next_action,omitempty"` +} + +func runVoice(cmd *cobra.Command, _ []string) error { + from, _ := cmd.Flags().GetString("from") + latest, _ := cmd.Flags().GetBool("latest") + title, _ := cmd.Flags().GetString("title") + path, _ := cmd.Flags().GetString("path") + name, _ := cmd.Flags().GetString("name") + publishIt, _ := cmd.Flags().GetBool("publish") + dryRun, _ := cmd.Flags().GetBool("dry-run") + format, _ := cmd.Flags().GetString("format") + command, _ := cmd.Flags().GetString("command") + + if from == "" { + return output.NewError("voice_source_required", "no voice source was given"). + WithNextAction("re-run with --from " + strings.Join(voice.SourceNames, "|")) + } + if format != publishFormatPretty && format != publishFormatJSON { + return output.NewError("voice_format_unsupported", fmt.Sprintf("format %q is not supported", format)). + WithNextAction(fmt.Sprintf("re-run with --format %s or --format %s", publishFormatPretty, publishFormatJSON)) + } + if !latest && title == "" && (from == "plaud" || from == "wisprflow") { + return output.NewError("voice_query_required", "say which note to take"). + WithNextAction("re-run with --latest, or --title ") + } + + root, err := projectRoot() + if err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + + src, err := voice.Open(from, voice.Options{Command: command, Stdin: cmd.InOrStdin()}) + if err != nil { + return output.NewError("voice_source_unknown", err.Error()). + WithNextAction("re-run with --from " + strings.Join(voice.SourceNames, "|")) + } + parent := cmd.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, voiceFetchTimeout) + defer cancel() + note, err := src.Fetch(ctx, voice.Query{Latest: latest, Title: title, Path: path}) + if err != nil { + return output.NewError("voice_fetch_failed", err.Error()).WithResource(from). + WithNextAction("fix the source and retry; or export the note and re-run with --from file --path ") + } + + draft := voice.DraftNote(note) + if name == "" { + name = voice.Slug(draft.Title) + } + st := store.NewSourceStore(root, "project") + res := voiceResult{Source: from, Title: draft.Title, URI: note.URI, Missing: draft.Missing} + + if !publishIt { + res.Material = filepath.Join(".spektacular", "tmp", "voice", name) + if !dryRun { + if err := writeVoiceMaterial(root, res.Material, note, draft); err != nil { + return err + } + } + data, _ := json.Marshal(map[string]any{ + "name": name, + "sources": []map[string]string{{"uri": note.URI}}, + "material": res.Material, + }) + nc := &cobra.Command{RunE: runSpecNew} + nc.Flags().Bool("schema", false, "") + nc.Flags().Bool("dry-run", dryRun, "") + nc.Flags().String("data", string(data), "") + nc.Flags().Bool("force", false, "") + nc.Flags().String("stdin", "", "") + nc.Flags().String("file", "", "") + nc.SetOut(cmd.OutOrStdout()) + nc.SetIn(cmd.InOrStdin()) + return runSpecNew(nc, nil) + } + + if len(draft.Missing) > 0 { + return output.NewError("voice_not_spec_shaped", + fmt.Sprintf("the note %q has no %s section, so it cannot be published without an interview", draft.Title, strings.Join(draft.Missing, " or "))). + WithResource(note.URI). + WithNextAction(fmt.Sprintf("re-run without --publish to start the interview from this note; or record with a summary template whose headings are the spec sections (%s)", strings.Join(voice.Sections, ", "))) + } + + resolved, err := spec.ResolveIdentifier(spec.IdentifierRequest{ + Name: name, Method: cfg.Spec.IDMethod, SpecDir: cfg.Spec.Config.Directory, Store: st, + Now: specIdentifierNow, RandomID: specIdentifierRandomID, + }) + if err != nil { + return err + } + res.SpecName = resolved.Name + res.SpecPath = spec.SpecFilePath(cfg.Spec.Config.Directory, resolved.Name) + if st.Exists(res.SpecPath) { + return output.NewError("voice_spec_exists", fmt.Sprintf("a spec named %q already exists", resolved.Name)). + WithResource(res.SpecPath). + WithNextAction("re-run with --name , or publish the existing spec with `" + cfg.Command + " publish " + resolved.Name + "`") + } + sources, err := stampSources(cfg, []sourceInput{{URI: note.URI}}, time.Now().UTC()) + if err != nil { + return err + } + final := metadata.StatusFinal + content, err := metadata.Merge(nil, []byte(draft.Body), metadata.UpdateOptions{Sources: &sources, DocumentStatus: &final}) + if err != nil { + return err + } + if dryRun { + res.NextAction = fmt.Sprintf("re-run without --dry-run to write %s and file it as an RFC with %d child issues", res.SpecPath, len(requirementTitles(draft.Body))) + if format == publishFormatJSON { + return output.Write(cmd.OutOrStdout(), map[string]any{"dry_run": true, "voice": res, "children": requirementTitles(draft.Body)}, globalFields) + } + fmt.Fprintf(cmd.OutOrStdout(), "Would write %s from %s (%s) and publish it; children:\n", res.SpecPath, from, note.URI) + for _, c := range requirementTitles(draft.Body) { + fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", c) + } + fmt.Fprintf(cmd.OutOrStdout(), "\n%s", draft.Body) + return nil + } + if err := st.Write(res.SpecPath, content); err != nil { + return err + } + res.Material = filepath.Join(".spektacular", "work", resolved.Name, "voice") + if err := writeVoiceMaterial(root, res.Material, note, draft); err != nil { + return err + } + + pc := &cobra.Command{RunE: runPublish} + publishFlags(pc) + for _, f := range []string{"target", "repo"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + _ = pc.Flags().Set(f, v) + } + } + _ = pc.Flags().Set("format", publishFormatJSON) + var pubOut bytes.Buffer + pc.SetOut(&pubOut) + if err := runPublish(pc, []string{resolved.Name}); err != nil { + return err + } + var pub struct { + RFCURL string `json:"rfc_url"` + Children []struct { + URL string `json:"url"` + } `json:"children"` + } + if err := json.Unmarshal(pubOut.Bytes(), &pub); err == nil { + res.RFCURL = pub.RFCURL + for _, c := range pub.Children { + if c.URL != "" { + res.Children = append(res.Children, c.URL) + } + } + } + if format == publishFormatJSON { + out := map[string]any{} + _ = json.Unmarshal(pubOut.Bytes(), &out) + out["voice"] = res + return output.Write(cmd.OutOrStdout(), out, globalFields) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Spec %s written to %s from %s (%s)\n", resolved.Name, res.SpecPath, from, note.URI) + if res.RFCURL != "" { + fmt.Fprintf(w, "RFC: %s\n", res.RFCURL) + } + for _, c := range res.Children { + fmt.Fprintf(w, " child: %s\n", c) + } + return nil +} + +// requirementTitles lists the bold titles of a drafted body's requirements: +// the child issues publish would file. +func requirementTitles(body string) []string { + var out []string + in := false + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "## ") { + in = strings.TrimSpace(line[3:]) == "Requirements" + continue + } + if !in || !strings.HasPrefix(line, "- [ ] **") { + continue + } + rest := strings.TrimPrefix(line, "- [ ] **") + if i := strings.Index(rest, "**"); i > 0 { + out = append(out, rest[:i]) + } + } + return out +} + +// writeVoiceMaterial stages the note, transcript and drafted spec where the +// agent and the spec workflow can read them. +func writeVoiceMaterial(root, dir string, note voice.Note, draft voice.Draft) error { + abs := filepath.Join(root, dir) + if err := os.MkdirAll(abs, 0o755); err != nil { + return err + } + files := map[string]string{ + "note.md": note.Summary, + "transcript.md": note.Transcript, + "draft.md": draft.Body, + } + for name, body := range files { + if strings.TrimSpace(body) == "" { + continue + } + if err := os.WriteFile(filepath.Join(abs, name), []byte(strings.TrimSpace(body)+"\n"), 0o644); err != nil { + return err + } + } + return nil +} + +func init() { + voiceCmd.Flags().String("from", "", "Voice source: "+strings.Join(voice.SourceNames, ", ")) + voiceCmd.Flags().Bool("latest", false, "Take the most recent note") + voiceCmd.Flags().String("title", "", "Take the newest note whose title contains this text") + voiceCmd.Flags().String("path", "", "File or folder to read (file source), or a flow.sqlite/export folder (wisprflow)") + voiceCmd.Flags().String("name", "", "Spec name; defaults to a slug of the note's title") + voiceCmd.Flags().Bool("publish", false, "Write the drafted spec as final and file it as an RFC with child issues") + voiceCmd.Flags().BoolP("dry-run", "n", false, "Fetch and draft, but write nothing and make no network calls") + voiceCmd.Flags().String("repo", "", "With --publish: GitHub owner/repo to file in") + voiceCmd.Flags().String("target", "", "With --publish: work source (github, jira, linear)") + voiceCmd.Flags().String("format", publishFormatPretty, "Output format: pretty or json") + voiceCmd.Flags().String("command", "", "MCP server command for MCP-backed sources (plaud default: "+voice.DefaultPlaudCommand+")") + rootCmd.AddCommand(voiceCmd) +} diff --git a/cmd/voice_test.go b/cmd/voice_test.go new file mode 100644 index 00000000..11b61167 --- /dev/null +++ b/cmd/voice_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hivecommons/spektacular/internal/metadata" + "github.com/hivecommons/spektacular/internal/publish/github/githubtest" +) + +// voiceSummary is what a recorder's "spec-shaped" summary template yields: +// headings that match the scaffold, bullets that become checklist items. +const voiceSummary = `# Cluster drift alerts + +## Summary +Page the on-call when a cluster drifts from its desired state, so drift is fixed before users notice it. + +## Requirements +- Detect drift: compare each cluster's live state with its desired state every minute +- Page on-call: route a drift alert through the existing alert channel once per cluster + +## Acceptance criteria +- Detect drift: a cluster changed by hand shows as drifted in the console within 60 seconds +- Page on-call: exactly one alert fires per drifted cluster until it is reconciled + +## Out of scope +- auto-remediation +` + +func voiceProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + t.Chdir(root) + writeSpecCommandConfig(t, root, "") + t.Setenv("GITHUB_TOKEN", githubtest.Token) + t.Setenv("GH_TOKEN", "") + return root +} + +func writeVoiceNote(t *testing.T, root, name, body string) string { + t.Helper() + path := filepath.Join(root, name) + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) + return path +} + +func TestVoicePublishesASpecShapedNoteAsAnRFCWithChildren(t *testing.T) { + root := voiceProject(t) + gh := githubtest.New(t) + t.Setenv("SPEKTACULAR_GITHUB_API", gh.URL) + note := writeVoiceNote(t, root, "memo.md", voiceSummary) + + stdout, stderr, code := runRootCmd(t, "voice", "--from", "file", "--path", note, "--publish", "--repo", "acme/demo", "--format", "json") + require.Empty(t, stderr) + require.Equal(t, 0, code, stdout) + var res struct { + RFCURL string `json:"rfc_url"` + Created []string `json:"created"` + Voice struct { + SpecName string `json:"spec_name"` + SpecPath string `json:"spec_path"` + URI string `json:"uri"` + RFCURL string `json:"rfc_url"` + Children []string `json:"children"` + } `json:"voice"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &res), stdout) + require.Equal(t, "https://github.com/acme/demo/issues/1", res.RFCURL) + require.Equal(t, []string{"rfc", "R1", "R2"}, res.Created, "one child per requirement") + require.True(t, strings.HasSuffix(res.Voice.SpecName, "-cluster-drift-alerts"), res.Voice.SpecName) + require.Equal(t, res.RFCURL, res.Voice.RFCURL) + require.Equal(t, []string{"https://github.com/acme/demo/issues/2", "https://github.com/acme/demo/issues/3"}, res.Voice.Children) + require.True(t, strings.HasPrefix(res.Voice.URI, "file://"), res.Voice.URI) + require.Contains(t, gh.Issues[2].Title, "Detect drift") + require.Contains(t, gh.Issues[2].Body, "within 60 seconds", "the traced acceptance criterion travels with the child") + + raw, err := os.ReadFile(filepath.Join(root, res.Voice.SpecPath)) + require.NoError(t, err) + fm, body, err := metadata.Split(raw) + require.NoError(t, err) + require.Equal(t, metadata.StatusFinal, fm.DocumentStatus) + require.Len(t, fm.Sources, 1) + require.Equal(t, res.Voice.URI, fm.Sources[0].URI) + require.Len(t, fm.Published, 1) + require.Contains(t, string(body), "# Feature: Cluster drift alerts") + require.Contains(t, string(body), "- [ ] **Detect drift**: compare each cluster's live state") + require.Contains(t, string(body), "## Non-Goals\n\n- auto-remediation") + + staged := filepath.Join(root, ".spektacular", "work", res.Voice.SpecName, "voice") + for _, f := range []string{"note.md", "draft.md"} { + _, err := os.Stat(filepath.Join(staged, f)) + require.NoError(t, err, f) + } +} + +func TestVoiceRefusesToPublishANoteWithoutRequirements(t *testing.T) { + root := voiceProject(t) + note := writeVoiceNote(t, root, "memo.txt", "We should alert on cluster drift and page whoever is on call.") + + stdout, _, code := runRootCmd(t, "voice", "--from", "file", "--path", note, "--publish", "--repo", "acme/demo") + resp := requirePublishRefusal(t, stdout, code, "voice_not_spec_shaped") + require.Contains(t, resp.Message, "Requirements or Acceptance Criteria") + require.Contains(t, resp.NextAction, "re-run without --publish") + _, err := os.Stat(filepath.Join(root, ".spektacular", "specs")) + require.True(t, os.IsNotExist(err), "a refusal writes no spec") +} + +func TestVoiceWithoutPublishStagesTheNoteAndStartsTheSpecWorkflow(t *testing.T) { + root := voiceProject(t) + note := writeVoiceNote(t, root, "memo.txt", "We should alert on cluster drift and page whoever is on call.") + + stdout, stderr, code := runRootCmd(t, "voice", "--from", "file", "--path", note, "--name", "drift") + require.Empty(t, stderr) + require.Equal(t, 0, code, stdout) + require.Contains(t, stdout, `"step": "new"`, "the spec workflow has started") + + staged := filepath.Join(root, ".spektacular", "tmp", "voice", "drift") + transcript, err := os.ReadFile(filepath.Join(staged, "transcript.md")) + require.NoError(t, err) + require.Contains(t, string(transcript), "alert on cluster drift") + _, err = os.Stat(filepath.Join(staged, "draft.md")) + require.NoError(t, err) + + state, err := os.ReadFile(filepath.Join(root, ".spektacular", "state.json")) + require.NoError(t, err) + require.Contains(t, string(state), `"material": ".spektacular/tmp/voice/drift"`) + require.Contains(t, string(state), `"uri": "file://`) + + // The interview that follows tells the agent to read the staged folder. + stdout, _, code = runRootCmd(t, "spec", "goto", "--data", `{"step":"interview"}`) + require.Equal(t, 0, code, stdout) + require.Contains(t, stdout, "Read the material already fetched to `.spektacular/tmp/voice/drift`") + require.NotContains(t, stdout, "Fetch each source again with your own tools") +} + +func TestVoiceFromStdinDryRunPreviewsTheChildren(t *testing.T) { + voiceProject(t) + resetRootCmd(t) + rootCmd.SetIn(strings.NewReader(voiceSummary)) + t.Cleanup(func() { rootCmd.SetIn(nil) }) + stdout, _, code := runRootCmd(t, "voice", "--from", "stdin", "--publish", "--dry-run", "--format", "json") + require.Equal(t, 0, code, stdout) + var res struct { + DryRun bool `json:"dry_run"` + Children []string `json:"children"` + Voice struct { + URI string `json:"uri"` + SpecPath string `json:"spec_path"` + } `json:"voice"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &res), stdout) + require.True(t, res.DryRun) + require.Equal(t, []string{"Detect drift", "Page on-call"}, res.Children) + require.Equal(t, "stdin://", res.Voice.URI) + _, err := os.Stat(res.Voice.SpecPath) + require.True(t, os.IsNotExist(err), "dry run writes nothing") +} diff --git a/internal/steps/spec/steps.go b/internal/steps/spec/steps.go index e31414ff..a56044da 100644 --- a/internal/steps/spec/steps.go +++ b/internal/steps/spec/steps.go @@ -122,8 +122,9 @@ func interview() workflow.StepCallback { } // interviewExtra is what the interview template is given beyond the standard -// variables: the links the spec was seeded from and the epic it joined, so the -// template can open a seeded interview. Both are empty for a plain spec. +// variables: the links the spec was seeded from, the folder their content was +// already fetched to (voice stages one), and the epic it joined, so the +// template can open a seeded interview. All are empty for a plain spec. func interviewExtra(data workflow.Data) (map[string]any, error) { sources, err := sourcesFrom(data) if err != nil { @@ -137,8 +138,9 @@ func interviewExtra(data workflow.Data) (map[string]any, error) { "sources": uris, // seeded gates the template's seeded branch: a mustache section // over the list itself would repeat the branch once per source. - "seeded": len(uris) > 0, - "epic": stepkit.GetString(data, "epic"), + "seeded": len(uris) > 0, + "material": stepkit.GetString(data, "material"), + "epic": stepkit.GetString(data, "epic"), }, nil } diff --git a/internal/voice/draft.go b/internal/voice/draft.go new file mode 100644 index 00000000..1ab044d7 --- /dev/null +++ b/internal/voice/draft.go @@ -0,0 +1,212 @@ +package voice + +import ( + "regexp" + "strings" +) + +// Sections lists the spec scaffold headings in order. +var Sections = []string{"Overview", "Requirements", "Constraints", "Acceptance Criteria", "Technical Approach", "Success Metrics", "Non-Goals"} + +// RequiredSections are what a note must carry to be published without an +// interview. +var RequiredSections = []string{"Requirements", "Acceptance Criteria"} + +// sectionAliases maps lower-cased headings as a summary template (Plaud's, +// Wispr's, a human's) might phrase them onto scaffold sections. +var sectionAliases = map[string]string{ + "overview": "Overview", "summary": "Overview", "goal": "Overview", "problem": "Overview", "context": "Overview", "background": "Overview", + "requirements": "Requirements", "requirement": "Requirements", "must": "Requirements", "features": "Requirements", "what to build": "Requirements", "scope": "Requirements", + "constraints": "Constraints", "constraint": "Constraints", "limits": "Constraints", "boundaries": "Constraints", + "acceptance criteria": "Acceptance Criteria", "acceptance": "Acceptance Criteria", "action items": "Acceptance Criteria", "done when": "Acceptance Criteria", "definition of done": "Acceptance Criteria", "todo": "Acceptance Criteria", "to-do": "Acceptance Criteria", "tasks": "Acceptance Criteria", + "technical approach": "Technical Approach", "approach": "Technical Approach", "technical": "Technical Approach", "implementation": "Technical Approach", "how": "Technical Approach", "design": "Technical Approach", + "success metrics": "Success Metrics", "metrics": "Success Metrics", "success": "Success Metrics", "measures": "Success Metrics", + "non-goals": "Non-Goals", "non goals": "Non-Goals", "nongoals": "Non-Goals", "out of scope": "Non-Goals", "not doing": "Non-Goals", "exclusions": "Non-Goals", + "open questions": "Open Questions", "questions": "Open Questions", "unknowns": "Open Questions", +} + +// Draft maps a note onto the spec scaffold. Recognised summary headings land +// in their sections; unrecognised text goes to Overview; a note with no +// summary gets the transcript's opening as its Overview. Requirements and +// acceptance criteria are normalised to the scaffold's checklist form. +// missing names the required sections the note did not fill. +type Draft struct { + Title string + Body string + Missing []string + // Extra holds recognised sections the scaffold has no slot for (Open + // Questions), appended after Non-Goals. + Extra map[string]string +} + +var headingLine = regexp.MustCompile(`^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$`) + +// DraftNote builds a Draft from a note. +func DraftNote(n Note) Draft { + sections := map[string]string{} + var extra []string + title := strings.TrimSpace(n.Title) + + if strings.TrimSpace(n.Summary) != "" { + current := "Overview" + var buf strings.Builder + flush := func() { + if t := strings.TrimSpace(buf.String()); t != "" { + sections[current] = joinSection(sections[current], t) + } + buf.Reset() + } + for _, line := range strings.Split(n.Summary, "\n") { + if m := headingLine.FindStringSubmatch(line); m != nil { + heading := strings.TrimSpace(m[2]) + if len(m[1]) == 1 && title == "" { + title = strings.TrimPrefix(heading, "Feature: ") + continue + } + flush() + if sec, ok := sectionAliases[normaliseHeading(heading)]; ok { + current = sec + if sec == "Open Questions" { + extra = appendUnique(extra, sec) + } + } else { + current = "Overview" + buf.WriteString("**" + heading + "**\n") + } + continue + } + buf.WriteString(line + "\n") + } + flush() + } else if t := strings.TrimSpace(n.Transcript); t != "" { + sections["Overview"] = excerpt(t, 600) + } + + for _, sec := range []string{"Requirements", "Acceptance Criteria"} { + if sections[sec] != "" { + sections[sec] = checklist(sections[sec]) + } + } + + if title == "" { + title = "Voice note" + } + var b strings.Builder + b.WriteString("# Feature: " + title + "\n") + for _, sec := range Sections { + b.WriteString("\n## " + sec + "\n\n") + if body := sections[sec]; body != "" { + b.WriteString(body + "\n") + } + } + d := Draft{Title: title, Body: b.String(), Extra: map[string]string{}} + for _, sec := range extra { + d.Extra[sec] = sections[sec] + b.WriteString("\n## " + sec + "\n\n" + sections[sec] + "\n") + } + d.Body = b.String() + for _, sec := range RequiredSections { + if strings.TrimSpace(sections[sec]) == "" { + d.Missing = append(d.Missing, sec) + } + } + return d +} + +func normaliseHeading(h string) string { + h = strings.ToLower(strings.TrimSpace(h)) + h = strings.TrimRight(h, ":") + h = strings.TrimSpace(strings.TrimPrefix(h, "feature:")) + return h +} + +func joinSection(existing, more string) string { + if existing == "" { + return more + } + return existing + "\n\n" + more +} + +func appendUnique(list []string, s string) []string { + for _, x := range list { + if x == s { + return list + } + } + return append(list, s) +} + +var bulletPrefix = regexp.MustCompile(`^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?`) + +// checklist rewrites bullets as "- [ ] **Title**: detail" so publish's +// requirement parser and the scaffold agree. Already-formatted lines and +// indented detail lines pass through. +func checklist(text string) string { + var out []string + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + if t == "" { + continue + } + if !bulletPrefix.MatchString(line) { + if len(out) > 0 && strings.HasPrefix(line, " ") { + out = append(out, line) + } else { + out = append(out, "- [ ] "+boldTitle(t)) + } + continue + } + item := bulletPrefix.ReplaceAllString(line, "") + if strings.HasPrefix(item, "**") { + out = append(out, "- [ ] "+item) + continue + } + out = append(out, "- [ ] "+boldTitle(item)) + } + return strings.Join(out, "\n") +} + +// boldTitle splits "Title: detail" or "Sentence. More." into a bold title +// and its detail; a short item becomes a bold title alone. +func boldTitle(item string) string { + item = strings.TrimSpace(item) + if i := strings.Index(item, ": "); i > 0 && i < 80 { + return "**" + item[:i] + "**: " + strings.TrimSpace(item[i+2:]) + } + if i := strings.Index(item, ". "); i > 0 && i < 80 { + return "**" + item[:i] + "**: " + strings.TrimSpace(item[i+2:]) + } + words := strings.Fields(item) + if len(words) <= 10 { + return "**" + strings.TrimRight(item, ".") + "**" + } + return "**" + strings.Join(words[:6], " ") + "**: " + item +} + +// excerpt returns the first n runes of text at a word boundary. +func excerpt(text string, n int) string { + r := []rune(text) + if len(r) <= n { + return text + } + cut := string(r[:n]) + if i := strings.LastIndex(cut, " "); i > n/2 { + cut = cut[:i] + } + return cut + "…" +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// Slug makes a filesystem- and identifier-safe name from a title. +func Slug(title string) string { + s := slugStrip.ReplaceAllString(strings.ToLower(title), "-") + s = strings.Trim(s, "-") + if len(s) > 60 { + s = strings.Trim(s[:60], "-") + } + if s == "" { + return "voice-note" + } + return s +} diff --git a/internal/voice/file.go b/internal/voice/file.go new file mode 100644 index 00000000..e462a396 --- /dev/null +++ b/internal/voice/file.go @@ -0,0 +1,176 @@ +package voice + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// fileSource reads one transcript or note file (.md, .txt, .srt), or the +// newest such file in a folder. +type fileSource struct{} + +func (fileSource) Name() string { return "file" } + +func (fileSource) Fetch(_ context.Context, q Query) (Note, error) { + if q.Path == "" { + return Note{}, fmt.Errorf("file: --path is required") + } + info, err := os.Stat(q.Path) + if err != nil { + return Note{}, fmt.Errorf("file: %w", err) + } + if info.IsDir() { + return fetchFromFolder("file", q.Path, q) + } + return readNoteFile(q.Path, info.ModTime()) +} + +// stdinSource reads a pasted or dictated note from standard input. +type stdinSource struct{ r io.Reader } + +func (stdinSource) Name() string { return "stdin" } + +func (s stdinSource) Fetch(_ context.Context, _ Query) (Note, error) { + if s.r == nil { + return Note{}, fmt.Errorf("stdin: nothing to read") + } + data, err := io.ReadAll(s.r) + if err != nil { + return Note{}, fmt.Errorf("stdin: %w", err) + } + text := strings.TrimSpace(string(data)) + if text == "" { + return Note{}, fmt.Errorf("stdin: nothing to read; pipe or paste the note text") + } + n := noteFromText(text, "") + n.URI = "stdin://" + n.RecordedAt = time.Now() + return n, nil +} + +var noteExtensions = map[string]bool{".md": true, ".markdown": true, ".txt": true, ".srt": true, ".vtt": true} + +// fetchFromFolder picks the newest note file in dir (or the newest whose +// name or title matches the query). +func fetchFromFolder(source, dir string, q Query) (Note, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return Note{}, fmt.Errorf("%s: %w", source, err) + } + type cand struct { + path string + mod time.Time + } + var cands []cand + for _, e := range entries { + if e.IsDir() || !noteExtensions[strings.ToLower(filepath.Ext(e.Name()))] { + continue + } + info, err := e.Info() + if err != nil { + continue + } + cands = append(cands, cand{filepath.Join(dir, e.Name()), info.ModTime()}) + } + sort.Slice(cands, func(i, j int) bool { return cands[i].mod.After(cands[j].mod) }) + i, err := pick(source, q, len(cands), + func(i int) string { + return strings.TrimSuffix(filepath.Base(cands[i].path), filepath.Ext(cands[i].path)) + }, + func(i int) time.Time { return cands[i].mod }) + if err != nil { + return Note{}, err + } + return readNoteFile(cands[i].path, cands[i].mod) +} + +func readNoteFile(path string, mod time.Time) (Note, error) { + data, err := os.ReadFile(path) + if err != nil { + return Note{}, fmt.Errorf("file: %w", err) + } + text := strings.TrimSpace(string(data)) + if text == "" { + return Note{}, fmt.Errorf("file: %s is empty", path) + } + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".srt" || ext == ".vtt" { + text = stripSubtitles(text) + } + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + n := noteFromText(text, base) + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + n.URI = "file://" + abs + n.RecordedAt = mod + return n, nil +} + +// noteFromText treats Markdown with headings as a summary and anything else +// as a transcript. fallbackTitle is used when the text has no "# " heading. +func noteFromText(text, fallbackTitle string) Note { + n := Note{} + lines := strings.Split(text, "\n") + if strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") { + n.Title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[0]), "# ")) + text = strings.TrimSpace(strings.Join(lines[1:], "\n")) + } + if strings.Contains("\n"+text, "\n#") { + n.Summary = text + } else { + n.Transcript = text + } + if n.Title == "" { + n.Title = fallbackTitle + } + if n.Title == "" { + n.Title = titleFromText(text) + } + return n +} + +var ( + subtitleIndex = regexp.MustCompile(`^\d+$`) + subtitleTime = regexp.MustCompile(`^\d{2}:\d{2}:\d{2}[,.]\d{3} --> `) + subtitleTag = regexp.MustCompile(`<[^>]+>`) +) + +// stripSubtitles drops SRT/VTT cue numbers and timestamps, keeping the text. +func stripSubtitles(text string) string { + var out []string + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + switch { + case t == "", t == "WEBVTT", subtitleIndex.MatchString(t), subtitleTime.MatchString(t): + continue + } + out = append(out, subtitleTag.ReplaceAllString(t, "")) + } + return strings.Join(out, " ") +} + +// titleFromText makes a short title from the first words of a note. +func titleFromText(text string) string { + line := strings.TrimLeft(firstLine(strings.TrimSpace(text)), "#-*[] ") + if i := strings.IndexAny(line, ".!?"); i > 0 { + line = line[:i] + } + words := strings.Fields(line) + if len(words) > 8 { + words = words[:8] + } + t := strings.TrimRight(strings.Join(words, " "), ".,;:!?") + if t == "" { + return "Voice note" + } + return t +} diff --git a/internal/voice/mcp.go b/internal/voice/mcp.go new file mode 100644 index 00000000..59ba9917 --- /dev/null +++ b/internal/voice/mcp.go @@ -0,0 +1,264 @@ +package voice + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" +) + +// mcpProtocolVersion is the MCP revision spoken as a stdio client. +const mcpProtocolVersion = "2024-11-05" + +// mcpMaxLineBytes caps one JSON-RPC line read from an MCP server; a long +// transcript arrives as one line. +const mcpMaxLineBytes = 32 << 20 + +// MCPClient is one connected MCP session: Call invokes a tool and returns +// the concatenated text content of its result. +type MCPClient interface { + Call(ctx context.Context, tool string, args map[string]any) (string, error) + Close() error +} + +// Dialer opens an MCP session. +type Dialer func(ctx context.Context) (MCPClient, error) + +// mcpSession speaks newline-delimited JSON-RPC 2.0 over a reader/writer +// pair, the same shape hive's knowledge connectors use. Requests are +// serialised; notifications are dropped. +type mcpSession struct { + w io.Writer + lines chan []byte + done chan struct{} + mu sync.Mutex + nextID int64 + stderr *lockedBuffer + closer func() error +} + +// lockedBuffer is a goroutine-safe buffer for a child's stderr. +type lockedBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) tail() string { + b.mu.Lock() + defer b.mu.Unlock() + s := strings.TrimSpace(b.buf.String()) + if len(s) > 2000 { + s = s[len(s)-2000:] + } + return s +} + +type rpcMessage struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// NewMCPSession wires a session over r/w and performs the MCP handshake. +// closer, when non-nil, is called by Close. +func NewMCPSession(ctx context.Context, r io.Reader, w io.Writer, closer func() error) (MCPClient, error) { + return newSession(ctx, r, w, nil, closer) +} + +func newSession(ctx context.Context, r io.Reader, w io.Writer, stderr *lockedBuffer, closer func() error) (MCPClient, error) { + s := &mcpSession{w: w, lines: make(chan []byte, 64), done: make(chan struct{}), stderr: stderr, closer: closer} + go s.readLoop(r) + init := map[string]any{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "spektacular-voice", "version": "1"}, + } + if _, err := s.request(ctx, "initialize", init); err != nil { + _ = s.Close() + return nil, err + } + _ = s.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"}) + return s, nil +} + +func (s *mcpSession) readLoop(r io.Reader) { + defer close(s.done) + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), mcpMaxLineBytes) + for sc.Scan() { + if line := strings.TrimSpace(sc.Text()); line != "" { + s.lines <- []byte(line) + } + } +} + +func (s *mcpSession) send(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + _, err = s.w.Write(append(data, '\n')) + return err +} + +// request sends one request and waits for its reply. +func (s *mcpSession) request(ctx context.Context, method string, params any) (json.RawMessage, error) { + s.mu.Lock() + s.nextID++ + id := s.nextID + s.mu.Unlock() + if err := s.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + return nil, fmt.Errorf("mcp: sending %s: %w", method, err) + } + want := fmt.Sprintf("%d", id) + for { + select { + case <-ctx.Done(): + return nil, s.withStderr(fmt.Errorf("mcp: %s: %w", method, ctx.Err())) + case line := <-s.lines: + if res, ok, err := s.match(line, want, method); ok { + return res, err + } + case <-s.done: + // Drain replies that arrived just before the server hung up. + for { + select { + case line := <-s.lines: + if res, ok, err := s.match(line, want, method); ok { + return res, err + } + default: + return nil, s.withStderr(fmt.Errorf("mcp: %s: server closed the connection", method)) + } + } + } + } +} + +// match decodes one line; ok is true when it is the reply to request id want. +func (s *mcpSession) match(line []byte, want, method string) (json.RawMessage, bool, error) { + var msg rpcMessage + if err := json.Unmarshal(line, &msg); err != nil || len(msg.ID) == 0 || msg.Method != "" { + return nil, false, nil + } + if strings.Trim(string(msg.ID), `"`) != want { + return nil, false, nil + } + if msg.Error != nil { + return nil, true, fmt.Errorf("mcp: %s: %s (code %d)", method, msg.Error.Message, msg.Error.Code) + } + return msg.Result, true, nil +} + +func (s *mcpSession) withStderr(err error) error { + if s.stderr == nil { + return err + } + if tail := s.stderr.tail(); tail != "" { + return fmt.Errorf("%w (stderr: %s)", err, tail) + } + return err +} + +// Call implements MCPClient: tools/call with the text content joined. +func (s *mcpSession) Call(ctx context.Context, tool string, args map[string]any) (string, error) { + if args == nil { + args = map[string]any{} + } + raw, err := s.request(ctx, "tools/call", map[string]any{"name": tool, "arguments": args}) + if err != nil { + return "", err + } + var res struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return "", fmt.Errorf("mcp: %s: decoding result: %w", tool, err) + } + var parts []string + for _, c := range res.Content { + if c.Type == "text" && c.Text != "" { + parts = append(parts, c.Text) + } + } + text := strings.Join(parts, "\n") + if res.IsError { + if text == "" { + text = "tool reported an error" + } + return text, fmt.Errorf("mcp: %s: %s", tool, firstLine(text)) + } + return text, nil +} + +// Close implements MCPClient. +func (s *mcpSession) Close() error { + if s.closer != nil { + return s.closer() + } + return nil +} + +// stdioDialer starts command as a child process speaking MCP on its +// stdin/stdout. +func stdioDialer(command string) Dialer { + return func(ctx context.Context) (MCPClient, error) { + argv := strings.Fields(command) + if len(argv) == 0 { + return nil, errors.New("mcp: command is empty") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Env = os.Environ() + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr := &lockedBuffer{} + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("mcp: starting %q: %w", argv[0], err) + } + closer := sync.OnceValue(func() error { + _ = stdin.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return cmd.Wait() + }) + return newSession(ctx, stdout, stdin, stderr, closer) + } +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/internal/voice/plaud.go b/internal/voice/plaud.go new file mode 100644 index 00000000..af9dbfc4 --- /dev/null +++ b/internal/voice/plaud.go @@ -0,0 +1,269 @@ +package voice + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// DefaultPlaudCommand starts Plaud's official MCP server (free, OAuth; the +// token lives in ~/.plaud/tokens-mcp.json after `npx --loglevel=info -y @plaud-ai/mcp@latest install`). +const DefaultPlaudCommand = "npx -y @plaud-ai/mcp@latest" + +// plaudListPageSize is the list_files page size requested. +const plaudListPageSize = 20 + +// plaudSource reads one recording through the Plaud MCP: list_files → +// get_note + get_transcript. +type plaudSource struct { + dial Dialer +} + +func newPlaud(opts Options) *plaudSource { + if opts.Dial != nil { + return &plaudSource{dial: opts.Dial} + } + command := opts.Command + if command == "" { + command = DefaultPlaudCommand + } + return &plaudSource{dial: stdioDialer(command)} +} + +func (p *plaudSource) Name() string { return "plaud" } + +type plaudFile struct { + ID string + Name string + CreatedAt time.Time +} + +func (p *plaudSource) Fetch(ctx context.Context, q Query) (Note, error) { + client, err := p.dial(ctx) + if err != nil { + return Note{}, err + } + defer func() { _ = client.Close() }() + + args := map[string]any{"page": 1, "page_size": plaudListPageSize} + if q.Title != "" { + args["query"] = q.Title + } + text, err := client.Call(ctx, "list_files", args) + if err != nil { + return Note{}, plaudAuthError(err) + } + files, err := decodePlaudFiles(text) + if err != nil { + return Note{}, err + } + i, err := pick("plaud", q, len(files), + func(i int) string { return files[i].Name }, + func(i int) time.Time { return files[i].CreatedAt }) + if err != nil { + return Note{}, err + } + f := files[i] + + noteText, noteErr := client.Call(ctx, "get_note", map[string]any{"file_id": f.ID}) + transcriptText, trErr := client.Call(ctx, "get_transcript", map[string]any{"file_id": f.ID}) + if noteErr != nil && trErr != nil { + return Note{}, plaudAuthError(trErr) + } + n := Note{ + Title: f.Name, + Summary: renderPlaudNote(noteText), + Transcript: renderPlaudTranscript(transcriptText), + URI: "plaud://recordings/" + f.ID, + RecordedAt: f.CreatedAt, + } + if n.Title == "" { + n.Title = "Plaud recording " + f.ID + } + if strings.TrimSpace(n.Summary) == "" && strings.TrimSpace(n.Transcript) == "" { + return Note{}, fmt.Errorf("plaud: recording %q (%s) has no note or transcript yet; let the Plaud app finish processing it", n.Title, f.ID) + } + return n, nil +} + +// plaudAuthError turns a 401-shaped failure into the one next step. +func plaudAuthError(err error) error { + msg := err.Error() + if strings.Contains(msg, "401") || strings.Contains(strings.ToLower(msg), "not authenticated") || strings.Contains(strings.ToLower(msg), "unauthori") { + return fmt.Errorf("plaud: not signed in: run `npx --loglevel=info -y @plaud-ai/mcp@latest install` (or ask your agent to \"log me into Plaud\") and retry: %w", err) + } + return err +} + +// decodePlaudFiles accepts a JSON array of recordings, or an object wrapping +// one under files/items/data/results/recordings. +func decodePlaudFiles(text string) ([]plaudFile, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, nil + } + var raw any + if err := json.Unmarshal([]byte(text), &raw); err != nil { + if i := strings.IndexAny(text, "[{"); i > 0 { + return decodePlaudFiles(text[i:]) + } + return nil, fmt.Errorf("plaud: list_files returned non-JSON output: %s", firstLine(text)) + } + var items []any + switch v := raw.(type) { + case []any: + items = v + case map[string]any: + for _, key := range []string{"files", "items", "data", "results", "recordings"} { + if arr, ok := v[key].([]any); ok { + items = arr + break + } + } + if items == nil { + if _, ok := v["id"]; ok { + items = []any{v} + } + } + } + out := make([]plaudFile, 0, len(items)) + for _, it := range items { + m, ok := it.(map[string]any) + if !ok { + continue + } + f := plaudFile{ + ID: jsonString(m, "id", "file_id"), + Name: jsonString(m, "name", "title", "filename"), + CreatedAt: jsonTime(m, "created_at", "start_at", "create_time"), + } + if f.ID != "" { + out = append(out, f) + } + } + return out, nil +} + +func jsonString(m map[string]any, keys ...string) string { + for _, k := range keys { + switch v := m[k].(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + return s + } + case float64: + return strconv.FormatInt(int64(v), 10) + } + } + return "" +} + +func jsonTime(m map[string]any, keys ...string) time.Time { + for _, k := range keys { + switch v := m[k].(type) { + case string: + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, v); err == nil { + return t + } + } + case float64: + // Epoch seconds or milliseconds. + if v > 1e12 { + return time.UnixMilli(int64(v)) + } + return time.Unix(int64(v), 0) + } + } + return time.Time{} +} + +// renderPlaudNote returns get_note output as Markdown: plain text passes +// through; JSON is walked for its note_list/markdown/content strings. +func renderPlaudNote(text string) string { + text = strings.TrimSpace(text) + if text == "" || !strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[") { + return text + } + var raw any + if err := json.Unmarshal([]byte(text), &raw); err != nil { + return text + } + var parts []string + var walk func(v any) + walk = func(v any) { + switch t := v.(type) { + case string: + if s := strings.TrimSpace(t); s != "" { + parts = append(parts, s) + } + case []any: + for _, it := range t { + walk(it) + } + case map[string]any: + if s := jsonString(t, "title"); s != "" { + parts = append(parts, "### "+s) + } + for _, key := range []string{"note_list", "notes", "markdown", "content", "summary", "text", "note"} { + if inner, ok := t[key]; ok { + walk(inner) + return + } + } + } + } + walk(raw) + return strings.Join(parts, "\n\n") +} + +// renderPlaudTranscript turns get_transcript output into "[mm:ss] Speaker: +// text" lines when it is JSON; plain text passes through. +func renderPlaudTranscript(text string) string { + text = strings.TrimSpace(text) + if text == "" || !strings.HasPrefix(text, "{") && !strings.HasPrefix(text, "[") { + return text + } + var raw any + if err := json.Unmarshal([]byte(text), &raw); err != nil { + return text + } + var segs []any + switch t := raw.(type) { + case []any: + segs = t + case map[string]any: + for _, key := range []string{"source_list", "segments", "transcript", "items", "data"} { + if arr, ok := t[key].([]any); ok { + segs = arr + break + } + if s, ok := t[key].(string); ok { + return strings.TrimSpace(s) + } + } + } + var b strings.Builder + for _, s := range segs { + m, ok := s.(map[string]any) + if !ok { + continue + } + line := jsonString(m, "text", "content", "sentence") + if line == "" { + continue + } + if ms, ok := m["start_time"].(float64); ok { + sec := int(ms / 1000) + fmt.Fprintf(&b, "[%02d:%02d] ", sec/60, sec%60) + } + if sp := jsonString(m, "speaker", "speaker_name"); sp != "" { + b.WriteString(sp + ": ") + } + b.WriteString(line + "\n") + } + return strings.TrimSpace(b.String()) +} diff --git a/internal/voice/voice.go b/internal/voice/voice.go new file mode 100644 index 00000000..13116952 --- /dev/null +++ b/internal/voice/voice.go @@ -0,0 +1,121 @@ +// Package voice turns a spoken note — a recording on a voice recorder, a +// dictation, a transcript file — into the material a spec starts from. Each +// source fetches one Note; Draft maps the note onto the spec scaffold. +package voice + +import ( + "context" + "fmt" + "io" + "strings" + "time" +) + +// Note is one spoken note as every source returns it. Summary is Markdown +// when the recorder produced an AI summary (a Plaud note); Transcript is the +// spoken text, timestamped or not. Either may be empty, never both. +type Note struct { + Title string + Summary string + Transcript string + // URI is the note's stable address, recorded as the spec's source: + // plaud://recordings/, wisprflow://history/, file://. + URI string + RecordedAt time.Time +} + +// Query selects one note from a source. Latest picks the most recent; Title +// a case-insensitive match on the recording's name; Path a file or folder +// for sources that read disk. +type Query struct { + Latest bool + Title string + Path string +} + +// Source is one voice interface. Fetch returns the single note the query +// selects, or an error naming what was and was not found. +type Source interface { + Name() string + Fetch(ctx context.Context, q Query) (Note, error) +} + +// Options configures the sources that need more than the query. +type Options struct { + // Command is the MCP server command line for MCP-backed sources; empty + // uses the source's default. + Command string + // Stdin is what the stdin source reads. + Stdin io.Reader + // Run executes a command line and returns its stdout; nil uses exec. + // Tests substitute it. + Run Runner + // Dial opens an MCP session for MCP-backed sources; nil starts Command + // as a child process. Tests substitute it. + Dial Dialer +} + +// Runner executes argv and returns stdout. +type Runner func(ctx context.Context, argv ...string) ([]byte, error) + +// NotFoundError says the query matched nothing, listing what was there. +type NotFoundError struct { + Source string + Query Query + Available []string +} + +func (e *NotFoundError) Error() string { + what := "the latest note" + if e.Query.Title != "" { + what = fmt.Sprintf("a note titled %q", e.Query.Title) + } + if len(e.Available) == 0 { + return fmt.Sprintf("%s: no %s found: the source has no notes", e.Source, what) + } + return fmt.Sprintf("%s: no %s found; recent notes: %s", e.Source, what, strings.Join(e.Available, "; ")) +} + +// SourceNames lists the sources Open accepts. +var SourceNames = []string{"file", "plaud", "stdin", "wisprflow"} + +// Open returns the named source. +func Open(name string, opts Options) (Source, error) { + switch name { + case "plaud": + return newPlaud(opts), nil + case "wisprflow": + return newWisprFlow(opts), nil + case "file": + return fileSource{}, nil + case "stdin": + return stdinSource{r: opts.Stdin}, nil + } + return nil, fmt.Errorf("unknown voice source %q; sources: %s", name, strings.Join(SourceNames, ", ")) +} + +// pick chooses one entry by query from a list of candidates. titleOf and +// timeOf read each candidate; the result is its index. +func pick(source string, q Query, n int, titleOf func(int) string, timeOf func(int) time.Time) (int, error) { + if n == 0 { + return -1, &NotFoundError{Source: source, Query: q} + } + best := -1 + want := strings.ToLower(strings.TrimSpace(q.Title)) + for i := 0; i < n; i++ { + if want != "" && !strings.Contains(strings.ToLower(titleOf(i)), want) { + continue + } + if best < 0 || timeOf(i).After(timeOf(best)) { + best = i + } + } + if best < 0 { + avail := make([]string, 0, n) + for i := 0; i < n && i < 10; i++ { + avail = append(avail, titleOf(i)) + } + return -1, &NotFoundError{Source: source, Query: q, Available: avail} + } + return best, nil +} diff --git a/internal/voice/voice_test.go b/internal/voice/voice_test.go new file mode 100644 index 00000000..4c60ebd3 --- /dev/null +++ b/internal/voice/voice_test.go @@ -0,0 +1,249 @@ +package voice + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeMCP answers initialize and tools/call over pipes; tools maps a tool +// name to a handler that returns the text content. +func fakeMCP(t *testing.T, tools map[string]func(args map[string]any) (string, bool)) Dialer { + t.Helper() + return func(ctx context.Context) (MCPClient, error) { + clientIn, serverOut := io.Pipe() + serverIn, clientOut := io.Pipe() + go func() { + defer serverOut.Close() + sc := bufio.NewScanner(serverIn) + sc.Buffer(make([]byte, 64<<10), 8<<20) + for sc.Scan() { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(sc.Bytes(), &req); err != nil || req.ID == nil { + continue + } + var result any + switch req.Method { + case "initialize": + result = map[string]any{"protocolVersion": mcpProtocolVersion, "serverInfo": map[string]any{"name": "fake"}} + case "tools/call": + h, ok := tools[req.Params.Name] + if !ok { + result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "unknown tool " + req.Params.Name}}} + break + } + text, isErr := h(req.Params.Arguments) + result = map[string]any{"isError": isErr, "content": []map[string]any{{"type": "text", "text": text}}} + default: + result = map[string]any{} + } + out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result}) + if _, err := serverOut.Write(append(out, '\n')); err != nil { + return + } + } + }() + return NewMCPSession(ctx, clientIn, clientOut, func() error { + _ = clientOut.Close() + return nil + }) + } +} + +func plaudTools(files string, notes, transcripts map[string]string) map[string]func(map[string]any) (string, bool) { + return map[string]func(map[string]any) (string, bool){ + "list_files": func(map[string]any) (string, bool) { return files, false }, + "get_note": func(a map[string]any) (string, bool) { return notes[a["file_id"].(string)], false }, + "get_transcript": func(a map[string]any) (string, bool) { return transcripts[a["file_id"].(string)], false }, + } +} + +const plaudFiles = `{"files":[ + {"id":"a1","name":"Cluster drift alerts","created_at":"2026-05-02T09:00:00Z"}, + {"id":"b2","name":"Grocery list","created_at":"2026-05-03T09:00:00Z"}]}` + +func TestPlaudFetchesTheLatestRecordingAsNoteAndTranscript(t *testing.T) { + notes := map[string]string{"b2": `{"note_list":[{"title":"Summary","content":"Buy milk."}]}`} + transcripts := map[string]string{"b2": `{"source_list":[{"start_time":1500,"speaker":"Andy","text":"Buy milk."}]}`} + src := newPlaud(Options{Dial: fakeMCP(t, plaudTools(plaudFiles, notes, transcripts))}) + n, err := src.Fetch(context.Background(), Query{Latest: true}) + if err != nil { + t.Fatal(err) + } + if n.Title != "Grocery list" || n.URI != "plaud://recordings/b2" { + t.Fatalf("picked %q %q", n.Title, n.URI) + } + if !strings.Contains(n.Summary, "### Summary") || !strings.Contains(n.Summary, "Buy milk.") { + t.Fatalf("summary: %q", n.Summary) + } + if n.Transcript != "[00:01] Andy: Buy milk." { + t.Fatalf("transcript: %q", n.Transcript) + } +} + +func TestPlaudMatchesATitleCaseInsensitively(t *testing.T) { + notes := map[string]string{"a1": "## Requirements\n- alert on drift"} + src := newPlaud(Options{Dial: fakeMCP(t, plaudTools(plaudFiles, notes, map[string]string{}))}) + n, err := src.Fetch(context.Background(), Query{Title: "cluster DRIFT"}) + if err != nil { + t.Fatal(err) + } + if n.URI != "plaud://recordings/a1" || n.Summary != "## Requirements\n- alert on drift" { + t.Fatalf("got %+v", n) + } +} + +func TestPlaudReportsWhatWasAvailableWhenNothingMatches(t *testing.T) { + src := newPlaud(Options{Dial: fakeMCP(t, plaudTools(plaudFiles, nil, nil))}) + _, err := src.Fetch(context.Background(), Query{Title: "nope"}) + var nf *NotFoundError + if !errors.As(err, &nf) || !strings.Contains(err.Error(), "Cluster drift alerts") { + t.Fatalf("err = %v", err) + } +} + +func TestPlaudPointsAtLoginWhenUnauthenticated(t *testing.T) { + tools := map[string]func(map[string]any) (string, bool){ + "list_files": func(map[string]any) (string, bool) { return "HTTP 401 Unauthorized", true }, + } + src := newPlaud(Options{Dial: fakeMCP(t, tools)}) + _, err := src.Fetch(context.Background(), Query{Latest: true}) + if err == nil || !strings.Contains(err.Error(), "@plaud-ai/mcp@latest install") { + t.Fatalf("err = %v", err) + } +} + +func TestMCPSessionFailsCleanlyWhenTheServerExits(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + r, w := io.Pipe() + _ = w.Close() + _, err := NewMCPSession(ctx, r, io.Discard, nil) + if err == nil { + t.Fatal("expected an error when the server closes before answering") + } +} + +func TestWisprFlowReadsTheHistoryDatabaseThroughSqlite3(t *testing.T) { + db := filepath.Join(t.TempDir(), "flow.sqlite") + if err := os.WriteFile(db, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + var got []string + run := func(_ context.Context, argv ...string) ([]byte, error) { + got = argv + return []byte(`[{"id":"h1","text":"Add drift alerts. The console should page us when a cluster drifts.","timestamp":"2026-05-03 10:00:00.000"}, + {"id":"h0","text":"older","timestamp":"2026-05-01 10:00:00.000"}]`), nil + } + n, err := newWisprFlow(Options{Run: run}).Fetch(context.Background(), Query{Latest: true, Path: db}) + if err != nil { + t.Fatal(err) + } + if got[0] != "sqlite3" || got[1] != "-readonly" || got[3] != db { + t.Fatalf("argv = %q", got) + } + if n.URI != "wisprflow://history/h1" || n.Title != "Add drift alerts" || !strings.HasPrefix(n.Transcript, "Add drift alerts.") { + t.Fatalf("got %+v", n) + } +} + +func TestFileSourceReadsMarkdownAsSummaryAndSrtAsTranscript(t *testing.T) { + dir := t.TempDir() + md := filepath.Join(dir, "drift.md") + if err := os.WriteFile(md, []byte("# Cluster drift\n\n## Requirements\n- page on drift\n"), 0o644); err != nil { + t.Fatal(err) + } + srt := filepath.Join(dir, "memo.srt") + if err := os.WriteFile(srt, []byte("1\n00:00:00,000 --> 00:00:02,000\nWe need drift alerts.\n\n2\n00:00:02,000 --> 00:00:04,000\nPage the on-call.\n"), 0o644); err != nil { + t.Fatal(err) + } + n, err := fileSource{}.Fetch(context.Background(), Query{Path: md}) + if err != nil { + t.Fatal(err) + } + if n.Title != "Cluster drift" || !strings.Contains(n.Summary, "## Requirements") || n.Transcript != "" || !strings.HasPrefix(n.URI, "file:///") { + t.Fatalf("md: %+v", n) + } + n, err = fileSource{}.Fetch(context.Background(), Query{Path: srt}) + if err != nil { + t.Fatal(err) + } + if n.Transcript != "We need drift alerts. Page the on-call." || n.Title != "memo" { + t.Fatalf("srt: %+v", n) + } +} + +func TestStdinSourceTakesPastedText(t *testing.T) { + n, err := stdinSource{r: strings.NewReader("Build me a drift alert for every cluster, please.\n")}.Fetch(context.Background(), Query{}) + if err != nil { + t.Fatal(err) + } + if n.URI != "stdin://" || n.Title != "Build me a drift alert for every cluster" || n.Transcript == "" { + t.Fatalf("got %+v", n) + } +} + +func TestDraftMapsSummaryHeadingsOntoTheScaffold(t *testing.T) { + n := Note{Title: "Cluster drift alerts", Summary: strings.Join([]string{ + "## Summary", "Page the on-call when a cluster drifts from its desired state.", + "## Requirements", "- Detect drift: compare live to desired every minute", "- Page on-call via the existing alert route", + "## Action items", "- [ ] Detect drift: a drifted cluster shows in the alerts widget within 60s", "- Page on-call: an alert fires once per cluster", + "## Out of scope", "- auto-remediation", + "## Open questions", "- which alert route?", + }, "\n")} + d := DraftNote(n) + if len(d.Missing) != 0 { + t.Fatalf("missing %v", d.Missing) + } + for _, want := range []string{ + "# Feature: Cluster drift alerts", + "## Overview\n\nPage the on-call", + "## Requirements\n\n- [ ] **Detect drift**: compare live to desired every minute\n- [ ] **Page on-call via the existing alert route**", + "## Acceptance Criteria\n\n- [ ] **Detect drift**: a drifted cluster shows", + "- [ ] **Page on-call**: an alert fires once per cluster", + "## Non-Goals\n\n- auto-remediation", + "## Open Questions\n\n- which alert route?", + } { + if !strings.Contains(d.Body, want) { + t.Fatalf("body missing %q:\n%s", want, d.Body) + } + } + for _, sec := range Sections { + if !strings.Contains(d.Body, "## "+sec+"\n") { + t.Fatalf("scaffold section %q absent", sec) + } + } +} + +func TestDraftFromABareTranscriptNamesWhatIsMissing(t *testing.T) { + d := DraftNote(Note{Title: "Memo", Transcript: "We should alert on cluster drift."}) + if strings.Join(d.Missing, ",") != "Requirements,Acceptance Criteria" { + t.Fatalf("missing = %v", d.Missing) + } + if !strings.Contains(d.Body, "## Overview\n\nWe should alert on cluster drift.") { + t.Fatalf("body:\n%s", d.Body) + } +} + +func TestSlug(t *testing.T) { + if got := Slug("Cluster Drift Alerts!"); got != "cluster-drift-alerts" { + t.Fatalf("got %q", got) + } + if got := Slug("???"); got != "voice-note" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/voice/wisprflow.go b/internal/voice/wisprflow.go new file mode 100644 index 00000000..09769991 --- /dev/null +++ b/internal/voice/wisprflow.go @@ -0,0 +1,118 @@ +package voice + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// wisprFlowHistorySQL reads recent dictations from Wispr Flow's local +// history; the best available text wins (edited > formatted > raw ASR). +const wisprFlowHistorySQL = `SELECT transcriptEntityId AS id, ` + + `COALESCE(NULLIF(editedText,''), NULLIF(formattedText,''), asrText) AS text, ` + + `timestamp FROM History WHERE status='formatted' AND isArchived=0 ` + + `ORDER BY timestamp DESC LIMIT 50` + +// wisprFlowSource reads Wispr Flow dictations. Path may be the app's +// flow.sqlite (the default) or a folder of exported .md/.txt notes. +type wisprFlowSource struct { + run Runner +} + +func newWisprFlow(opts Options) *wisprFlowSource { + run := opts.Run + if run == nil { + run = execRunner + } + return &wisprFlowSource{run: run} +} + +func execRunner(ctx context.Context, argv ...string) ([]byte, error) { + out, err := exec.CommandContext(ctx, argv[0], argv[1:]...).Output() + if err != nil { + var ee *exec.ExitError + if ok := asExitError(err, &ee); ok && len(ee.Stderr) > 0 { + return nil, fmt.Errorf("%s: %s", argv[0], firstLine(string(ee.Stderr))) + } + return nil, fmt.Errorf("%s: %w", argv[0], err) + } + return out, nil +} + +func asExitError(err error, target **exec.ExitError) bool { + ee, ok := err.(*exec.ExitError) + if ok { + *target = ee + } + return ok +} + +// DefaultWisprFlowPath is where the macOS app keeps its history. +func DefaultWisprFlowPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, "Library", "Application Support", "Wispr Flow", "flow.sqlite") +} + +func (w *wisprFlowSource) Name() string { return "wisprflow" } + +func (w *wisprFlowSource) Fetch(ctx context.Context, q Query) (Note, error) { + path := q.Path + if path == "" { + path = DefaultWisprFlowPath() + } + info, err := os.Stat(path) + if err != nil { + return Note{}, fmt.Errorf("wisprflow: %w (is Wispr Flow installed? pass --path to a flow.sqlite or an export folder)", err) + } + if info.IsDir() { + return fetchFromFolder("wisprflow", path, q) + } + out, err := w.run(ctx, "sqlite3", "-readonly", "-json", path, wisprFlowHistorySQL) + if err != nil { + return Note{}, fmt.Errorf("wisprflow: reading history: %w", err) + } + var rows []struct { + ID string `json:"id"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` + } + if strings.TrimSpace(string(out)) != "" { + if err := json.Unmarshal(out, &rows); err != nil { + return Note{}, fmt.Errorf("wisprflow: decoding history: %w", err) + } + } + times := make([]time.Time, len(rows)) + for i, r := range rows { + times[i] = parseLooseTime(r.Timestamp) + } + i, err := pick("wisprflow", q, len(rows), + func(i int) string { return firstLine(rows[i].Text) }, + func(i int) time.Time { return times[i] }) + if err != nil { + return Note{}, err + } + r := rows[i] + return Note{ + Title: titleFromText(r.Text), + Transcript: strings.TrimSpace(r.Text), + URI: "wisprflow://history/" + r.ID, + RecordedAt: times[i], + }, nil +} + +func parseLooseTime(s string) time.Time { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.000", "2006-01-02 15:04:05", "2006-01-02T15:04:05.000Z"} { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Time{} +} diff --git a/templates/seeding_test.go b/templates/seeding_test.go index 948043c2..8c13038b 100644 --- a/templates/seeding_test.go +++ b/templates/seeding_test.go @@ -291,3 +291,50 @@ func TestSpekNewIncludesBothSplitPartials(t *testing.T) { "only if they agree, add `\"confirm_completed_epic\": true` to the staged description", ) } + +// Criterion: a voice recording (a Plaud note, a meeting recording) is +// recognised as source material however the request is phrased; the agent +// fetches both its summary and its transcript, records the recorder's own URI +// as provenance, and the interview seeds sections from the summary while the +// transcript's exact phrasing goes to the working context, never into a +// section as if it were a settled requirement. +func TestSpekNewStartsFromAVoiceRecording(t *testing.T) { + skill := installedSkill(t) + + require.Contains(t, section(t, skill, "---"), "a voice recording's transcript and summary", + "the skill description must name voice recordings so the agent routes the request here") + + material := flat(section(t, skill, "## Starting from existing material")) + requirePhrases(t, "Starting from existing material", material, + `or a voice recording ("my latest Plaud recording", "the note titled …", "Tuesday's meeting")`, + "or a voice recording held by a recorder service (Plaud, Otter, a voice-memo app) whose MCP server or export you can read", + `The same goes for "my latest recording" when several recordings are from the same day.`, + "For a voice recording, fetch **both** the AI summary (the note) and the full transcript", + "`plaud://recordings/` for a Plaud note, or the path of the exported transcript file", + "a recording has no child items unless its summary is an explicit list of separate pieces of work", + ) + + raw := readTemplate(t, interviewStep) + open, closeAt := strings.Index(raw, "{{#seeded}}"), strings.Index(raw, "{{/seeded}}") + require.True(t, open >= 0 && open < closeAt) + requirePhrases(t, "the interview's seeded branch", flat(raw[open:closeAt]), + "for a voice recording, the summary's headings map as above and its action items → `acceptance_criteria.md`", + "the transcript is not a section: write its decisions and the user's exact phrasing for key requirements into `.spektacular/working-context.md`", + `mark anything the speaker said tentatively ("maybe", "we could") as a gap to ask about rather than a requirement`, + ) + + // `voice` stages the recording before the workflow starts; the interview + // must read the staged folder rather than fetch again, and the skill must + // offer the shortcut. + requirePhrases(t, "the interview's material branch", flat(raw[open:closeAt]), + "{{#material}}", "{{^material}}", + "**Read the material already fetched to `{{material}}`** instead of fetching the source again", + "`note.md` is the recorder's summary, `transcript.md` the spoken words, `draft.md` a first mapping onto the spec scaffold", + ) + requirePhrases(t, "Starting from existing material", material, + "`"+installedCommand+" voice --from --latest`", + `starts `+"`spec new`"+` with the provenance and `+"`\"material\"`"+` set, so step 2 above is already done`, + `**A transcript handed to you as text**`, + "if the task says to finish without asking, draft every section from the transcript", + ) +} diff --git a/templates/skills/workflows/spek-new/SKILL.md b/templates/skills/workflows/spek-new/SKILL.md index ef16cbce..4b15b9a6 100644 --- a/templates/skills/workflows/spek-new/SKILL.md +++ b/templates/skills/workflows/spek-new/SKILL.md @@ -1,6 +1,6 @@ --- name: spek-new -description: Create a new Specification for a feature, from a conversation or from existing material such as an issue, a tracker epic, a design document, a file, a web page or pasted text ("spec from #45", "start a spec from LIN-123", a pasted link), or split an already-written spec into an epic. +description: Create a new Specification for a feature, from a conversation or from existing material such as an issue, a tracker epic, a design document, a file, a web page, pasted text or a voice recording's transcript and summary ("spec from #45", "start a spec from LIN-123", a pasted link, "spec from my latest Plaud recording"), or split an already-written spec into an epic. --- {{> partials/version-check}} @@ -146,10 +146,10 @@ Never choose for the user, and never guess from context which they would want ## Starting from existing material -A spec can start from material the team has already written. Recognise a source however the request is phrased: "spec from issue 45", "#45", "start from LIN-123", a pasted link to any tracker, a design document's name, a file path, or pasted text the user says to start from. Being understood must not depend on careful wording. +A spec can start from material the team has already written. Recognise a source however the request is phrased: "spec from issue 45", "#45", "start from LIN-123", a pasted link to any tracker, a design document's name, a file path, pasted text the user says to start from, or a voice recording ("my latest Plaud recording", "the note titled …", "Tuesday's meeting"). Being understood must not depend on careful wording. -1. **Identify the source.** An issue or epic from any tracker, a design document, a file, a web page, or pasted text. If it is ambiguous, such as a bare issue number with no tracker or repository, ask the user rather than guess. -2. **Fetch it with your own tools.** Spektacular prescribes no tool and no tracker: a CLI, an MCP server, a web fetch or a file read are all fine. Whatever the tool, collect the title and body, the discussion (comments, since decisions are often made there), its child items (sub-issues, linked children, task-list entries), and a stable link to record as provenance. For pasted text, the user's message is the content; record a link only if one exists. +1. **Identify the source.** An issue or epic from any tracker, a design document, a file, a web page, pasted text, or a voice recording held by a recorder service (Plaud, Otter, a voice-memo app) whose MCP server or export you can read. If it is ambiguous, such as a bare issue number with no tracker or repository, ask the user rather than guess. The same goes for "my latest recording" when several recordings are from the same day. +2. **Fetch it with your own tools.** Spektacular prescribes no tool and no tracker: a CLI, an MCP server, a web fetch or a file read are all fine. Whatever the tool, collect the title and body, the discussion (comments, since decisions are often made there), its child items (sub-issues, linked children, task-list entries), and a stable link to record as provenance. For pasted text, the user's message is the content; record a link only if one exists. For a voice recording, fetch **both** the AI summary (the note) and the full transcript: the summary carries the shape, the transcript carries the user's exact phrasing and the decisions the summary dropped. Its provenance is the recorder's own URI for the recording, `plaud://recordings/` for a Plaud note, or the path of the exported transcript file; a recording has no child items unless its summary is an explicit list of separate pieces of work. 3. **If you cannot reach it,** because no tool you have can read it or fetching fails, say so plainly and ask the user to paste the content. Never fall back to a blank interview without saying so. 4. **Check for child items.** A source that already has child items is a set of pieces of work, not one spec. Offer to set up an epic for it instead, with one spec per child (see "A source with child items" below). On decline, continue with a single spec. 5. **Propose a name** from the source's title, and let the user confirm or change it. @@ -157,6 +157,10 @@ A spec can start from material the team has already written. Recognise a source A design document the spec must be built to is also recorded as a normal design reference; `sources` records only where content came from. +**A transcript handed to you as text** ("start a spec from this transcript: …", a transcript file in the task, or one piped to you by an automated runner) needs no recorder and no interview partner: treat the text as the fetched material, record its provenance as the file path or the recording URI the task names (`stdin://` when there is none), and if the task says to finish without asking, draft every section from the transcript, mark anything tentative as an open question rather than a requirement, and continue through the steps without waiting for answers. + +**A shortcut for voice notes.** `{{command}} voice --from --latest` (or `--title "…"`, `--path `) fetches the recording itself, stages its summary, transcript and a first draft under `.spektacular/tmp/voice//`, and starts `spec new` with the provenance and `"material"` set, so step 2 above is already done: read the staged files and continue with the interview. Prefer it when the CLI can reach the recorder (Plaud's MCP is signed in; Wispr Flow is installed); fetch with your own tools otherwise. + ### A source with child items When the user accepts an epic for a source with child items: diff --git a/templates/steps/spec/00b-interview.md b/templates/steps/spec/00b-interview.md index 4cc122bf..60b26c93 100644 --- a/templates/steps/spec/00b-interview.md +++ b/templates/steps/spec/00b-interview.md @@ -15,12 +15,18 @@ - `{{{.}}}` {{/sources}} +{{#material}} +1. **Read the material already fetched to `{{material}}`** instead of fetching the source again: `note.md` is the recorder's summary, `transcript.md` the spoken words, `draft.md` a first mapping onto the spec scaffold. Trust `draft.md` only as a starting point; the transcript is what the user actually said. +{{/material}} +{{^material}} 1. **Fetch each source again with your own tools** (Spektacular prescribes none) and collect its title, body, discussion and child items. If a source cannot be reached, say so and ask the user to paste its content; never fall back to a blank interview without saying so. +{{/material}} 2. **Seed the section working files** under `.spektacular/work/{{spec_name}}/` with your own `Write` tool, mapping the material onto the spec: - title and body → `overview.md` and `requirements.md`; - checklist or task-list items → `acceptance_criteria.md`; - "must" / "must not" remarks → `constraints.md`; - - "out of scope" remarks → `non_goals.md`. + - "out of scope" remarks → `non_goals.md`; + - for a voice recording, the summary's headings map as above and its action items → `acceptance_criteria.md`; the transcript is not a section: write its decisions and the user's exact phrasing for key requirements into `.spektacular/working-context.md`, and mark anything the speaker said tentatively ("maybe", "we could") as a gap to ask about rather than a requirement. Write each file in its section's required shape (checklist items for requirements and acceptance criteria, one bullet per item otherwise). Leave a section's file absent when the material says nothing about it. 3. **List the gaps to the user:** every section the material does not cover, or covers too thinly to build from. A thin source simply leaves more gaps.