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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ spektacular publish <name> --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, `<!-- spek:publish name=<name> artifact_id=<id> target=github -->` on the RFC and the same with `task=<child-id> rfc=<url>` 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<hash>` 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 `<placeholder>`, 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/<name>/` (or `.spektacular/work/<name>/voice/` once published), the recording's URI (`plaud://recordings/<id>`, `wisprflow://history/<id>`, `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:
Expand Down
20 changes: 13 additions & 7 deletions cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions cmd/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
299 changes: 299 additions & 0 deletions cmd/voice.go
Original file line number Diff line number Diff line change
@@ -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 <part of the recording's name>")
}

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 <file>")
}

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 <other 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)
}
Loading
Loading