From f239ae2cfc2b47f7204245ce32684abde4bb30d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 08:40:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli,mcp):=20orun=20initiatives=20=E2=80=94?= =?UTF-8?q?=20the=20group=20replaces=20work;=20the=20work=20MCP=20grows=20?= =?UTF-8?q?to=2021=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orun half of the orun-initiatives epic (authoritative spec: orun-cloud specs/epics/orun-initiatives/; this repo's leg in specs/orun-initiatives/). CLI: 'orun initiatives' lands with list (portfolio table), view (the tree as an indented ladder: intent chips with @revision + drift, milestone states, task rungs with evidence hints), create (--why repeatable, becomes the success checklist), edit/cancel/import (ported from work), task view/create, activity (the tagged tail), doc pull, and design list/propose. 'orun work' survives one release hidden and deprecated, forwarding to the same constructors — zero duplicated logic; spec pull and epic pull are byte-identical. Wire: remotestate gains ListInitiatives, GetInitiativeTree, GetTaskDetail, GetWorkActivity, CreateInitiative, ListWorkDesigns, UpsertMilestones plus the mirrored portfolio/tree/detail/activity types; WorkSummary finally parses the summary's initiatives array. MCP: the work roster grows 15 → 21 (initiatives_list, initiative_tree, task_get, activity_get; initiative_create, milestone_upsert) with the existing names untouched, and a pinned test proves a human_only refusal passes through typed and verbatim — agents can tell 'not allowed for you' from 'does not exist'. Docs: website cli/orun-initiatives.md replaces orun-work.md (redirect stub kept), sidebar + related links updated, orun-mcp.md counts redone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014mwXwkdPScFHzdJY1Zn5d7 --- cmd/orun/commands_root.go | 1 + cmd/orun/initiatives.go | 1080 ++++++++++++++++++++++++++ cmd/orun/initiatives_test.go | 233 ++++++ cmd/orun/mcp_serve_test.go | 6 +- cmd/orun/work.go | 385 +-------- internal/platformmcp/prompts_test.go | 6 +- internal/platformmcp/server_test.go | 43 +- internal/remotestate/work.go | 388 ++++++++- internal/workmcp/server.go | 143 +++- internal/workmcp/server_test.go | 210 ++++- specs/orun-initiatives/README.md | 98 +++ website/docs/cli/orun-epic.md | 2 +- website/docs/cli/orun-initiatives.md | 184 +++++ website/docs/cli/orun-mcp.md | 29 +- website/docs/cli/orun-spec.md | 2 +- website/docs/cli/orun-work.md | 85 +- website/sidebars.js | 2 +- 17 files changed, 2414 insertions(+), 483 deletions(-) create mode 100644 cmd/orun/initiatives.go create mode 100644 cmd/orun/initiatives_test.go create mode 100644 specs/orun-initiatives/README.md create mode 100644 website/docs/cli/orun-initiatives.md diff --git a/cmd/orun/commands_root.go b/cmd/orun/commands_root.go index 41200e42..948e71a6 100644 --- a/cmd/orun/commands_root.go +++ b/cmd/orun/commands_root.go @@ -287,6 +287,7 @@ func init() { registerSecretsCommand(rootCmd) registerIntegrationsCommand(rootCmd) registerPolicyCommand(rootCmd) + registerInitiativesCommand(rootCmd) registerWorkCommand(rootCmd) registerSpecCommand(rootCmd) registerEpicCommand(rootCmd) diff --git a/cmd/orun/initiatives.go b/cmd/orun/initiatives.go new file mode 100644 index 00000000..03614a82 --- /dev/null +++ b/cmd/orun/initiatives.go @@ -0,0 +1,1080 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/sourceplane/orun/internal/remotestate" + "github.com/sourceplane/orun/internal/worklens" +) + +// orun initiatives (orun-initiatives IN6) — the work plane's CLI group, +// replacing `orun work` (which survives one release as a hidden deprecated +// alias forwarding to the same run functions; see work.go). Lifecycle stays +// a derived query over the two logs: nothing in this group can set a status. + +func registerInitiativesCommand(root *cobra.Command) { + cmd := &cobra.Command{ + Use: "initiatives", + Short: "The work plane: portfolio, trees, tasks, activity, designs", + Long: `The work plane (specs/orun-initiatives; substrate specs/orun-work). + +Work lifecycle is a derived query over two append-only logs, never a stored +status. This group is the terminal face of the Initiatives surface: the +portfolio, one initiative's tree, task detail with evidence, the tagged +activity tail, and the design/import/doc plumbing. + +Subcommands: + list Portfolio: key, title, status, progress, needs-you, target + view One initiative's tree as an indented ladder + create Create an initiative envelope (--title, --why …) + edit Edit an item's envelope (title/description/owner/target/…) + cancel Retire an item (task or epic) — the append-only "delete" + import Map a specs/ tree to the hierarchy and apply to Orun Cloud + task Task detail (view) and creation (create) + activity The tagged activity tail for any noun + doc Pull an epic spec / design doc as markdown + design List or propose design runs on an initiative + +Run 'orun initiatives --help' for details.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newInitiativesListCommand()) + cmd.AddCommand(newInitiativesViewCommand()) + cmd.AddCommand(newInitiativesCreateCommand()) + cmd.AddCommand(newItemEditCommand()) + cmd.AddCommand(newItemCancelCommand()) + cmd.AddCommand(newWorkImportCommand()) + cmd.AddCommand(newInitiativesTaskCommand()) + cmd.AddCommand(newInitiativesActivityCommand()) + cmd.AddCommand(newInitiativesDocCommand()) + cmd.AddCommand(newInitiativesDesignCommand()) + root.AddCommand(cmd) +} + +// workClient resolves scope + auth and builds the cloud client — the same +// preamble as catalog push (flag > env > intent > cached link). +func workClient(ctx context.Context, backendURLFlag, orgFlag string) (*remotestate.Client, error) { + backendURL, err := requireBackendURL(nil, backendURLFlag) + if err != nil { + return nil, err + } + repo, err := resolveRepoContext(backendURL) + if err != nil { + return nil, err + } + linkOrg, linkProject := "", "" + if repo != nil { + linkOrg, linkProject = repo.OrgID, repo.ProjectID + } + intentOrg, intentProject, _ := intentScope(loadIntentForCloudConfig()) + scope := resolveScope(orgFlag, "", intentOrg, intentProject, linkOrg, linkProject) + if scope.OrgID == "" { + return nil, fmt.Errorf("orun work: no workspace resolved; pass --workspace or link the repo (orun auth login)") + } + tokenSrc, _, _, err := remotestate.ResolveTokenSource(ctx, remotestate.ResolveOptions{ + BackendURL: backendURL, + Version: version, + Interactive: termIsInteractive(), + RequireLogin: true, + Org: scope.OrgID, + }) + if err != nil { + if isNoLoginErr(err) { + return nil, errNotLoggedIn() + } + return nil, fmt.Errorf("remote state auth: %w", err) + } + return remotestate.NewClientWithScope(backendURL, version, tokenSrc, scope), nil +} + +// addWorkScopeFlags registers the three flags every subcommand of the group +// carries: --workspace, --backend-url, --json. +func addWorkScopeFlags(cmd *cobra.Command, workspace, backendURL *string, asJSON *bool) { + cmd.Flags().StringVar(workspace, "workspace", "", "target workspace (org id or slug; defaults to the linked repo's)") + cmd.Flags().StringVar(backendURL, "backend-url", "", "Backend URL (Orun Cloud or self-hosted)") + cmd.Flags().BoolVar(asJSON, "json", false, "emit JSON") +} + +// encodeJSON is the group's one JSON idiom: pretty-encoded response structs. +func encodeJSON(cmd *cobra.Command, v interface{}) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// ── list ───────────────────────────────────────────────────────────────────── + +func newInitiativesListCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "list", + Short: "The portfolio: every initiative with derived status, progress, needs-you", + Long: `Fetch the portfolio fold from Orun Cloud: one row per initiative with its +derived status (planning until the first approved epic, the health fold +afterwards), two-segment progress, and the needs-you reasons that wait on a +human. Nothing shown here is a stored status.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + portfolio, err := client.ListInitiatives(cmd.Context()) + if err != nil { + return fmt.Errorf("orun initiatives list: %w", err) + } + if asJSON { + return encodeJSON(cmd, portfolio) + } + fmt.Fprint(cmd.OutOrStdout(), renderPortfolio(portfolio)) + return nil + }, + } + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// renderPortfolio renders the portfolio table plus the fold-stats footer. +func renderPortfolio(p *remotestate.WorkPortfolio) string { + rows := make([][]string, 0, len(p.Initiatives)) + for _, ini := range p.Initiatives { + needs := "-" + if len(ini.NeedsYou) > 0 { + needs = ini.NeedsYou[0].Text + if extra := len(ini.NeedsYou) - 1; extra > 0 { + needs += fmt.Sprintf(" (+%d)", extra) + } + } + target := orDash(ini.TargetDate) + rows = append(rows, []string{ + ini.Key, + ini.Title, + ini.Status, + fmt.Sprintf("%d/%d", ini.Progress.Done, ini.Progress.Total), + needs, + target, + }) + } + out := renderColumns([]string{"KEY", "TITLE", "STATUS", "PROGRESS", "NEEDS YOU", "TARGET"}, rows) + out += fmt.Sprintf("\nopen tasks: %d · needs you: %d", p.Stats.OpenTasks, p.Stats.NeedsYou) + if p.Stats.AgentsLive > 0 { + out += fmt.Sprintf(" · agents live: %d", p.Stats.AgentsLive) + } + return out + "\n" +} + +// ── view ───────────────────────────────────────────────────────────────────── + +func newInitiativesViewCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "view ", + Short: "One initiative's tree: epics, milestones, tasks with evidence", + Long: `Fetch one initiative's full hierarchy and render it as an indented +ladder: the initiative header (status, owner, target, progress), each epic +with its intent state and approved revision (drift named when present), each +milestone with derived state and progress, and each task with its rung and +the evidence that put it there.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + tree, err := client.GetInitiativeTree(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("orun initiatives view: %w", err) + } + if asJSON { + return encodeJSON(cmd, tree) + } + fmt.Fprint(cmd.OutOrStdout(), renderInitiativeTree(tree)) + return nil + }, + } + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// renderInitiativeTree renders the tree as an indented ladder, modeled on +// the plain evidence-bearing style of the old `orun work list`. +func renderInitiativeTree(t *remotestate.WorkInitiativeTree) string { + var b strings.Builder + ini := t.Initiative + fmt.Fprintf(&b, "%s — %s [%s]", ini.Key, ini.Title, ini.Status) + if ini.Owner != "" { + fmt.Fprintf(&b, " owner %s", ini.Owner) + } + if ini.TargetDate != "" { + fmt.Fprintf(&b, " target %s", ini.TargetDate) + } + fmt.Fprintf(&b, " %d/%d done", ini.ProgressView.Done, ini.ProgressView.Total) + if ini.ProgressView.Active > 0 { + fmt.Fprintf(&b, ", %d active", ini.ProgressView.Active) + } + b.WriteString("\n") + for _, r := range ini.NeedsYou { + fmt.Fprintf(&b, " needs you: %s\n", r.Text) + } + for _, e := range t.Epics { + fmt.Fprintf(&b, "\n %s — %s %s %d/%d\n", e.Key, e.Title, intentChip(e.Intent), e.Progress.Done, e.Progress.Total) + for _, m := range e.Milestones { + fmt.Fprintf(&b, " %s — %s [%s] %d/%d\n", m.Key, m.Title, m.State, m.Progress.Done, m.Progress.Total) + for _, task := range m.Tasks { + b.WriteString(renderTreeTask(task)) + } + } + if len(e.Backlog) > 0 { + b.WriteString(" backlog:\n") + for _, task := range e.Backlog { + b.WriteString(renderTreeTask(task)) + } + } + for _, d := range e.Docs { + rev := "" + if d.Revision != "" { + rev = " @" + shortRevision(d.Revision) + } + threads := "" + if d.Threads != nil && d.Threads.Open > 0 { + threads = fmt.Sprintf(" %d open thread(s)", d.Threads.Open) + } + fmt.Fprintf(&b, " doc %s %s%s [%s] — %s%s\n", d.Kind, d.Subject, rev, d.State, d.Title, threads) + } + } + if len(t.Designs) > 0 { + b.WriteString("\n designs:\n") + for _, d := range t.Designs { + fmt.Fprintf(&b, " %s — %s [%s]\n", d.Key, d.Title, designStateOf(d)) + } + } + return b.String() +} + +func renderTreeTask(t remotestate.WorkTreeTaskRow) string { + line := fmt.Sprintf(" %-10s %-12s %s", t.Key, t.Rung, t.Title) + if hint := evidenceHint(t.Evidence); hint != "" { + line += " (" + hint + ")" + } + return line + "\n" +} + +// intentChip renders the intent ladder chip: state, the approved revision +// (approval never renders without it — V4-2), and drift when present. +func intentChip(i remotestate.WorkEpicIntentView) string { + chip := "intent " + i.State + if i.Approval != nil && i.Approval.Revision != "" { + chip += " @" + shortRevision(i.Approval.Revision) + } + if i.DocDrifted || i.LadderDrifted { + chip += " (drifted)" + } + return chip +} + +// shortRevision shortens a sha256: doc digest to its familiar 8-hex form. +func shortRevision(rev string) string { + r := strings.TrimPrefix(rev, "sha256:") + if len(r) > 8 { + r = r[:8] + } + return r +} + +// evidenceHint folds the evidence view into the short parenthetical hint the +// tree and task views print. Empty when the logs are silent — never invented. +func evidenceHint(ev remotestate.WorkTaskEvidenceView) string { + var parts []string + if ev.Branch != nil { + parts = append(parts, "branch "+ev.Branch.Name) + } + if ev.PR != nil { + pr := "PR #" + ev.PR.Number + if ev.PR.Merged { + pr += " merged" + } else { + pr += " open" + } + if ev.PR.ChecksTotal > 0 { + pr += fmt.Sprintf(", checks %d/%d", ev.PR.ChecksPassed, ev.PR.ChecksTotal) + } + parts = append(parts, pr) + } else if ev.Checks != nil { + parts = append(parts, fmt.Sprintf("checks %d/%d", ev.Checks.Passed, ev.Checks.Total)) + } + return strings.Join(parts, "; ") +} + +// designStateOf extracts the folded intent state from a design view. +func designStateOf(d remotestate.WorkDesignView) string { + var intent struct { + State string `json:"state"` + } + if len(d.Intent) > 0 && json.Unmarshal(d.Intent, &intent) == nil && intent.State != "" { + return intent.State + } + return "draft" +} + +// ── create ─────────────────────────────────────────────────────────────────── + +func newInitiativesCreateCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + title string + why []string + description string + owner string + target string + slug string + ) + cmd := &cobra.Command{ + Use: "create", + Short: "Create an initiative envelope (--title, --why …)", + Long: `Create an initiative: the strategic envelope grouping epics. Pure intent — +an initiative has no lifecycle and no contract; its status, progress, and +health derive from its member epics' logs. + +--why is repeatable: each occurrence becomes one success criterion.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(title) == "" { + return fmt.Errorf("orun initiatives create: --title is required") + } + if slug == "" { + slug = slugFromTitle(title) + } + if slug == "" { + return fmt.Errorf("orun initiatives create: cannot derive a slug from %q; pass --slug", title) + } + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.CreateInitiative(cmd.Context(), remotestate.CreateWorkInitiativeRequest{ + Slug: slug, + Title: title, + Description: description, + Owner: owner, + TargetDate: target, + SuccessCriteria: why, + }) + if err != nil { + return fmt.Errorf("orun initiatives create: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "created %s (seq %d)\n", resp.Key, resp.Seq) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "initiative title (required)") + cmd.Flags().StringArrayVar(&why, "why", nil, "success criterion (repeatable; the human-edited why)") + cmd.Flags().StringVar(&description, "description", "", "initiative description") + cmd.Flags().StringVar(&owner, "owner", "", "owner subject") + cmd.Flags().StringVar(&target, "target", "", "target date YYYY-MM-DD") + cmd.Flags().StringVar(&slug, "slug", "", "initiative slug (default: derived from --title)") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// slugFromTitle derives a lowercase hyphenated slug from a title. +func slugFromTitle(title string) string { + var b strings.Builder + lastHyphen := true + for _, r := range strings.ToLower(strings.TrimSpace(title)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastHyphen = false + default: + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + } + return strings.TrimRight(b.String(), "-") +} + +// ── edit / cancel (ported from the work group, shared with the alias) ──────── + +func newItemEditCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + title string + description string + owner string + target string + initiative string + criteria []string + ) + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit an item's envelope (title/description/owner/target/…)", + Long: `Edit an item's envelope through the one mutator (item_edited): title, +plus the fields the item's kind exposes — description, owner, target date, and +success criteria for an initiative; target date and initiative filing for an +epic; title for a task. + +This is intent only. Nothing here can move a rung — lifecycle stays derived +from observations (WP-3). Only the flags you pass are sent; pass an empty +value (e.g. --owner "") to clear a field.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + req := remotestate.EditWorkItemRequest{} + if f.Changed("title") { + req.Title = &title + } + if f.Changed("description") { + req.Description = &description + } + if f.Changed("owner") { + req.Owner = &owner + } + if f.Changed("target") { + req.TargetDate = &target + } + if f.Changed("initiative") { + req.Initiative = &initiative + } + if f.Changed("criteria") { + req.SuccessCriteria = criteria + } + if req.Title == nil && req.Description == nil && req.Owner == nil && + req.TargetDate == nil && req.Initiative == nil && req.SuccessCriteria == nil { + return fmt.Errorf("orun initiatives edit: nothing to change; pass at least one of --title/--description/--owner/--target/--initiative/--criteria") + } + + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.EditWorkItem(cmd.Context(), args[0], req) + if err != nil { + return fmt.Errorf("orun initiatives edit: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "edited %s (seq %d)\n", resp.Key, resp.Seq) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "new title") + cmd.Flags().StringVar(&description, "description", "", "new description (initiatives)") + cmd.Flags().StringVar(&owner, "owner", "", "owner subject; \"\" clears (initiatives)") + cmd.Flags().StringVar(&target, "target", "", "target date YYYY-MM-DD; \"\" clears") + cmd.Flags().StringVar(&initiative, "initiative", "", "file an epic under this initiative key; \"\" unfiles") + cmd.Flags().StringArrayVar(&criteria, "criteria", nil, "success criterion (repeatable; initiatives)") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +func newItemCancelCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + yes bool + ) + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Retire an item (task or epic) — the append-only \"delete\"", + Long: `Retire a task or epic by folding a terminal 'canceled' state onto it. +Cancel is the model's native "delete": a terminal, attributed, append-only +state — the record and its whole history stay, and agents stop picking up its +work. It is effectively permanent (there is no un-cancel). + +Initiatives have no lifecycle to cancel — edit their envelope, or retire their +epics, instead.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + if !yes && termIsInteractive() { + fmt.Fprintf(cmd.OutOrStdout(), "Retire %s? This is terminal and cannot be un-canceled. Re-run with --yes to confirm.\n", key) + return fmt.Errorf("orun initiatives cancel: confirmation required (pass --yes)") + } + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.CancelWorkItem(cmd.Context(), key) + if err != nil { + return fmt.Errorf("orun initiatives cancel: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "retired %s — canceled (seq %d)\n", resp.Key, resp.Seq) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// ── import (the v2 importer, unchanged wire) ───────────────────────────────── + +func toWireContract(c *worklens.Contract) *remotestate.WorkContract { + if c == nil { + return nil + } + return &remotestate.WorkContract{ + Goal: c.Goal, + Affects: c.Affects, + DoneWhen: c.DoneWhen, + Gates: c.Gates, + DesignRefs: c.DesignRefs, + Deps: c.Deps, + GatesDefined: c.GatesDefined, + } +} + +func toWirePlan(plan *worklens.ImportPlan, prefix string) remotestate.WorkImportRequest { + req := remotestate.WorkImportRequest{ + Workspace: plan.Workspace, + Root: plan.Root, + Prefix: prefix, + } + for _, i := range plan.Initiatives { + req.Initiatives = append(req.Initiatives, remotestate.WorkImportInitiative{Slug: i.Slug, Title: i.Title}) + } + for _, s := range plan.Specs { + req.Specs = append(req.Specs, remotestate.WorkImportSpec{ + Slug: s.Slug, Title: s.Title, DocPath: s.DocPath, DocSHA256: s.DocSHA256, PlanPath: s.PlanPath, + Initiative: s.Initiative, + }) + } + for _, m := range plan.Milestones { + req.Milestones = append(req.Milestones, remotestate.WorkImportMilestone{ + SpecSlug: m.SpecSlug, Key: m.Key, Title: m.Title, Goal: m.Goal, DoneWhen: m.DoneWhen, Ordinal: m.Ordinal, + }) + } + for _, t := range plan.Tasks { + req.Tasks = append(req.Tasks, remotestate.WorkImportTask{ + SpecSlug: t.SpecSlug, MilestoneID: t.MilestoneID, Milestone: t.Milestone, Title: t.Title, Contract: toWireContract(t.Contract), + }) + } + return req +} + +func newWorkImportCommand() *cobra.Command { + var ( + workspace string + backendURL string + prefix string + dryRun bool + asJSON bool + ) + cmd := &cobra.Command{ + Use: "import ", + Short: "Map a specs tree to the hierarchy (epic READMEs → Epics, plan headings → Milestones)", + Long: `Parse a repo's specs tree into the work plane's import plan: each epic +folder's README.md becomes an Epic (doc body content-addressed verbatim), +each implementation-plan.md milestone heading becomes a ladder Milestone, +checklist items become Tasks, and roadmap clusters become Initiatives. + +Lifecycle is never imported: IMPLEMENTATION-STATUS.md tables stay behind, and +rungs derive from real observations after apply — the point of the exercise. + +--dry-run prints the deterministic mapping; without it the plan applies to +Orun Cloud through the work mutators (every event lands as via=import; +re-imports skip existing specs and milestones).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + plan, err := worklens.ParseSpecTree(args[0], workspace) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if dryRun { + if asJSON { + return encodeJSON(cmd, plan) + } + fmt.Fprintf(out, "workspace: %s\n", plan.Workspace) + if len(plan.Initiatives) > 0 { + fmt.Fprintf(out, "initiatives: %d\n", len(plan.Initiatives)) + } + fmt.Fprintf(out, "specs: %d\n", len(plan.Specs)) + fmt.Fprintf(out, "milestones: %d\n", len(plan.Milestones)) + fmt.Fprintf(out, "tasks: %d\n\n", len(plan.Tasks)) + for _, s := range plan.Specs { + n := 0 + for _, t := range plan.Tasks { + if t.SpecSlug == s.Slug { + n++ + } + } + fmt.Fprintf(out, " %-32s %2d tasks %s\n", s.Slug, n, s.DocSHA256[:19]) + } + fmt.Fprintln(out, "\n(dry run — nothing written)") + return nil + } + + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.ImportWork(cmd.Context(), toWirePlan(plan, prefix)) + if err != nil { + return fmt.Errorf("orun initiatives import: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + if resp.InitiativesCreated+resp.InitiativesSkipped > 0 { + fmt.Fprintf(out, "initiatives: %d created, %d skipped\n", resp.InitiativesCreated, resp.InitiativesSkipped) + } + fmt.Fprintf(out, "specs: %d created, %d skipped\n", resp.SpecsCreated, resp.SpecsSkipped) + fmt.Fprintf(out, "milestones: %d created, %d skipped\n", resp.MilestonesCreated, resp.MilestonesSkipped) + fmt.Fprintf(out, "tasks: %d created, %d skipped, %d migrated into milestones\n", resp.TasksCreated, resp.TasksSkipped, resp.TasksMigrated) + fmt.Fprintln(out, "\nlifecycle derives from observations — check `orun initiatives list`") + return nil + }, + } + cmd.Flags().StringVar(&prefix, "prefix", "WRK", "task-key prefix for imported milestones (2–5 uppercase)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the mapping without writing") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// ── task view / task create ────────────────────────────────────────────────── + +func newInitiativesTaskCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "task", + Short: "Task detail (view) and creation (create)", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newInitiativesTaskViewCommand()) + cmd.AddCommand(newInitiativesTaskCreateCommand()) + return cmd +} + +func newInitiativesTaskViewCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "view ", + Short: "One task: rung ladder, evidence, components affected, activity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + detail, err := client.GetTaskDetail(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("orun initiatives task view: %w", err) + } + if asJSON { + return encodeJSON(cmd, detail) + } + fmt.Fprint(cmd.OutOrStdout(), renderTaskDetail(detail)) + return nil + }, + } + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// rungLadder is the derived delivery ladder, in fold order. Canceled is a +// terminal side-exit and renders bare. +var rungLadder = []string{"draft", "ready", "in_progress", "in_review", "done", "released"} + +// rungLadderLine renders the ladder with the current rung bracketed. +func rungLadderLine(rung string) string { + parts := make([]string, len(rungLadder)) + found := false + for i, r := range rungLadder { + if r == rung { + parts[i] = "[" + r + "]" + found = true + } else { + parts[i] = r + } + } + if !found { + return rung + } + return strings.Join(parts, " › ") +} + +// renderTaskDetail renders the task page: rung ladder, ancestry, evidence, +// components affected, recent activity. +func renderTaskDetail(d *remotestate.WorkTaskDetail) string { + var b strings.Builder + fmt.Fprintf(&b, "%s — %s\n", d.Task.Key, d.Task.Title) + fmt.Fprintf(&b, "rung %s\n", rungLadderLine(d.Task.Lifecycle.Rung)) + if d.Task.Lifecycle.Blocked { + b.WriteString(" [blocked]\n") + } + var ancestry []string + if d.Initiative != nil { + ancestry = append(ancestry, fmt.Sprintf("initiative %s — %s", d.Initiative.Key, d.Initiative.Title)) + } + if d.Epic != nil { + ancestry = append(ancestry, fmt.Sprintf("epic %s — %s", d.Epic.Key, d.Epic.Title)) + } + if d.Milestone != nil { + ancestry = append(ancestry, fmt.Sprintf("milestone %s — %s", d.Milestone.Key, d.Milestone.Title)) + } + if len(ancestry) > 0 { + fmt.Fprintf(&b, "under %s\n", strings.Join(ancestry, " · ")) + } + + b.WriteString("\nevidence\n") + printed := false + if d.Evidence.Branch != nil { + line := " branch " + d.Evidence.Branch.Name + if d.Evidence.Branch.LastPushAt != "" { + line += " (last push " + d.Evidence.Branch.LastPushAt + ")" + } + b.WriteString(line + "\n") + printed = true + } + if d.Evidence.PR != nil { + pr := d.Evidence.PR + state := "open" + if pr.Merged { + state = "merged" + if pr.MergedAt != "" { + state += " " + pr.MergedAt + } + } + line := fmt.Sprintf(" PR #%s %s", pr.Number, state) + if pr.ChecksTotal > 0 { + line += fmt.Sprintf(" checks %d/%d", pr.ChecksPassed, pr.ChecksTotal) + } + b.WriteString(line + "\n") + printed = true + } + if d.Evidence.Checks != nil { + fmt.Fprintf(&b, " checks %d/%d", d.Evidence.Checks.Passed, d.Evidence.Checks.Total) + if d.Evidence.Checks.At != "" { + fmt.Fprintf(&b, " (%s)", d.Evidence.Checks.At) + } + b.WriteString("\n") + printed = true + } + if !printed { + b.WriteString(" (nothing observed yet)\n") + } + + if len(d.ComponentsAffected) > 0 { + b.WriteString("\ncomponents affected\n") + for _, c := range d.ComponentsAffected { + line := " " + c.Path + if c.Additions > 0 || c.Deletions > 0 { + line += fmt.Sprintf(" +%d -%d", c.Additions, c.Deletions) + } + if c.Summary != "" { + line += " " + c.Summary + } + b.WriteString(line + "\n") + } + } + + if len(d.Activity) > 0 { + b.WriteString("\nactivity\n") + for _, e := range d.Activity { + fmt.Fprintf(&b, " %s %s\n", e.At, e.Text) + } + } + return b.String() +} + +func newInitiativesTaskCreateCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + prefix string + title string + epic string + milestone string + goal string + doneWhen []string + affects []string + gates []string + deps []string + ) + cmd := &cobra.Command{ + Use: "create", + Short: "Create a task through the one mutator surface", + Long: `Create a task under an epic's milestone (or unscheduled). The contract +flags author the task's definition of done; rungs derive from observations +afterwards — there is no status to set here, ever.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(title) == "" { + return fmt.Errorf("orun initiatives task create: --title is required") + } + var contract *remotestate.WorkContract + if goal != "" || len(doneWhen) > 0 || len(affects) > 0 || len(gates) > 0 || len(deps) > 0 { + contract = &remotestate.WorkContract{ + Goal: goal, DoneWhen: doneWhen, Affects: affects, Gates: gates, Deps: deps, + } + } + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.CreateWorkTask(cmd.Context(), remotestate.CreateWorkTaskRequest{ + Prefix: prefix, Title: title, SpecKey: epic, Milestone: milestone, Contract: contract, + }) + if err != nil { + return fmt.Errorf("orun initiatives task create: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "created %s (seq %d)\n", resp.Key, resp.Seq) + return nil + }, + } + cmd.Flags().StringVar(&prefix, "prefix", "WRK", "task-key prefix (2–5 uppercase)") + cmd.Flags().StringVar(&title, "title", "", "task title (required)") + cmd.Flags().StringVar(&epic, "epic", "", "parent epic slug") + cmd.Flags().StringVar(&milestone, "milestone", "", "milestone key within --epic") + cmd.Flags().StringVar(&goal, "contract-goal", "", "contract goal (one or two sentences)") + cmd.Flags().StringArrayVar(&doneWhen, "contract-done-when", nil, "done-when criterion (repeatable)") + cmd.Flags().StringArrayVar(&affects, "contract-affects", nil, "affected catalog component key (repeatable)") + cmd.Flags().StringArrayVar(&gates, "contract-gate", nil, "gate check (repeatable)") + cmd.Flags().StringArrayVar(&deps, "contract-dep", nil, "dependency task key (repeatable)") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// ── activity ───────────────────────────────────────────────────────────────── + +func newInitiativesActivityCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + limit int + cursor string + ) + cmd := &cobra.Command{ + Use: "activity ", + Short: "The tagged activity tail for any noun (initiative, epic, task, design)", + Long: `Fetch the tagged activity tail: both logs folded into one reverse- +chronological list. The tag trail is ancestry — filtering by an epic covers +its milestones' tasks, docs, and designs; an initiative covers its whole +subtree.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + activity, err := client.GetWorkActivity(cmd.Context(), remotestate.WorkActivityOptions{ + Tag: args[0], Limit: limit, Cursor: cursor, + }) + if err != nil { + return fmt.Errorf("orun initiatives activity: %w", err) + } + if asJSON { + return encodeJSON(cmd, activity) + } + fmt.Fprint(cmd.OutOrStdout(), renderActivity(activity)) + return nil + }, + } + cmd.Flags().IntVar(&limit, "limit", 50, "maximum entries to fetch") + cmd.Flags().StringVar(&cursor, "cursor", "", "resume from a prior page's cursor") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// renderActivity renders "TIME TEXT [TAG]" lines plus the next-page hint. +func renderActivity(a *remotestate.WorkActivity) string { + var b strings.Builder + for _, e := range a.Entries { + fmt.Fprintf(&b, "%s %s [%s]\n", e.At, e.Text, e.Tag) + } + if a.NextCursor != "" { + fmt.Fprintf(&b, "\nmore: re-run with --cursor %s\n", a.NextCursor) + } + return b.String() +} + +// ── doc pull ───────────────────────────────────────────────────────────────── + +func newInitiativesDocCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "doc", + Short: "Cloud documents: pull an epic spec / design doc as markdown", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newInitiativesDocPullCommand()) + return cmd +} + +func newInitiativesDocPullCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + rev string + ) + cmd := &cobra.Command{ + Use: "pull ", + Short: "Print an item's cloud document (markdown) to stdout", + Long: `Fetch an item's content-addressed cloud document — an epic's spec or a +design's doc — and print the markdown body to stdout (latest revision unless +--rev pins one). Pipe it wherever you need the text.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + doc, err := client.GetWorkDoc(cmd.Context(), args[0], rev) + if err != nil { + return fmt.Errorf("orun initiatives doc pull: %w", err) + } + if asJSON { + return encodeJSON(cmd, doc) + } + fmt.Fprint(cmd.OutOrStdout(), doc.Body) + if !strings.HasSuffix(doc.Body, "\n") { + fmt.Fprintln(cmd.OutOrStdout()) + } + return nil + }, + } + cmd.Flags().StringVar(&rev, "rev", "", "revision digest sha256: (default: latest)") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +// ── design list / design propose ───────────────────────────────────────────── + +func newInitiativesDesignCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "design", + Short: "Design runs on an initiative: list, propose", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newInitiativesDesignListCommand()) + cmd.AddCommand(newInitiativesDesignProposeCommand()) + return cmd +} + +func newInitiativesDesignListCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "list ", + Short: "List an initiative's design runs with their folded intent state", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + designs, err := client.ListWorkDesigns(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("orun initiatives design list: %w", err) + } + if asJSON { + return encodeJSON(cmd, designs) + } + rows := make([][]string, 0, len(designs.Designs)) + for _, d := range designs.Designs { + rows = append(rows, []string{d.Key, d.Title, designStateOf(d), d.CreatedBy.ID}) + } + fmt.Fprint(cmd.OutOrStdout(), renderColumns([]string{"KEY", "TITLE", "STATE", "BY"}, rows)) + return nil + }, + } + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} + +func newInitiativesDesignProposeCommand() *cobra.Command { + var ( + workspace string + backendURL string + asJSON bool + title string + docRef string + proposal string + ) + cmd := &cobra.Command{ + Use: "propose ", + Short: "Start a Draft design run under an initiative", + Long: `Create a Draft design under an initiative: a document reference plus an +optional structured proposal (epics → milestones → task skeletons). A design +is a PROPOSAL — humans review, compare, and adopt; adoption mints the epics +and stays human-only.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(title) == "" { + return fmt.Errorf("orun initiatives design propose: --title is required") + } + req := remotestate.CreateWorkDesignRequest{Title: title, DocRef: docRef} + if proposal != "" { + if !json.Valid([]byte(proposal)) { + return fmt.Errorf("orun initiatives design propose: --proposal is not valid JSON") + } + req.Proposal = json.RawMessage(proposal) + } + client, err := workClient(cmd.Context(), backendURL, workspace) + if err != nil { + return err + } + resp, err := client.CreateWorkDesign(cmd.Context(), args[0], req) + if err != nil { + return fmt.Errorf("orun initiatives design propose: %w", err) + } + if asJSON { + return encodeJSON(cmd, resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "proposed design %s (seq %d) — a human reviews, compares, and adopts\n", resp.Key, resp.Seq) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "design title (required)") + cmd.Flags().StringVar(&docRef, "doc-ref", "", "design doc revision sha256:") + cmd.Flags().StringVar(&proposal, "proposal", "", "structured proposal JSON ({\"epics\":[…]})") + addWorkScopeFlags(cmd, &workspace, &backendURL, &asJSON) + return cmd +} diff --git a/cmd/orun/initiatives_test.go b/cmd/orun/initiatives_test.go new file mode 100644 index 00000000..27f84783 --- /dev/null +++ b/cmd/orun/initiatives_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/sourceplane/orun/internal/remotestate" + "github.com/sourceplane/orun/internal/worklens" +) + +func runInitiativesCmd(t *testing.T, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "orun", SilenceUsage: true, SilenceErrors: true} + registerInitiativesCommand(root) + buf := &bytes.Buffer{} + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs(args) + err := root.Execute() + return buf.String(), err +} + +// The remote subcommands must resolve a backend + workspace before any wire +// call; in a bare test environment that resolution fails loudly rather than +// silently doing nothing. +func TestInitiativesRemoteCommandsNeedBackend(t *testing.T) { + for _, args := range [][]string{ + {"initiatives", "list", "--workspace", "ws_test"}, + {"initiatives", "view", "in5", "--workspace", "ws_test"}, + {"initiatives", "create", "--title", "AI-native work", "--workspace", "ws_test"}, + {"initiatives", "task", "view", "WK-1", "--workspace", "ws_test"}, + {"initiatives", "activity", "EP-1", "--workspace", "ws_test"}, + {"initiatives", "doc", "pull", "EP-1", "--workspace", "ws_test"}, + {"initiatives", "design", "list", "in5", "--workspace", "ws_test"}, + } { + out, err := runInitiativesCmd(t, args...) + if err == nil { + t.Errorf("%v succeeded without a backend:\n%s", args, out) + } + } +} + +// Argument validation fails before any backend resolution. +func TestInitiativesArgumentValidation(t *testing.T) { + if _, err := runInitiativesCmd(t, "initiatives", "create"); err == nil || !strings.Contains(err.Error(), "--title is required") { + t.Fatalf("create without --title = %v", err) + } + if _, err := runInitiativesCmd(t, "initiatives", "task", "create"); err == nil || !strings.Contains(err.Error(), "--title is required") { + t.Fatalf("task create without --title = %v", err) + } + if _, err := runInitiativesCmd(t, "initiatives", "design", "propose", "in5"); err == nil || !strings.Contains(err.Error(), "--title is required") { + t.Fatalf("design propose without --title = %v", err) + } +} + +// The importer is shared with the deprecated `orun work` alias; the dry-run +// leg stays fully offline. +func TestInitiativesImportDryRunJSON(t *testing.T) { + out, err := runInitiativesCmd(t, "initiatives", "import", "../../internal/worklens/testdata/spectree", "--workspace", "ws_test", "--dry-run", "--json") + if err != nil { + t.Fatalf("import --dry-run failed: %v\n%s", err, out) + } + var plan worklens.ImportPlan + if err := json.Unmarshal([]byte(out), &plan); err != nil { + t.Fatalf("output is not a plan: %v\n%s", err, out) + } + if plan.Workspace != "ws_test" || len(plan.Specs) != 2 || len(plan.Tasks) != 2 { + t.Fatalf("plan = %d specs, %d tasks, ws %q", len(plan.Specs), len(plan.Tasks), plan.Workspace) + } +} + +func TestSlugFromTitle(t *testing.T) { + for in, want := range map[string]string{ + "AI-native work": "ai-native-work", + " Observability 2.0 ": "observability-2-0", + "---": "", + } { + if got := slugFromTitle(in); got != want { + t.Errorf("slugFromTitle(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRungLadderLine(t *testing.T) { + if got := rungLadderLine("in_review"); got != "draft › ready › in_progress › [in_review] › done › released" { + t.Fatalf("ladder = %q", got) + } + // A rung outside the ladder (canceled) renders bare, never bracketed + // into a ladder it does not sit on. + if got := rungLadderLine("canceled"); got != "canceled" { + t.Fatalf("canceled = %q", got) + } +} + +func TestRenderPortfolio(t *testing.T) { + p := &remotestate.WorkPortfolio{ + Stats: remotestate.WorkFoldStats{OpenTasks: 5, NeedsYou: 2, AgentsLive: 1}, + Initiatives: []remotestate.WorkPortfolioInitiativeRow{ + {Key: "in5", Title: "Initiatives", Status: "at_risk", TargetDate: "2026-09-01", + Progress: remotestate.WorkProgressView{Done: 3, Total: 8}, + NeedsYou: []remotestate.WorkNeedsYouReason{{Text: "EP-1 approval drifted"}, {Text: "M2 idle 6d"}}}, + {Key: "obs", Title: "Observability", Status: "planning", + Progress: remotestate.WorkProgressView{Done: 0, Total: 0}}, + }, + } + want := "" + + "KEY TITLE STATUS PROGRESS NEEDS YOU TARGET\n" + + "in5 Initiatives at_risk 3/8 EP-1 approval drifted (+1) 2026-09-01\n" + + "obs Observability planning 0/0 - -\n" + + "\nopen tasks: 5 · needs you: 2 · agents live: 1\n" + if got := renderPortfolio(p); got != want { + t.Fatalf("portfolio =\n%s\nwant\n%s", got, want) + } +} + +// The view ladder: initiative header, intent chip with @revision + drift, +// milestone states with n/m, rung words with evidence hints, backlog, docs, +// designs — fixture-driven, byte-exact. +func TestRenderInitiativeTree(t *testing.T) { + tree := &remotestate.WorkInitiativeTree{ + Epics: []remotestate.WorkTreeEpic{{ + Key: "EP-1", Title: "Wire surface", + Intent: remotestate.WorkEpicIntentView{ + State: "approved", + Approval: &remotestate.WorkApprovalView{Revision: "sha256:b2d4aa00ff11", By: remotestate.WorkActor{Type: "user", ID: "u"}}, + DocDrifted: true, + }, + Progress: remotestate.WorkProgressView{Done: 3, Active: 2, Total: 8}, + Milestones: []remotestate.WorkTreeMilestone{ + {Key: "M1", Title: "Client", State: "complete", Progress: remotestate.WorkProgressView{Done: 2, Total: 2}, + Tasks: []remotestate.WorkTreeTaskRow{{Key: "WK-1", Title: "types", Rung: "done", + Evidence: remotestate.WorkTaskEvidenceView{PR: &remotestate.WorkEvidencePR{Number: "7", Merged: true}}}}}, + {Key: "M2", Title: "CLI", State: "active", Progress: remotestate.WorkProgressView{Done: 1, Total: 4}, + Tasks: []remotestate.WorkTreeTaskRow{{Key: "WK-2", Title: "render ladder", Rung: "in_review", + Evidence: remotestate.WorkTaskEvidenceView{ + Branch: &remotestate.WorkEvidenceBranch{Name: "claude/x"}, + PR: &remotestate.WorkEvidencePR{Number: "9", ChecksPassed: 3, ChecksTotal: 4}}}}}, + }, + Backlog: []remotestate.WorkTreeTaskRow{{Key: "WK-9", Title: "stretch", Rung: "draft"}}, + Docs: []remotestate.WorkTreeDocRow{{Subject: "EP-1", Kind: "spec", Title: "Wire surface", + Revision: "sha256:b2d4aa00ff11", State: "drifted"}}, + }}, + Designs: []remotestate.WorkDesignView{{Key: "DSG-1", Title: "Alt", Intent: json.RawMessage(`{"state":"in_review"}`)}}, + } + tree.Initiative.Key = "in5" + tree.Initiative.Title = "Initiatives" + tree.Initiative.Owner = "rahul" + tree.Initiative.TargetDate = "2026-09-01" + tree.Initiative.Status = "at_risk" + tree.Initiative.NeedsYou = []remotestate.WorkNeedsYouReason{{Kind: "approval_drifted", Subject: "EP-1", Text: "EP-1 approval drifted"}} + tree.Initiative.ProgressView = remotestate.WorkProgressView{Done: 3, Active: 2, Total: 8} + doc := tree.Epics[0].Docs[0] + doc.Threads = &struct { + Total int `json:"total"` + Open int `json:"open"` + }{Total: 3, Open: 1} + tree.Epics[0].Docs[0] = doc + + want := `in5 — Initiatives [at_risk] owner rahul target 2026-09-01 3/8 done, 2 active + needs you: EP-1 approval drifted + + EP-1 — Wire surface intent approved @b2d4aa00 (drifted) 3/8 + M1 — Client [complete] 2/2 + WK-1 done types (PR #7 merged) + M2 — CLI [active] 1/4 + WK-2 in_review render ladder (branch claude/x; PR #9 open, checks 3/4) + backlog: + WK-9 draft stretch + doc spec EP-1 @b2d4aa00 [drifted] — Wire surface 1 open thread(s) + + designs: + DSG-1 — Alt [in_review] +` + if got := renderInitiativeTree(tree); got != want { + t.Fatalf("tree =\n%s\nwant\n%s", got, want) + } +} + +func TestRenderTaskDetail(t *testing.T) { + det := &remotestate.WorkTaskDetail{ + Task: remotestate.WorkTaskView{Key: "WK-2", Title: "render ladder", + Lifecycle: remotestate.WorkLifecycle{Rung: "in_review"}}, + Initiative: &remotestate.WorkItemRef{Key: "in5", Title: "Initiatives"}, + Epic: &remotestate.WorkItemRef{Key: "EP-1", Title: "Wire surface"}, + Milestone: &remotestate.WorkItemRef{Key: "M2", Title: "CLI"}, + Evidence: remotestate.WorkTaskEvidenceView{ + Branch: &remotestate.WorkEvidenceBranch{Name: "claude/x", LastPushAt: "2026-08-06T10:00:00Z"}, + PR: &remotestate.WorkEvidencePR{Number: "9", ChecksPassed: 3, ChecksTotal: 4}, + }, + ComponentsAffected: []remotestate.WorkComponentTouched{{Path: "cmd/orun/initiatives.go", Additions: 120, Deletions: 3}}, + Activity: []remotestate.WorkActivityEntry{{At: "2026-08-06T10:00:00Z", Text: "opened PR #9"}}, + } + want := `WK-2 — render ladder +rung draft › ready › in_progress › [in_review] › done › released +under initiative in5 — Initiatives · epic EP-1 — Wire surface · milestone M2 — CLI + +evidence + branch claude/x (last push 2026-08-06T10:00:00Z) + PR #9 open checks 3/4 + +components affected + cmd/orun/initiatives.go +120 -3 + +activity + 2026-08-06T10:00:00Z opened PR #9 +` + if got := renderTaskDetail(det); got != want { + t.Fatalf("task detail =\n%s\nwant\n%s", got, want) + } + // Evidence is never invented: a silent log renders the honest absence. + bare := &remotestate.WorkTaskDetail{Task: remotestate.WorkTaskView{Key: "WK-3", Title: "quiet", + Lifecycle: remotestate.WorkLifecycle{Rung: "draft"}}} + if got := renderTaskDetail(bare); !strings.Contains(got, "(nothing observed yet)") { + t.Fatalf("bare task detail lacks the honest absence:\n%s", got) + } +} + +func TestRenderActivity(t *testing.T) { + got := renderActivity(&remotestate.WorkActivity{ + Entries: []remotestate.WorkActivityEntry{ + {At: "2026-08-06T10:00:00Z", Text: "approved EP-1 @b2d4", Tag: "EP-1"}, + }, + NextCursor: "c9", + }) + want := "2026-08-06T10:00:00Z approved EP-1 @b2d4 [EP-1]\n\nmore: re-run with --cursor c9\n" + if got != want { + t.Fatalf("activity = %q, want %q", got, want) + } +} diff --git a/cmd/orun/mcp_serve_test.go b/cmd/orun/mcp_serve_test.go index 377d7499..c77e1d32 100644 --- a/cmd/orun/mcp_serve_test.go +++ b/cmd/orun/mcp_serve_test.go @@ -83,7 +83,7 @@ func TestDegradedServeAssembly(t *testing.T) { // TestFullyMountedAssemblyRoster: with auth + workspace the roster is the // full merged surface PLUS connection_info (counts derived from the live -// rosters — UM4 — with the field report's 40+1 pinned literally so a silent +// rosters — UM4 — with the current 46+1 pinned literally so a silent // shrink fails loudly), and connection_info reports the all-ok posture. func TestFullyMountedAssemblyRoster(t *testing.T) { // A real client, never called: Tools() is roster-only. @@ -118,8 +118,8 @@ func TestFullyMountedAssemblyRoster(t *testing.T) { if want := counts.work + counts.platform + counts.server; len(tools) != want { t.Fatalf("fully mounted roster = %d tools, want %d", len(tools), want) } - if len(tools) != 41 { - t.Fatalf("fully mounted roster = %d tools, want 41 (40 + connection_info)", len(tools)) + if len(tools) != 47 { + t.Fatalf("fully mounted roster = %d tools, want 47 (46 + connection_info)", len(tools)) } last := tools[len(tools)-1].(map[string]interface{}) if last["name"] != "connection_info" { diff --git a/cmd/orun/work.go b/cmd/orun/work.go index 337a385d..b6f580e9 100644 --- a/cmd/orun/work.go +++ b/cmd/orun/work.go @@ -1,384 +1,37 @@ package main import ( - "context" - "encoding/json" - "fmt" - "github.com/spf13/cobra" - - "github.com/sourceplane/orun/internal/remotestate" - "github.com/sourceplane/orun/internal/worklens" ) +// orun work — the previous name of the work-plane group, kept for one +// release as a hidden deprecated alias (orun-initiatives IN6, compatibility +// ledger). Every subcommand forwards to the same run functions the +// `orun initiatives` group registers (initiatives.go); nothing is +// duplicated here and nothing here will outlive the deprecation window. func registerWorkCommand(root *cobra.Command) { workCmd := &cobra.Command{ - Use: "work", - Short: "The work plane: import spec trees, inspect derived lifecycle", - Long: `The work plane (specs/orun-work, v2 — the work lens). - -Work lifecycle is a derived query over two append-only logs, never a stored -status. The CLI surface is deliberately small; the board lives in Orun Cloud. - -Subcommands: - import Map a specs/ tree to Spec/Task envelopes and apply to Orun Cloud - list Show the workspace's derived lifecycle (rungs with evidence) + Use: "work", + Hidden: true, + Deprecated: "use 'orun initiatives' (this alias will be removed)", + Short: "The work plane (deprecated alias of 'orun initiatives')", + Long: `Deprecated alias of 'orun initiatives' — the work plane's CLI group. + +Subcommands forward to the same implementations: + import Map a specs/ tree to the hierarchy and apply to Orun Cloud + list Portfolio: key, title, status, progress, needs-you, target edit Edit an item's envelope (title/description/owner/target/…) cancel Retire an item (task or epic) — the append-only "delete" -Run 'orun work --help' for details.`, +Run 'orun initiatives --help' for the full group.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } - registerWorkImportCommand(workCmd) - registerWorkListCommand(workCmd) - registerWorkEditCommand(workCmd) - registerWorkCancelCommand(workCmd) + workCmd.AddCommand(newWorkImportCommand()) + workCmd.AddCommand(newInitiativesListCommand()) + workCmd.AddCommand(newItemEditCommand()) + workCmd.AddCommand(newItemCancelCommand()) root.AddCommand(workCmd) } - -// workClient resolves scope + auth and builds the cloud client — the same -// preamble as catalog push (flag > env > intent > cached link). -func workClient(ctx context.Context, backendURLFlag, orgFlag string) (*remotestate.Client, error) { - backendURL, err := requireBackendURL(nil, backendURLFlag) - if err != nil { - return nil, err - } - repo, err := resolveRepoContext(backendURL) - if err != nil { - return nil, err - } - linkOrg, linkProject := "", "" - if repo != nil { - linkOrg, linkProject = repo.OrgID, repo.ProjectID - } - intentOrg, intentProject, _ := intentScope(loadIntentForCloudConfig()) - scope := resolveScope(orgFlag, "", intentOrg, intentProject, linkOrg, linkProject) - if scope.OrgID == "" { - return nil, fmt.Errorf("orun work: no workspace resolved; pass --workspace or link the repo (orun auth login)") - } - tokenSrc, _, _, err := remotestate.ResolveTokenSource(ctx, remotestate.ResolveOptions{ - BackendURL: backendURL, - Version: version, - Interactive: termIsInteractive(), - RequireLogin: true, - Org: scope.OrgID, - }) - if err != nil { - if isNoLoginErr(err) { - return nil, errNotLoggedIn() - } - return nil, fmt.Errorf("remote state auth: %w", err) - } - return remotestate.NewClientWithScope(backendURL, version, tokenSrc, scope), nil -} - -func toWireContract(c *worklens.Contract) *remotestate.WorkContract { - if c == nil { - return nil - } - return &remotestate.WorkContract{ - Goal: c.Goal, - Affects: c.Affects, - DoneWhen: c.DoneWhen, - Gates: c.Gates, - DesignRefs: c.DesignRefs, - Deps: c.Deps, - GatesDefined: c.GatesDefined, - } -} - -func toWirePlan(plan *worklens.ImportPlan, prefix string) remotestate.WorkImportRequest { - req := remotestate.WorkImportRequest{ - Workspace: plan.Workspace, - Root: plan.Root, - Prefix: prefix, - } - for _, i := range plan.Initiatives { - req.Initiatives = append(req.Initiatives, remotestate.WorkImportInitiative{Slug: i.Slug, Title: i.Title}) - } - for _, s := range plan.Specs { - req.Specs = append(req.Specs, remotestate.WorkImportSpec{ - Slug: s.Slug, Title: s.Title, DocPath: s.DocPath, DocSHA256: s.DocSHA256, PlanPath: s.PlanPath, - Initiative: s.Initiative, - }) - } - for _, m := range plan.Milestones { - req.Milestones = append(req.Milestones, remotestate.WorkImportMilestone{ - SpecSlug: m.SpecSlug, Key: m.Key, Title: m.Title, Goal: m.Goal, DoneWhen: m.DoneWhen, Ordinal: m.Ordinal, - }) - } - for _, t := range plan.Tasks { - req.Tasks = append(req.Tasks, remotestate.WorkImportTask{ - SpecSlug: t.SpecSlug, MilestoneID: t.MilestoneID, Milestone: t.Milestone, Title: t.Title, Contract: toWireContract(t.Contract), - }) - } - return req -} - -func registerWorkImportCommand(parent *cobra.Command) { - var ( - workspace string - backendURL string - prefix string - dryRun bool - asJSON bool - ) - cmd := &cobra.Command{ - Use: "import ", - Short: "Map a specs tree to Spec/Task envelopes (epic READMEs → Specs, milestones → Tasks)", - Long: `Parse a repo's specs tree into the work plane's import plan: each epic -folder's README.md becomes a Spec (doc body content-addressed verbatim) and -each implementation-plan.md milestone becomes a Task whose contract is the -milestone's Goal / Deps / Done when fields. - -Lifecycle is never imported: IMPLEMENTATION-STATUS.md tables stay behind, and -rungs derive from real observations after apply — the point of the exercise. - ---dry-run prints the deterministic mapping; without it the plan applies to -Orun Cloud through the work mutators (every event lands as via=import; -re-imports skip existing specs and milestones).`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - plan, err := worklens.ParseSpecTree(args[0], workspace) - if err != nil { - return err - } - out := cmd.OutOrStdout() - if dryRun { - if asJSON { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(plan) - } - fmt.Fprintf(out, "workspace: %s\n", plan.Workspace) - if len(plan.Initiatives) > 0 { - fmt.Fprintf(out, "initiatives: %d\n", len(plan.Initiatives)) - } - fmt.Fprintf(out, "specs: %d\n", len(plan.Specs)) - fmt.Fprintf(out, "milestones: %d\n", len(plan.Milestones)) - fmt.Fprintf(out, "tasks: %d\n\n", len(plan.Tasks)) - for _, s := range plan.Specs { - n := 0 - for _, t := range plan.Tasks { - if t.SpecSlug == s.Slug { - n++ - } - } - fmt.Fprintf(out, " %-32s %2d tasks %s\n", s.Slug, n, s.DocSHA256[:19]) - } - fmt.Fprintln(out, "\n(dry run — nothing written)") - return nil - } - - client, err := workClient(cmd.Context(), backendURL, workspace) - if err != nil { - return err - } - resp, err := client.ImportWork(cmd.Context(), toWirePlan(plan, prefix)) - if err != nil { - return fmt.Errorf("orun work import: %w", err) - } - if asJSON { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(resp) - } - if resp.InitiativesCreated+resp.InitiativesSkipped > 0 { - fmt.Fprintf(out, "initiatives: %d created, %d skipped\n", resp.InitiativesCreated, resp.InitiativesSkipped) - } - fmt.Fprintf(out, "specs: %d created, %d skipped\n", resp.SpecsCreated, resp.SpecsSkipped) - fmt.Fprintf(out, "milestones: %d created, %d skipped\n", resp.MilestonesCreated, resp.MilestonesSkipped) - fmt.Fprintf(out, "tasks: %d created, %d skipped, %d migrated into milestones\n", resp.TasksCreated, resp.TasksSkipped, resp.TasksMigrated) - fmt.Fprintln(out, "\nlifecycle derives from observations — check `orun work list`") - return nil - }, - } - cmd.Flags().StringVar(&workspace, "workspace", "", "target workspace (org id or slug; defaults to the linked repo's)") - cmd.Flags().StringVar(&backendURL, "backend-url", "", "Backend URL (Orun Cloud or self-hosted)") - cmd.Flags().StringVar(&prefix, "prefix", "WRK", "task-key prefix for imported milestones (2–5 uppercase)") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the mapping without writing") - cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON") - parent.AddCommand(cmd) -} - -func registerWorkEditCommand(parent *cobra.Command) { - var ( - workspace string - backendURL string - asJSON bool - title string - description string - owner string - target string - initiative string - criteria []string - ) - cmd := &cobra.Command{ - Use: "edit ", - Short: "Edit an item's envelope (title/description/owner/target/…)", - Long: `Edit an item's envelope through the one mutator (item_edited): title, -plus the fields the item's kind exposes — description, owner, target date, and -success criteria for an initiative; target date and initiative filing for an -epic; title for a task. - -This is intent only. Nothing here can move a rung — lifecycle stays derived -from observations (WP-3). Only the flags you pass are sent; pass an empty -value (e.g. --owner "") to clear a field.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - f := cmd.Flags() - req := remotestate.EditWorkItemRequest{} - if f.Changed("title") { - req.Title = &title - } - if f.Changed("description") { - req.Description = &description - } - if f.Changed("owner") { - req.Owner = &owner - } - if f.Changed("target") { - req.TargetDate = &target - } - if f.Changed("initiative") { - req.Initiative = &initiative - } - if f.Changed("criteria") { - req.SuccessCriteria = criteria - } - if req.Title == nil && req.Description == nil && req.Owner == nil && - req.TargetDate == nil && req.Initiative == nil && req.SuccessCriteria == nil { - return fmt.Errorf("orun work edit: nothing to change; pass at least one of --title/--description/--owner/--target/--initiative/--criteria") - } - - client, err := workClient(cmd.Context(), backendURL, workspace) - if err != nil { - return err - } - resp, err := client.EditWorkItem(cmd.Context(), args[0], req) - if err != nil { - return fmt.Errorf("orun work edit: %w", err) - } - out := cmd.OutOrStdout() - if asJSON { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(resp) - } - fmt.Fprintf(out, "edited %s (seq %d)\n", resp.Key, resp.Seq) - return nil - }, - } - cmd.Flags().StringVar(&title, "title", "", "new title") - cmd.Flags().StringVar(&description, "description", "", "new description (initiatives)") - cmd.Flags().StringVar(&owner, "owner", "", "owner subject; \"\" clears (initiatives)") - cmd.Flags().StringVar(&target, "target", "", "target date YYYY-MM-DD; \"\" clears") - cmd.Flags().StringVar(&initiative, "initiative", "", "file an epic under this initiative key; \"\" unfiles") - cmd.Flags().StringArrayVar(&criteria, "criteria", nil, "success criterion (repeatable; initiatives)") - cmd.Flags().StringVar(&workspace, "workspace", "", "target workspace (org id or slug; defaults to the linked repo's)") - cmd.Flags().StringVar(&backendURL, "backend-url", "", "Backend URL (Orun Cloud or self-hosted)") - cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON") - parent.AddCommand(cmd) -} - -func registerWorkCancelCommand(parent *cobra.Command) { - var ( - workspace string - backendURL string - asJSON bool - yes bool - ) - cmd := &cobra.Command{ - Use: "cancel ", - Short: "Retire an item (task or epic) — the append-only \"delete\"", - Long: `Retire a task or epic by folding a terminal 'canceled' state onto it. -Cancel is the model's native "delete": a terminal, attributed, append-only -state — the record and its whole history stay, and agents stop picking up its -work. It is effectively permanent (there is no un-cancel). - -Initiatives have no lifecycle to cancel — edit their envelope, or retire their -epics, instead.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - if !yes && termIsInteractive() { - fmt.Fprintf(cmd.OutOrStdout(), "Retire %s? This is terminal and cannot be un-canceled. Re-run with --yes to confirm.\n", key) - return fmt.Errorf("orun work cancel: confirmation required (pass --yes)") - } - client, err := workClient(cmd.Context(), backendURL, workspace) - if err != nil { - return err - } - resp, err := client.CancelWorkItem(cmd.Context(), key) - if err != nil { - return fmt.Errorf("orun work cancel: %w", err) - } - out := cmd.OutOrStdout() - if asJSON { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(resp) - } - fmt.Fprintf(out, "retired %s — canceled (seq %d)\n", resp.Key, resp.Seq) - return nil - }, - } - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt") - cmd.Flags().StringVar(&workspace, "workspace", "", "target workspace (org id or slug; defaults to the linked repo's)") - cmd.Flags().StringVar(&backendURL, "backend-url", "", "Backend URL (Orun Cloud or self-hosted)") - cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON") - parent.AddCommand(cmd) -} - -func registerWorkListCommand(parent *cobra.Command) { - var ( - workspace string - backendURL string - asJSON bool - ) - cmd := &cobra.Command{ - Use: "list", - Short: "Show the workspace's derived lifecycle (rungs with evidence)", - Long: `Fetch the fold summary from Orun Cloud: every rung prints with the -evidence it derives from. Nothing shown here is a stored status.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - client, err := workClient(cmd.Context(), backendURL, workspace) - if err != nil { - return err - } - summary, err := client.GetWorkSummary(cmd.Context()) - if err != nil { - return fmt.Errorf("orun work list: %w", err) - } - out := cmd.OutOrStdout() - if asJSON { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(summary) - } - for _, s := range summary.Specs { - fmt.Fprintf(out, "%s — %s %v\n", s.Key, s.Title, s.Progress) - } - for _, t := range summary.Tasks { - evidence := "" - if len(t.Lifecycle.Evidence) > 0 { - evidence = " (" + t.Lifecycle.Evidence[0] + ")" - } - flags := "" - if t.Lifecycle.Blocked { - flags = " [blocked]" - } - fmt.Fprintf(out, " %-10s %-12s %s%s%s\n", t.Key, t.Lifecycle.Rung, t.Title, flags, evidence) - } - return nil - }, - } - cmd.Flags().StringVar(&workspace, "workspace", "", "target workspace (org id or slug; defaults to the linked repo's)") - cmd.Flags().StringVar(&backendURL, "backend-url", "", "Backend URL (Orun Cloud or self-hosted)") - cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON") - parent.AddCommand(cmd) -} diff --git a/internal/platformmcp/prompts_test.go b/internal/platformmcp/prompts_test.go index 672463b4..952d4586 100644 --- a/internal/platformmcp/prompts_test.go +++ b/internal/platformmcp/prompts_test.go @@ -98,7 +98,7 @@ func TestPromptBranches(t *testing.T) { // TestPromptToolDriftGuard ports the TS plane's guard: every snake_case // token in rendered prompt text must be a tool on the composed roster (work -// + platform + built-in — the 41 tools one serve advertises), so a tool +// + platform + built-in — the 47 tools one serve advertises), so a tool // rename breaks this test instead of silently orphaning a prompt. func TestPromptToolDriftGuard(t *testing.T) { roster := map[string]bool{} @@ -111,8 +111,8 @@ func TestPromptToolDriftGuard(t *testing.T) { for _, tool := range (&mcpserve.ConnectionInfoProvider{}).Tools() { roster[tool.Name] = true } - if len(roster) != 41 { - t.Fatalf("composed roster = %d tools, want 41 (15 work + 25 platform + 1 built-in)", len(roster)) + if len(roster) != 47 { + t.Fatalf("composed roster = %d tools, want 47 (21 work + 25 platform + 1 built-in)", len(roster)) } tokenPattern := regexp.MustCompile(`\b[a-z]+(?:_[a-z]+)+\b`) diff --git a/internal/platformmcp/server_test.go b/internal/platformmcp/server_test.go index 14f0e84d..7fb8c9e2 100644 --- a/internal/platformmcp/server_test.go +++ b/internal/platformmcp/server_test.go @@ -457,7 +457,7 @@ func TestConfigScopeValidation(t *testing.T) { } } -// TestComposedServer: 34 tools (9 work + 25 platform) under one initialize, +// TestComposedServer: 46 tools (21 work + 25 platform) under one initialize, // calls routed to the owning provider, and the WP-3/WP-10 forbidden-name // sweep green over the merged roster. func TestComposedServer(t *testing.T) { @@ -516,8 +516,8 @@ func TestComposedServer(t *testing.T) { if err := json.Unmarshal([]byte(lines[1]), &toolsResp); err != nil { t.Fatal(err) } - if len(toolsResp.Result.Tools) != 40 { - t.Fatalf("merged roster = %d tools, want 40 (15 work + 25 platform — WH5)", len(toolsResp.Result.Tools)) + if len(toolsResp.Result.Tools) != 46 { + t.Fatalf("merged roster = %d tools, want 46 (21 work + 25 platform — IN5)", len(toolsResp.Result.Tools)) } for _, tool := range toolsResp.Result.Tools { for _, frag := range mcpserve.ForbiddenNameFragments { @@ -546,9 +546,9 @@ func TestComposedServer(t *testing.T) { } // TestComposedServerReadOnly: --read-only drops exactly the 6 platform -// writes (34 → 28); the 9 work tools stay — they are mutator-shaped by WP-6, -// not read-only-filtered (risk U-R3) — and a filtered write is blocked at -// execution too, not just delisted. +// writes (46 → 40); the 21 work tools stay — they are mutator-shaped by +// WP-6, not read-only-filtered (risk U-R3) — and a filtered write is +// blocked at execution too, not just delisted. func TestComposedServerReadOnly(t *testing.T) { api := &fakeAPI{page: page(`{}`, "")} work := &workmcp.Server{API: workFake{}, Workspace: "ws_1"} @@ -574,23 +574,24 @@ func TestComposedServerReadOnly(t *testing.T) { if err := json.Unmarshal([]byte(lines[0]), &toolsResp); err != nil { t.Fatal(err) } - if len(toolsResp.Result.Tools) != 34 { - t.Fatalf("read-only roster = %d tools, want 34 (15 work + 19 platform reads — WH5)", len(toolsResp.Result.Tools)) + if len(toolsResp.Result.Tools) != 40 { + t.Fatalf("read-only roster = %d tools, want 40 (21 work + 19 platform reads — IN5)", len(toolsResp.Result.Tools)) } workCount := 0 for _, tool := range toolsResp.Result.Tools { if strings.HasPrefix(tool.Name, "work_") || strings.HasPrefix(tool.Name, "spec_") || strings.HasPrefix(tool.Name, "task_") || strings.HasPrefix(tool.Name, "contract_") || strings.HasPrefix(tool.Name, "epic_") || strings.HasPrefix(tool.Name, "design_") || - strings.HasPrefix(tool.Name, "milestone_") || strings.HasPrefix(tool.Name, "initiative_") { + strings.HasPrefix(tool.Name, "milestone_") || strings.HasPrefix(tool.Name, "initiative_") || + strings.HasPrefix(tool.Name, "initiatives_") || strings.HasPrefix(tool.Name, "activity_") { workCount++ } if tool.Name == "project_create" { t.Error("write tool advertised under --read-only") } } - if workCount != 15 { - t.Errorf("work tools under --read-only = %d, want 15 (mutator-shaped, unaffected — WH5)", workCount) + if workCount != 21 { + t.Errorf("work tools under --read-only = %d, want 21 (mutator-shaped, unaffected — IN5)", workCount) } if !strings.Contains(lines[1], "isError") || !strings.Contains(lines[1], "read-only") { t.Errorf("blocked write must be an isError read-only verdict: %s", lines[1]) @@ -642,3 +643,23 @@ func (workFake) CreateWorkDesign(context.Context, string, remotestate.CreateWork func (workFake) RegenerateWorkTasks(context.Context, string, string, remotestate.RegenerateWorkTasksRequest) (*remotestate.RegenerateWorkTasksResponse, error) { return &remotestate.RegenerateWorkTasksResponse{}, nil } +func (workFake) ListInitiatives(context.Context) (*remotestate.WorkPortfolio, error) { + return &remotestate.WorkPortfolio{}, nil +} +func (workFake) GetInitiativeTree(_ context.Context, key string) (*remotestate.WorkInitiativeTree, error) { + tree := &remotestate.WorkInitiativeTree{} + tree.Initiative.Key = key + return tree, nil +} +func (workFake) GetTaskDetail(_ context.Context, key string) (*remotestate.WorkTaskDetail, error) { + return &remotestate.WorkTaskDetail{Task: remotestate.WorkTaskView{Key: key}}, nil +} +func (workFake) GetWorkActivity(context.Context, remotestate.WorkActivityOptions) (*remotestate.WorkActivity, error) { + return &remotestate.WorkActivity{}, nil +} +func (workFake) CreateInitiative(_ context.Context, req remotestate.CreateWorkInitiativeRequest) (*remotestate.WorkMutationResponse, error) { + return &remotestate.WorkMutationResponse{Key: req.Slug}, nil +} +func (workFake) UpsertMilestones(_ context.Context, epicKey string, _ remotestate.WorkMilestoneRequest) (*remotestate.WorkMutationResponse, error) { + return &remotestate.WorkMutationResponse{Key: epicKey}, nil +} diff --git a/internal/remotestate/work.go b/internal/remotestate/work.go index 3165b401..05ab285a 100644 --- a/internal/remotestate/work.go +++ b/internal/remotestate/work.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "net/url" + "strconv" ) // Work-plane client (orun-work v2 WP1) — the CLI's seam onto the cloud work @@ -121,12 +123,31 @@ type WorkSpecView struct { Progress map[string]int `json:"progress"` } +// WorkInitiativeView mirrors the platform's WorkInitiativeView: the +// envelope plus the DERIVED health/progress projections (v3 PM3/v4 — +// nothing here is a number anyone types). +type WorkInitiativeView struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Owner string `json:"owner,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + SuccessCriteria []string `json:"successCriteria,omitempty"` + Health string `json:"health,omitempty"` + HealthEvidence []string `json:"healthEvidence,omitempty"` + CreatedBy WorkActor `json:"createdBy"` + CreatedAt string `json:"createdAt,omitempty"` + Specs []string `json:"specs,omitempty"` + Progress map[string]int `json:"progress,omitempty"` +} + // WorkSummary is the workspace lens: everything derives from the two logs. type WorkSummary struct { - Specs []WorkSpecView `json:"specs"` - Tasks []WorkTaskView `json:"tasks"` - CoordSeq int64 `json:"coordSeq"` - ObsSeq int64 `json:"obsSeq"` + Specs []WorkSpecView `json:"specs"` + Tasks []WorkTaskView `json:"tasks"` + Initiatives []WorkInitiativeView `json:"initiatives,omitempty"` + CoordSeq int64 `json:"coordSeq"` + ObsSeq int64 `json:"obsSeq"` } // workPath builds an org-scoped work path (no project segment — the work @@ -451,3 +472,362 @@ func (c *Client) GetWorkDoc(ctx context.Context, specKey, rev string) (*WorkDoc, } return &resp, nil } + +// ── orun-initiatives (IN1–IN2) — the four derived reads the Initiatives +// surface renders: portfolio, tree, task detail, tagged activity. Wire +// shapes mirror @saas/contracts/work (the field-level truth); everything +// here is a fold over the two logs — nothing is stored, nothing writable. + +// WorkFoldStats is the portfolio header's figures. AgentsLive is optional +// on the wire — the work plane does not own sessions. +type WorkFoldStats struct { + OpenTasks int `json:"openTasks"` + NeedsYou int `json:"needsYou"` + AgentsLive int `json:"agentsLive,omitempty"` +} + +// WorkNeedsYouReason is one reason an initiative waits on a human — always +// with the item key it points at and a short server sentence. +type WorkNeedsYouReason struct { + Kind string `json:"kind"` // approval_drifted | awaiting_approval | review_open | milestone_idle | design_in_review + Subject string `json:"subject"` + Text string `json:"text"` +} + +// WorkProgressView is the two-segment meter arithmetic: done = Done+Released, +// active = In Progress+In Review, total = all non-canceled member tasks. +type WorkProgressView struct { + Done int `json:"done"` + Active int `json:"active"` + Total int `json:"total"` +} + +// WorkApprovalView mirrors the platform's approval record on an epic intent. +type WorkApprovalView struct { + Revision string `json:"revision,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + By WorkActor `json:"by"` + At string `json:"at,omitempty"` + LadderHash string `json:"ladderHash,omitempty"` +} + +// WorkEpicIntentView is the folded intent ladder: state plus the approval +// record and drift flags (approved never renders without its revision). +type WorkEpicIntentView struct { + State string `json:"state"` + Approval *WorkApprovalView `json:"approval,omitempty"` + CurrentRevision string `json:"currentRevision,omitempty"` + DocDrifted bool `json:"docDrifted,omitempty"` + LadderDrifted bool `json:"ladderDrifted,omitempty"` +} + +// WorkPortfolioEpicRow is one epic row inside a portfolio initiative. +type WorkPortfolioEpicRow struct { + Key string `json:"key"` + Title string `json:"title"` + Intent WorkEpicIntentView `json:"intent"` + Progress WorkProgressView `json:"progress"` + ProposedBy string `json:"proposedBy,omitempty"` + AgentAssignees []string `json:"agentAssignees"` +} + +// WorkPortfolioDesignRow is one initiative-level design run in the portfolio. +type WorkPortfolioDesignRow struct { + Key string `json:"key"` + Title string `json:"title"` + State string `json:"state"` // draft | in_review | adopted | superseded + ProposedEpics int `json:"proposedEpics"` + ProposedMilestones int `json:"proposedMilestones"` +} + +// WorkPortfolioInitiativeRow is one initiative in the portfolio fold. +type WorkPortfolioInitiativeRow struct { + Key string `json:"key"` + Title string `json:"title"` + Status string `json:"status"` // planning | on_track | at_risk | off_track + HealthEvidence []string `json:"healthEvidence,omitempty"` + Owner string `json:"owner,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + EpicCount int `json:"epicCount"` + Progress WorkProgressView `json:"progress"` + NeedsYou []WorkNeedsYouReason `json:"needsYou"` + AgentAssignees []string `json:"agentAssignees"` + Epics []WorkPortfolioEpicRow `json:"epics"` + Designs []WorkPortfolioDesignRow `json:"designs"` +} + +// WorkPortfolio is the Initiatives home in one read (WorkPortfolioResponse). +type WorkPortfolio struct { + Stats WorkFoldStats `json:"stats"` + Initiatives []WorkPortfolioInitiativeRow `json:"initiatives"` + CoordSeq int64 `json:"coordSeq"` + ObsSeq int64 `json:"obsSeq"` +} + +// ListInitiatives fetches the portfolio: fold-stats plus one row per +// initiative with progress, needs-you reasons, epic and design rows. +func (c *Client) ListInitiatives(ctx context.Context) (*WorkPortfolio, error) { + var resp WorkPortfolio + if err := c.doJSON(ctx, http.MethodGet, c.workPath("/initiatives"), nil, &resp, true); err != nil { + return nil, err + } + return &resp, nil +} + +// WorkTaskEvidenceView is the task-rail evidence fold: branch_seen → branch, +// pr_* → pr, gate_result → checks. Fields absent when the logs are silent — +// evidence is never invented. +type WorkTaskEvidenceView struct { + Branch *WorkEvidenceBranch `json:"branch,omitempty"` + PR *WorkEvidencePR `json:"pr,omitempty"` + Checks *WorkEvidenceChecks `json:"checks,omitempty"` +} + +// WorkEvidenceBranch is the branch leg of the evidence fold. +type WorkEvidenceBranch struct { + Name string `json:"name"` + LastPushAt string `json:"lastPushAt,omitempty"` +} + +// WorkEvidencePR is the pull-request leg of the evidence fold. +type WorkEvidencePR struct { + Number string `json:"number"` + Merged bool `json:"merged"` + MergedAt string `json:"mergedAt,omitempty"` + ChecksPassed int `json:"checksPassed,omitempty"` + ChecksTotal int `json:"checksTotal,omitempty"` +} + +// WorkEvidenceChecks is the gate_result leg of the evidence fold. +type WorkEvidenceChecks struct { + Passed int `json:"passed"` + Total int `json:"total"` + At string `json:"at,omitempty"` +} + +// WorkTreeTaskRow is one task row in the initiative tree. +type WorkTreeTaskRow struct { + Key string `json:"key"` + Title string `json:"title"` + Rung string `json:"rung"` + Assignee *WorkActor `json:"assignee,omitempty"` + Evidence WorkTaskEvidenceView `json:"evidence"` + LandedAt string `json:"landedAt,omitempty"` +} + +// WorkTreeMilestone is one ladder milestone with its derived state +// (complete | active | upcoming — pure ladder arithmetic) and member tasks. +type WorkTreeMilestone struct { + Key string `json:"key"` + Title string `json:"title"` + Goal string `json:"goal,omitempty"` + DoneWhen []string `json:"doneWhen,omitempty"` + State string `json:"state"` + Progress WorkProgressView `json:"progress"` + Tasks []WorkTreeTaskRow `json:"tasks"` +} + +// WorkTreeDocRow is one document row (epic spec or design doc) in the tree. +type WorkTreeDocRow struct { + Subject string `json:"subject"` + Kind string `json:"kind"` // spec | design + Title string `json:"title"` + Revision string `json:"revision,omitempty"` + State string `json:"state"` // approved | drifted | adopted | archived | draft + Author *WorkActor `json:"author,omitempty"` + Threads *struct { + Total int `json:"total"` + Open int `json:"open"` + } `json:"threads,omitempty"` +} + +// WorkTreeEpic is one epic subtree: intent, milestones, backlog, docs. +type WorkTreeEpic struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Intent WorkEpicIntentView `json:"intent"` + Owner string `json:"owner,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + Progress WorkProgressView `json:"progress"` + Milestones []WorkTreeMilestone `json:"milestones"` + Backlog []WorkTreeTaskRow `json:"backlog"` + Docs []WorkTreeDocRow `json:"docs"` +} + +// WorkTreeInitiative is the tree's initiative header: the initiative view +// plus the portfolio's status/needs-you/progress folds. +type WorkTreeInitiative struct { + WorkInitiativeView + Status string `json:"status"` + NeedsYou []WorkNeedsYouReason `json:"needsYou"` + ProgressView WorkProgressView `json:"progressView"` +} + +// WorkInitiativeTree is one initiative's whole world +// (WorkInitiativeTreeResponse): the epic page and the home expansion. +type WorkInitiativeTree struct { + Initiative WorkTreeInitiative `json:"initiative"` + Epics []WorkTreeEpic `json:"epics"` + Designs []WorkDesignView `json:"designs"` +} + +// GetInitiativeTree fetches one initiative's full hierarchy: epics with +// intent, milestones with derived state, tasks with rungs and evidence, +// docs, and the initiative-scoped design runs. 404 (never 403) on +// cross-tenant or missing. +func (c *Client) GetInitiativeTree(ctx context.Context, key string) (*WorkInitiativeTree, error) { + var resp WorkInitiativeTree + if err := c.doJSON(ctx, http.MethodGet, c.workPath("/initiatives/"+urlSegment(key)), nil, &resp, true); err != nil { + return nil, err + } + return &resp, nil +} + +// WorkItemRef names an ancestor item (initiative/epic/milestone) on a task. +type WorkItemRef struct { + Key string `json:"key"` + Title string `json:"title"` +} + +// WorkComponentTouched is a diffstat carried by an observation payload; +// empty when the world never reported one — never invented. +type WorkComponentTouched struct { + Path string `json:"path"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// WorkActivityEntry is one entry of the folded two-log tail. Actor is +// absent for observations — the world acted, not an actor. +type WorkActivityEntry struct { + At string `json:"at"` + Source string `json:"source"` // coordination | observation + Kind string `json:"kind"` + Subject string `json:"subject"` + Tag string `json:"tag"` + Actor *WorkActor `json:"actor,omitempty"` + Text string `json:"text"` +} + +// WorkTaskDetail is one task's whole page (WorkTaskDetailResponse): the +// task view, its ancestry, evidence, components touched, and activity tail. +type WorkTaskDetail struct { + Task WorkTaskView `json:"task"` + Initiative *WorkItemRef `json:"initiative,omitempty"` + Epic *WorkItemRef `json:"epic,omitempty"` + Milestone *WorkItemRef `json:"milestone,omitempty"` + Evidence WorkTaskEvidenceView `json:"evidence"` + ComponentsAffected []WorkComponentTouched `json:"componentsAffected"` + Activity []WorkActivityEntry `json:"activity"` +} + +// GetTaskDetail fetches one task's detail: rung with evidence, ancestry, +// components affected, and the task-scoped activity tail (newest first). +func (c *Client) GetTaskDetail(ctx context.Context, key string) (*WorkTaskDetail, error) { + var resp WorkTaskDetail + if err := c.doJSON(ctx, http.MethodGet, c.workPath("/tasks/"+urlSegment(key)), nil, &resp, true); err != nil { + return nil, err + } + return &resp, nil +} + +// WorkActivityOptions filters the tagged activity tail. Empty fields are +// omitted. Tag ancestry is server-side: filtering by an epic covers its +// milestones' tasks, docs, and designs; an initiative covers its subtree. +type WorkActivityOptions struct { + Tag string + Limit int + Cursor string +} + +// WorkActivity is the reverse-chronological tail (WorkActivityResponse). +type WorkActivity struct { + Entries []WorkActivityEntry `json:"entries"` + NextCursor string `json:"nextCursor,omitempty"` +} + +// GetWorkActivity fetches the tagged activity tail: both logs folded into +// one reverse-chronological list of neutral server sentences. +func (c *Client) GetWorkActivity(ctx context.Context, opts WorkActivityOptions) (*WorkActivity, error) { + q := url.Values{} + if opts.Tag != "" { + q.Set("tag", opts.Tag) + } + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Cursor != "" { + q.Set("cursor", opts.Cursor) + } + path := c.workPath("/activity") + if enc := q.Encode(); enc != "" { + path += "?" + enc + } + var resp WorkActivity + if err := c.doJSON(ctx, http.MethodGet, path, nil, &resp, true); err != nil { + return nil, err + } + return &resp, nil +} + +// CreateWorkInitiativeRequest mirrors the platform's +// CreateWorkInitiativeRequest — the strategic envelope. No lifecycle, no +// contract: an initiative has nothing to cancel and nothing to fold. +type CreateWorkInitiativeRequest struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Owner string `json:"owner,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + SuccessCriteria []string `json:"successCriteria,omitempty"` +} + +// CreateInitiative creates an initiative envelope through the one mutator +// surface (POST /work/initiatives). +func (c *Client) CreateInitiative(ctx context.Context, req CreateWorkInitiativeRequest) (*WorkMutationResponse, error) { + var resp WorkMutationResponse + if err := c.doJSON(ctx, http.MethodPost, c.workPath("/initiatives"), req, &resp, false); err != nil { + return nil, err + } + return &resp, nil +} + +// WorkDesigns is one initiative's design runs (WorkDesignsResponse). +type WorkDesigns struct { + Designs []WorkDesignView `json:"designs"` +} + +// ListWorkDesigns fetches an initiative's design runs. +func (c *Client) ListWorkDesigns(ctx context.Context, initiativeKey string) (*WorkDesigns, error) { + var resp WorkDesigns + if err := c.doJSON(ctx, http.MethodGet, c.workPath("/initiatives/"+urlSegment(initiativeKey)+"/designs"), nil, &resp, true); err != nil { + return nil, err + } + return &resp, nil +} + +// WorkMilestoneRequest mirrors the platform's WorkMilestoneRequest: one +// ladder edit (create/edit/reorder/remove) — authored intent only; per- +// milestone progress stays derived (V4-4). Ordinal is a pointer so a +// reorder to position 0 survives the wire. +type WorkMilestoneRequest struct { + Op string `json:"op"` // create | edit | reorder | remove + Key string `json:"key"` + Title string `json:"title,omitempty"` + Goal string `json:"goal,omitempty"` + DoneWhen []string `json:"doneWhen,omitempty"` + TargetDate string `json:"targetDate,omitempty"` + Ordinal *int `json:"ordinal,omitempty"` +} + +// UpsertMilestones applies one ladder edit to an epic's milestone ladder +// through the one mutator (POST /work/epics/{epic}/milestones). +func (c *Client) UpsertMilestones(ctx context.Context, epicKey string, req WorkMilestoneRequest) (*WorkMutationResponse, error) { + var resp WorkMutationResponse + if err := c.doJSON(ctx, http.MethodPost, c.workPath("/epics/"+urlSegment(epicKey)+"/milestones"), req, &resp, false); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/workmcp/server.go b/internal/workmcp/server.go index ece4fc22..82e27622 100644 --- a/internal/workmcp/server.go +++ b/internal/workmcp/server.go @@ -4,12 +4,17 @@ // internal/mcpserve; this package supplies the tools. // // The tool surface is the whole point (agents-and-mcp.md): reads return the -// fold's output WITH evidence; the write surface is four tools — task_create, -// task_comment, task_assign, contract_propose — and deliberately nothing -// else. There is NO lifecycle write tool (lifecycle is a derived query, -// WP-3: the category "agent lies about status" is unrepresentable) and NO -// pin tool (pins are human-only, WP-10; the cloud mutator also rejects agent -// pins server-side — defense in depth, not client-side trust). +// fold's output WITH evidence; the write surface is mutator-shaped and +// deliberately small — the v2 four (task_create, task_comment, task_assign, +// contract_propose), the v4 pair (design_propose, task_regenerate), and the +// orun-initiatives pair (initiative_create, milestone_upsert). There is NO +// lifecycle write tool (lifecycle is a derived query, WP-3: the category +// "agent lies about status" is unrepresentable) and NO pin tool (pins are +// human-only, WP-10; the cloud mutator also rejects agent pins server-side — +// defense in depth, not client-side trust). Human-only decisions (approve, +// adopt, supersede, revoke) have no tool at all; when a write brushes one, +// the cloud's typed WorkError("human_only", …) verdict surfaces verbatim so +// the model can tell "not allowed for you" from "does not exist" (IN-4). package workmcp import ( @@ -42,6 +47,15 @@ type WorkAPI interface { GetWorkRollups(ctx context.Context, initiativeKey string) (*remotestate.WorkRollups, error) CreateWorkDesign(ctx context.Context, initiativeKey string, req remotestate.CreateWorkDesignRequest) (*remotestate.WorkMutationResponse, error) RegenerateWorkTasks(ctx context.Context, epicKey, milestone string, req remotestate.RegenerateWorkTasksRequest) (*remotestate.RegenerateWorkTasksResponse, error) + // orun-initiatives (IN5) — the four derived folds the Initiatives + // surface renders, plus the two envelope writes. Decisions stay off + // this seam entirely (human_only verdicts come back typed). + ListInitiatives(ctx context.Context) (*remotestate.WorkPortfolio, error) + GetInitiativeTree(ctx context.Context, key string) (*remotestate.WorkInitiativeTree, error) + GetTaskDetail(ctx context.Context, key string) (*remotestate.WorkTaskDetail, error) + GetWorkActivity(ctx context.Context, opts remotestate.WorkActivityOptions) (*remotestate.WorkActivity, error) + CreateInitiative(ctx context.Context, req remotestate.CreateWorkInitiativeRequest) (*remotestate.WorkMutationResponse, error) + UpsertMilestones(ctx context.Context, epicKey string, req remotestate.WorkMilestoneRequest) (*remotestate.WorkMutationResponse, error) } // Server is the work-plane mcpserve.ToolProvider for one workspace-scoped @@ -98,11 +112,11 @@ func ReadOnly(name string) bool { // or irreversibly overwrites), and idempotent:FALSE: unlike the platform // writes (per-attempt Idempotency-Key, UM2), the work mutators carry no // idempotency key and every call appends a new event to the coordination -// log — a blind retry of task_create/task_comment/design_propose -// duplicates the artifact, task_assign/contract_propose append duplicate -// events, and task_regenerate mints fresh task keys per run. Truthful -// hints over optimistic ones: a strict client should confirm before -// replaying a work write. +// log — a blind retry of task_create/task_comment/design_propose/ +// initiative_create duplicates the artifact, task_assign/contract_propose/ +// milestone_upsert append duplicate events, and task_regenerate mints +// fresh task keys per run. Truthful hints over optimistic ones: a strict +// client should confirm before replaying a work write. func Tools() []mcpserve.ToolDef { readAnn := mcpserve.Annotations(true, false, true) writeAnn := mcpserve.Annotations(false, false, false) @@ -132,6 +146,17 @@ func Tools() []mcpserve.ToolDef { {Name: "initiative_get", Description: "One initiative's DERIVED rollup: health with named evidence, progress, per-epic intent + execution. Nothing returned is enterable.", InputSchema: obj(map[string]interface{}{"initiative": str("initiative key")}, "initiative"), Annotations: readAnn}, {Name: "design_propose", Description: "Create a Draft design under an initiative: a document reference plus a structured proposal (epics → milestones → task skeletons). A design is a PROPOSAL — humans review, compare, and adopt; adoption mints epics and is not available here.", InputSchema: obj(map[string]interface{}{"initiative": str("initiative key"), "title": str("design title"), "docRef": str("design doc revision sha256: (optional)"), "proposal": map[string]interface{}{"type": "object", "description": "{epics: [{slug, title, docSeed?, milestones[], taskSkeletons[]}]}"}}, "initiative", "title"), Annotations: writeAnn}, {Name: "task_regenerate", Description: "Re-plan one milestone in one verdict batch: PLANNED (draft/ready) tasks cancel, in-flight tasks survive, and every proposed contract is applied AND flagged for human review. Tasks are implementation detail (V4-5) — this never touches the epic's approval.", InputSchema: obj(map[string]interface{}{"epic": str("epic slug"), "milestone": str("milestone key, e.g. M1"), "prefix": str("task-key prefix (default WK)"), "tasks": map[string]interface{}{"type": "array", "items": obj(map[string]interface{}{"title": str("task title"), "contract": contractSchema}, "title"), "description": "the replacement plan"}}, "epic", "milestone", "tasks"), Annotations: writeAnn}, + // orun-initiatives (IN5) — the portfolio surface: four derived + // reads and two envelope writes. STILL absent, on purpose: no + // approve, no adopt, no supersede, no pin — those are human-only + // decisions with no tool to name them; a write that brushes one + // gets the cloud's typed human_only verdict, surfaced verbatim. + {Name: "initiatives_list", Description: "The portfolio in one read: every initiative with DERIVED status (planning until the first approved epic, the health fold afterwards), two-segment progress (done/active/total), the needs-you reasons that wait on a human, agent assignees, epic rows with intent state, design rows, plus workspace fold-stats. Nothing returned is a stored status.", InputSchema: obj(map[string]interface{}{}), Annotations: readAnn}, + {Name: "initiative_tree", Description: "One initiative's full hierarchy — the world to plan against: epics with intent state and approved revision (drift named), milestones with derived state (complete/active/upcoming) and progress, tasks with rungs and delivery evidence, docs with revisions and open threads, and the initiative's design runs.", InputSchema: obj(map[string]interface{}{"key": str("initiative key")}, "key"), Annotations: readAnn}, + {Name: "task_get", Description: "One task's whole page: the task view (derived rung with evidence, contract, pins), its ancestry (initiative/epic/milestone), the folded delivery evidence (branch, PR, checks), components affected (observation diffstats — empty when the world reported none, never invented), and the task-scoped activity tail, newest first.", InputSchema: obj(map[string]interface{}{"key": str("task key, e.g. ORN-142")}, "key"), Annotations: readAnn}, + {Name: "activity_get", Description: "The tagged activity tail for any noun: both logs folded into one reverse-chronological list of neutral server sentences. The tag trail is ancestry — filtering by an epic covers its milestones' tasks, docs, and designs; an initiative covers its whole subtree. Omit tag for the workspace-wide tail; page with cursor.", InputSchema: obj(map[string]interface{}{"tag": str("item key to filter by, ancestry included (optional)"), "limit": map[string]interface{}{"type": "integer", "description": "maximum entries to return (optional)"}, "cursor": str("resume cursor from a prior page (optional)")}), Annotations: readAnn}, + {Name: "initiative_create", Description: "Create an initiative envelope (slug + title, optional description/owner/targetDate/successCriteria) through the one mutator surface. Agents may draft the envelope; the why (success criteria) stays human-edited. An initiative has no lifecycle — status derives from its member epics' logs.", InputSchema: obj(map[string]interface{}{"slug": str("initiative slug, lowercase kebab"), "title": str("initiative title"), "description": str("initiative description (optional)"), "owner": str("owner subject (optional)"), "targetDate": str("target date YYYY-MM-DD (optional)"), "successCriteria": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "success criteria bullets (optional)"}}, "slug", "title"), Annotations: writeAnn}, + {Name: "milestone_upsert", Description: "Apply one ladder edit to an epic's milestones: op create/edit/reorder/remove on one milestone key. Authored intent only (title, goal, doneWhen, targetDate, ordinal) — per-milestone progress stays derived and cannot be entered. Editing an approved epic's ladder drifts the approval (ladderHash); a human re-approves.", InputSchema: obj(map[string]interface{}{"epic": str("epic slug"), "op": str("create | edit | reorder | remove"), "key": str("milestone key, e.g. M2"), "title": str("milestone title (create/edit)"), "goal": str("milestone goal (optional)"), "doneWhen": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "done-when criteria (optional)"}, "targetDate": str("target date YYYY-MM-DD (optional)"), "ordinal": map[string]interface{}{"type": "integer", "description": "ladder position (reorder)"}}, "epic", "op", "key"), Annotations: writeAnn}, } } @@ -416,6 +441,102 @@ func (s *Server) call(ctx context.Context, name string, args json.RawMessage) (m } return toolText(fmt.Sprintf("regenerated %s/%s: created %v, canceled %v, kept in-flight %v — proposed contracts are flagged for human review", a.Epic, a.Milestone, out.Created, out.Canceled, out.Kept), false), nil + case "initiatives_list": + portfolio, err := s.API.ListInitiatives(ctx) + if err != nil { + return nil, err + } + return toolJSON(portfolio) + + case "initiative_tree": + var a struct { + Key string `json:"key"` + } + if err := json.Unmarshal(args, &a); err != nil || a.Key == "" { + return nil, fmt.Errorf("initiative_tree: key is required") + } + tree, err := s.API.GetInitiativeTree(ctx, a.Key) + if err != nil { + return nil, err + } + return toolJSON(tree) + + case "task_get": + var a struct { + Key string `json:"key"` + } + if err := json.Unmarshal(args, &a); err != nil || a.Key == "" { + return nil, fmt.Errorf("task_get: key is required") + } + detail, err := s.API.GetTaskDetail(ctx, a.Key) + if err != nil { + return nil, err + } + return toolJSON(detail) + + case "activity_get": + var a struct { + Tag string `json:"tag"` + Limit int `json:"limit"` + Cursor string `json:"cursor"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("activity_get: invalid arguments") + } + } + activity, err := s.API.GetWorkActivity(ctx, remotestate.WorkActivityOptions{ + Tag: a.Tag, Limit: a.Limit, Cursor: a.Cursor, + }) + if err != nil { + return nil, err + } + return toolJSON(activity) + + case "initiative_create": + var a struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Owner string `json:"owner"` + TargetDate string `json:"targetDate"` + SuccessCriteria []string `json:"successCriteria"` + } + if err := json.Unmarshal(args, &a); err != nil || a.Slug == "" || a.Title == "" { + return nil, fmt.Errorf("initiative_create: slug and title are required") + } + out, err := s.API.CreateInitiative(ctx, remotestate.CreateWorkInitiativeRequest{ + Slug: a.Slug, Title: a.Title, Description: a.Description, + Owner: a.Owner, TargetDate: a.TargetDate, SuccessCriteria: a.SuccessCriteria, + }) + if err != nil { + return nil, err + } + return toolText(fmt.Sprintf("created initiative %s (event seq %d)", out.Key, out.Seq), false), nil + + case "milestone_upsert": + var a struct { + Epic string `json:"epic"` + Op string `json:"op"` + Key string `json:"key"` + Title string `json:"title"` + Goal string `json:"goal"` + DoneWhen []string `json:"doneWhen"` + TargetDate string `json:"targetDate"` + Ordinal *int `json:"ordinal"` + } + if err := json.Unmarshal(args, &a); err != nil || a.Epic == "" || a.Op == "" || a.Key == "" { + return nil, fmt.Errorf("milestone_upsert: epic, op, and key are required") + } + out, err := s.API.UpsertMilestones(ctx, a.Epic, remotestate.WorkMilestoneRequest{ + Op: a.Op, Key: a.Key, Title: a.Title, Goal: a.Goal, + DoneWhen: a.DoneWhen, TargetDate: a.TargetDate, Ordinal: a.Ordinal, + }) + if err != nil { + return nil, err + } + return toolText(fmt.Sprintf("applied %s to milestone %s on %s (event seq %d)", a.Op, a.Key, a.Epic, out.Seq), false), nil + default: return nil, fmt.Errorf("unknown tool %s", name) } diff --git a/internal/workmcp/server_test.go b/internal/workmcp/server_test.go index 4f1bccff..aa58bc8f 100644 --- a/internal/workmcp/server_test.go +++ b/internal/workmcp/server_test.go @@ -21,6 +21,12 @@ type fakeAPI struct { regenerated []string brief *remotestate.WorkEpicBrief failNext error + // orun-initiatives (IN5) + initiatives []remotestate.CreateWorkInitiativeRequest + milestones []remotestate.WorkMilestoneRequest + // refuseWrites, when set, is returned by the envelope writes verbatim — + // the cloud's typed human_only verdict in tests. + refuseWrites error } func (f *fakeAPI) GetWorkSummary(context.Context) (*remotestate.WorkSummary, error) { @@ -96,6 +102,95 @@ func (f *fakeAPI) GetWorkDoc(_ context.Context, specKey, rev string) (*remotesta } return &remotestate.WorkDoc{Revision: "sha256:aa", SpecKey: specKey, Body: "# Doc\n\nbody for " + specKey + " " + rev}, nil } +func (f *fakeAPI) ListInitiatives(context.Context) (*remotestate.WorkPortfolio, error) { + if f.failNext != nil { + return nil, f.failNext + } + return &remotestate.WorkPortfolio{ + Stats: remotestate.WorkFoldStats{OpenTasks: 4, NeedsYou: 1}, + Initiatives: []remotestate.WorkPortfolioInitiativeRow{{ + Key: "ai-native-work", Title: "AI-native work", Status: "at_risk", + EpicCount: 1, + Progress: remotestate.WorkProgressView{Done: 1, Active: 1, Total: 4}, + NeedsYou: []remotestate.WorkNeedsYouReason{{Kind: "approval_drifted", Subject: "demo-epic", Text: "demo-epic approval drifted"}}, + AgentAssignees: []string{"sp_1"}, + Epics: []remotestate.WorkPortfolioEpicRow{{ + Key: "demo-epic", Title: "Demo", + Intent: remotestate.WorkEpicIntentView{State: "approved_drifted", Approval: &remotestate.WorkApprovalView{Revision: "sha256:b2d4", By: remotestate.WorkActor{Type: "user", ID: "u"}}, DocDrifted: true}, + Progress: remotestate.WorkProgressView{Done: 1, Active: 1, Total: 4}, AgentAssignees: []string{"sp_1"}, + }}, + Designs: []remotestate.WorkPortfolioDesignRow{}, + }}, + CoordSeq: 5, ObsSeq: 3, + }, nil +} +func (f *fakeAPI) GetInitiativeTree(_ context.Context, key string) (*remotestate.WorkInitiativeTree, error) { + if f.failNext != nil { + return nil, f.failNext + } + tree := &remotestate.WorkInitiativeTree{ + Epics: []remotestate.WorkTreeEpic{{ + Key: "demo-epic", Title: "Demo", + Intent: remotestate.WorkEpicIntentView{State: "approved", Approval: &remotestate.WorkApprovalView{Revision: "sha256:b2d4", By: remotestate.WorkActor{Type: "user", ID: "u"}}}, + Milestones: []remotestate.WorkTreeMilestone{{ + Key: "M1", Title: "Foundation", State: "active", + Progress: remotestate.WorkProgressView{Done: 1, Total: 2}, + Tasks: []remotestate.WorkTreeTaskRow{{ + Key: "ORN-1", Title: "route reads", Rung: "in_review", + Evidence: remotestate.WorkTaskEvidenceView{PR: &remotestate.WorkEvidencePR{Number: "1", Merged: false}}, + }}, + }}, + }}, + } + tree.Initiative.Key = key + tree.Initiative.Title = "AI-native work" + tree.Initiative.CreatedBy = remotestate.WorkActor{Type: "user", ID: "u"} + tree.Initiative.Status = "on_track" + tree.Initiative.NeedsYou = []remotestate.WorkNeedsYouReason{} + tree.Initiative.ProgressView = remotestate.WorkProgressView{Done: 1, Active: 1, Total: 2} + return tree, nil +} +func (f *fakeAPI) GetTaskDetail(_ context.Context, key string) (*remotestate.WorkTaskDetail, error) { + if f.failNext != nil { + return nil, f.failNext + } + return &remotestate.WorkTaskDetail{ + Task: remotestate.WorkTaskView{Key: key, Title: "route reads", + CreatedBy: remotestate.WorkActor{Type: "user", ID: "u"}, + Lifecycle: remotestate.WorkLifecycle{Rung: "in_review", Evidence: []string{"PR o/r#1 open"}}}, + Epic: &remotestate.WorkItemRef{Key: "demo-epic", Title: "Demo"}, + Evidence: remotestate.WorkTaskEvidenceView{Branch: &remotestate.WorkEvidenceBranch{Name: "claude/route-reads"}}, + ComponentsAffected: []remotestate.WorkComponentTouched{{Path: "internal/remotestate/work.go", Additions: 12}}, + Activity: []remotestate.WorkActivityEntry{{At: "2026-08-01T00:00:00Z", Source: "observation", Kind: "pr_opened", Subject: key, Tag: key, Text: "opened PR #1"}}, + }, nil +} +func (f *fakeAPI) GetWorkActivity(_ context.Context, opts remotestate.WorkActivityOptions) (*remotestate.WorkActivity, error) { + if f.failNext != nil { + return nil, f.failNext + } + return &remotestate.WorkActivity{ + Entries: []remotestate.WorkActivityEntry{{ + At: "2026-08-01T00:00:00Z", Source: "coordination", Kind: "approved", + Subject: "demo-epic", Tag: opts.Tag, Text: "approved demo-epic @b2d4", + Actor: &remotestate.WorkActor{Type: "user", ID: "u"}, + }}, + NextCursor: "c2", + }, nil +} +func (f *fakeAPI) CreateInitiative(_ context.Context, req remotestate.CreateWorkInitiativeRequest) (*remotestate.WorkMutationResponse, error) { + if f.refuseWrites != nil { + return nil, f.refuseWrites + } + f.initiatives = append(f.initiatives, req) + return &remotestate.WorkMutationResponse{Key: req.Slug, Seq: 31}, nil +} +func (f *fakeAPI) UpsertMilestones(_ context.Context, epicKey string, req remotestate.WorkMilestoneRequest) (*remotestate.WorkMutationResponse, error) { + if f.refuseWrites != nil { + return nil, f.refuseWrites + } + f.milestones = append(f.milestones, req) + return &remotestate.WorkMutationResponse{Key: epicKey + "#" + req.Key, Seq: 32}, nil +} func fixtureSummary() *remotestate.WorkSummary { return &remotestate.WorkSummary{ @@ -179,13 +274,15 @@ func TestInitializeAndToolSurface(t *testing.T) { "task_create", "task_comment", "task_assign", "contract_propose", "epic_brief", "milestone_get", "design_get", "initiative_get", "design_propose", "task_regenerate", + "initiatives_list", "initiative_tree", "task_get", "activity_get", + "initiative_create", "milestone_upsert", } { if !names[want] { t.Errorf("missing tool %s", want) } } - if len(tools) != 15 { - t.Errorf("tool surface = %d tools, want exactly 15 (closed: 9 reads + 6 writes — WH5)", len(tools)) + if len(tools) != 21 { + t.Errorf("tool surface = %d tools, want exactly 21 (closed: 13 reads + 8 writes — IN5)", len(tools)) } // The lie is unrepresentable: no lifecycle write, no pin (WP-3, WP-10). // The sweep runs over the composed server's tools/list (the merged @@ -243,6 +340,8 @@ func TestWireAnnotations(t *testing.T) { "work_query": true, "work_get": true, "spec_get": true, "work_timeline": true, "spec_doc": true, "epic_brief": true, "milestone_get": true, "design_get": true, "initiative_get": true, + "initiatives_list": true, "initiative_tree": true, "task_get": true, + "activity_get": true, } for _, tool := range Tools() { want := map[string]bool{ @@ -346,3 +445,110 @@ func TestErrorShapes(t *testing.T) { t.Fatalf("unknown tool shape: %s", text) } } + +// TestInitiativeSurfaceReads (IN5): the four new folds come back as JSON +// with the derived fields intact — status words, needs-you reasons, rungs +// with evidence, and the tagged tail's server sentences. +func TestInitiativeSurfaceReads(t *testing.T) { + s := &Server{API: &fakeAPI{summary: fixtureSummary()}, Workspace: "ws_1"} + responses := rpc(t, s, + callLine(1, "initiatives_list", `{}`), + callLine(2, "initiative_tree", `{"key":"ai-native-work"}`), + callLine(3, "task_get", `{"key":"ORN-1"}`), + callLine(4, "activity_get", `{"tag":"demo-epic","limit":10}`), + callLine(5, "initiative_tree", `{}`), + callLine(6, "task_get", `{}`), + ) + text, isErr := resultText(t, responses[0]) + if isErr || !strings.Contains(text, `"status": "at_risk"`) || !strings.Contains(text, "approval drifted") { + t.Fatalf("initiatives_list lacks the derived portfolio: %s", text) + } + if !strings.Contains(text, `"openTasks": 4`) { + t.Fatalf("initiatives_list lacks fold-stats: %s", text) + } + text, isErr = resultText(t, responses[1]) + if isErr || !strings.Contains(text, `"state": "approved"`) || !strings.Contains(text, `"rung": "in_review"`) { + t.Fatalf("initiative_tree lacks intent + rungs: %s", text) + } + if !strings.Contains(text, `"progressView"`) { + t.Fatalf("initiative_tree lacks the progress fold: %s", text) + } + text, isErr = resultText(t, responses[2]) + if isErr || !strings.Contains(text, `"branch"`) || !strings.Contains(text, "opened PR #1") { + t.Fatalf("task_get lacks evidence + activity: %s", text) + } + text, isErr = resultText(t, responses[3]) + if isErr || !strings.Contains(text, "approved demo-epic @b2d4") || !strings.Contains(text, `"nextCursor": "c2"`) { + t.Fatalf("activity_get lacks the tagged tail: %s", text) + } + if text, isErr = resultText(t, responses[4]); !isErr || !strings.Contains(text, "key is required") { + t.Fatalf("initiative_tree without a key must fail: %s", text) + } + if text, isErr = resultText(t, responses[5]); !isErr || !strings.Contains(text, "key is required") { + t.Fatalf("task_get without a key must fail: %s", text) + } +} + +// TestInitiativeSurfaceWrites (IN5): the two envelope writes go through the +// mutators with their arguments intact. +func TestInitiativeSurfaceWrites(t *testing.T) { + api := &fakeAPI{summary: fixtureSummary()} + s := &Server{API: api, Workspace: "ws_1"} + responses := rpc(t, s, + callLine(1, "initiative_create", `{"slug":"ai-native-work","title":"AI-native work","successCriteria":["agents ship epics"]}`), + callLine(2, "milestone_upsert", `{"epic":"demo-epic","op":"reorder","key":"M2","ordinal":0}`), + callLine(3, "initiative_create", `{"title":"missing slug"}`), + callLine(4, "milestone_upsert", `{"epic":"demo-epic","key":"M2"}`), + ) + text, isErr := resultText(t, responses[0]) + if isErr || !strings.Contains(text, "created initiative ai-native-work (event seq 31)") { + t.Fatalf("initiative_create result: %s", text) + } + if len(api.initiatives) != 1 || api.initiatives[0].SuccessCriteria[0] != "agents ship epics" { + t.Fatalf("initiatives = %+v", api.initiatives) + } + text, isErr = resultText(t, responses[1]) + if isErr || !strings.Contains(text, "applied reorder to milestone M2 on demo-epic") { + t.Fatalf("milestone_upsert result: %s", text) + } + // Ordinal 0 must survive the wire (reorder to the top of the ladder). + if len(api.milestones) != 1 || api.milestones[0].Ordinal == nil || *api.milestones[0].Ordinal != 0 { + t.Fatalf("milestones = %+v", api.milestones) + } + if text, isErr = resultText(t, responses[2]); !isErr || !strings.Contains(text, "slug and title are required") { + t.Fatalf("initiative_create without a slug must fail: %s", text) + } + if text, isErr = resultText(t, responses[3]); !isErr || !strings.Contains(text, "epic, op, and key are required") { + t.Fatalf("milestone_upsert without an op must fail: %s", text) + } +} + +// TestHumanOnlyRefusalPassesThrough (IN-4): when a write brushes a human-only +// decision, the cloud answers with the typed WorkError("human_only", …) and +// the MCP layer surfaces it VERBATIM — code included — so the model can tell +// "not allowed for you" from "does not exist". The refusal is an isError +// result (a verdict to reason about), never a protocol fault. +func TestHumanOnlyRefusalPassesThrough(t *testing.T) { + refusal := &remotestate.APIError{ + Code: "human_only", + Message: "approving an epic is a human decision", + Status: 403, + } + s := &Server{API: &fakeAPI{summary: fixtureSummary(), refuseWrites: refusal}, Workspace: "ws_1"} + responses := rpc(t, s, + callLine(1, "milestone_upsert", `{"epic":"demo-epic","op":"edit","key":"M1","title":"x"}`), + callLine(2, "initiative_create", `{"slug":"x","title":"X"}`), + ) + for i, r := range responses { + text, isErr := resultText(t, r) + if !isErr { + t.Fatalf("refusal %d not an isError result: %s", i+1, text) + } + if !strings.Contains(text, "human_only") { + t.Fatalf("refusal %d dropped the typed code: %s", i+1, text) + } + if !strings.Contains(text, "approving an epic is a human decision") { + t.Fatalf("refusal %d dropped the server message: %s", i+1, text) + } + } +} diff --git a/specs/orun-initiatives/README.md b/specs/orun-initiatives/README.md new file mode 100644 index 00000000..3779a40d --- /dev/null +++ b/specs/orun-initiatives/README.md @@ -0,0 +1,98 @@ +# Spec: orun-initiatives (the Initiatives surface — orun half) + +**Work gets one name and one home: Initiatives. The truth model does not +move — lifecycle stays the v2 derived fold, approval stays v4's human-only +sealed decision — but the surface over it consolidates: four new derived +reads (portfolio, tree, task detail, tagged activity), a work MCP that +grows from 15 to 21 tools with typed `human_only` refusals for the +decisions agents may not make, and a CLI group `orun initiatives` that +replaces `orun work` (which survives one release as a hidden deprecated +alias). Nothing here writes anything new: every gesture maps to an +existing mutator.** + +> **The authoritative epic lives in orun-cloud** +> (`specs/epics/orun-initiatives/`: README, design, api-and-mcp — cluster +> **IN**, milestones IN0→IN6). This folder is the orun half: what this +> repo owns and must hold true. The wire contract is +> `specs/epics/orun-initiatives/api-and-mcp.md` (§1 response shapes, §4 +> MCP roster, §5 CLI surface); field-level truth is +> `packages/contracts/src/work.ts`. + +## Status + +| Field | Value | +|-------|-------| +| Status | **In progress — IN5+IN6 orun legs** (wire client for the four reads + initiative create + milestone upsert · MCP roster 15 → 21 · `orun initiatives` group · `orun work` hidden deprecated alias · docs site renamed) | +| Builds on | `specs/orun-work/` (v2: the fold, the two-log model, import, MCP), `specs/orun-work-v4/` (the hierarchy: intent ladder, sealed briefs, milestones, designs) | +| Coordinates with | orun-cloud `specs/epics/orun-initiatives/` (authoritative; the four read endpoints IN1–IN2, console IN3–IN4) | +| Wire | `/v1/organizations/{org}/work/*` — unchanged prefix, additive reads only (IN-A) | +| Milestone prefix | **IN** (this repo's legs land inside IN5/IN6) | + +## What this repo owns + +1. **The wire client grows, reads only** (`internal/remotestate/work.go`). + Four derived reads mirroring the cloud contracts: + `ListInitiatives` (portfolio: fold-stats, needs-you reasons, epic and + design rows), `GetInitiativeTree` (the full hierarchy — epics with + intent, milestones with derived state, tasks with rungs and evidence, + docs, designs), `GetTaskDetail` (rung with evidence, ancestry, + components affected, activity tail), `GetWorkActivity` (the tagged + two-log tail, ancestry-scoped, cursor-paged). Plus the two envelope + writes the surface needed on this seam: `CreateInitiative` + (POST /work/initiatives — the route that already existed) and + `UpsertMilestones` (POST /work/epics/{epic}/milestones, one ladder edit + per call). `WorkSummary` learns the summary's `initiatives` array. + Nothing here can carry a status: every new struct is a fold projection. +2. **The MCP grows 15 → 21, the guardrails extend** (`internal/workmcp`). + New reads: `initiatives_list`, `initiative_tree`, `task_get`, + `activity_get`. New writes: `initiative_create`, `milestone_upsert`. + Legacy tool names all stay registered (IN-1 compatibility ledger). + **Still absent, on purpose:** no approve, adopt, supersede, or pin + tool — the forbidden-name sweep extends over the new roster, and when + a write brushes a human-only decision the cloud's typed + `WorkError("human_only", …)` verdict surfaces verbatim (code included) + so a model can tell "not allowed for you" from "does not exist" (IN-4). + Asserted by test. +3. **CLI: `orun initiatives` replaces `orun work`** (`cmd/orun/initiatives.go`). + The group per api-and-mcp.md §5: `list` (portfolio table), `view` + (the tree as an indented ladder: intent chips with @revision and named + drift, milestone states, rung words with evidence hints), `create` + (`--title`, repeatable `--why` → success criteria), `edit`/`cancel` + (the item mutators, unchanged), `import` (the v2 importer, unchanged + wire), `task view`/`task create`, `activity`, `doc pull`, and + `design list`/`design propose`. Every subcommand keeps `--workspace`, + `--backend-url`, `--json` (pretty-encoded response structs). +4. **The deprecation of `orun work`** (`cmd/orun/work.go`). The group + survives exactly one release as a hidden, `Deprecated:`-marked alias + whose subcommands (`import`, `list`, `edit`, `cancel`) forward to the + same run functions the new group registers — zero duplicated logic, + nothing to drift. `orun spec pull` and `orun epic pull` are untouched + (they already speak the plane's nouns). +5. **Docs.** `website/docs/cli/orun-initiatives.md` replaces + `cli/orun-work.md` (redirect stub kept), sidebar renamed in place, + cross-links in the spec/epic/mcp pages updated. + +## Invariants this repo enforces (beyond v2/v4's, which all stand) + +1. The delivery fold and `internal/worklens` (the conformance oracle) are + untouched — the new surface renders folds, it never re-derives them. +2. No new read gains a write shadow: the portfolio/tree/detail/activity + structs have no mutator, and the client offers no call that could + store a status, a progress number, or a health word. +3. MCP tool names never change once shipped; growth is additive and the + forbidden-fragment sweep (`status`, `pin`, `lifecycle`, `approve`, + `adopt`) runs over the whole 21-tool roster. +4. `human_only` refusals pass through the MCP layer verbatim — tests pin + the code and the server sentence in the isError result. +5. `orun spec pull` and `orun epic pull` behave byte-identically before + and after this epic. + +## Read order + +1. orun-cloud `specs/epics/orun-initiatives/README.md` — the one-name + decision, invariants, milestones IN0→IN6. +2. orun-cloud `specs/epics/orun-initiatives/api-and-mcp.md` — the wire + contract this repo implements against (§1 reads, §4 MCP, §5 CLI). +3. This file — the orun-half ownership and its enforcement points. +4. `specs/orun-work/` and `specs/orun-work-v4/` — the substrate this + never breaks. diff --git a/website/docs/cli/orun-epic.md b/website/docs/cli/orun-epic.md index 6845e048..66563d6a 100644 --- a/website/docs/cli/orun-epic.md +++ b/website/docs/cli/orun-epic.md @@ -65,5 +65,5 @@ canonicalization drift can exist. ## Related - [`orun spec`](./orun-spec.md) — the v2 spec brief this generalizes -- [`orun work`](./orun-work.md) — import and inspect the hierarchy +- [`orun initiatives`](./orun-initiatives.md) — import and inspect the hierarchy - [`orun mcp`](./orun-mcp.md) — `epic_brief` serves the same sealed bytes diff --git a/website/docs/cli/orun-initiatives.md b/website/docs/cli/orun-initiatives.md new file mode 100644 index 00000000..696edf4f --- /dev/null +++ b/website/docs/cli/orun-initiatives.md @@ -0,0 +1,184 @@ +--- +title: orun initiatives +--- + +`orun initiatives` is the CLI face of the **work plane** — orun's +delivery-derived work tracker (specs/orun-initiatives, on the orun-work +substrate). Its central invariant: **lifecycle is a derived query, not a +stored status**. A task's rung (Draft → Ready → In Progress → In Review → +Done → Released) is computed by folding two append-only logs — the +coordination log (human/agent events) and the observation log (facts the +platform observed: branches, PRs, merges, gate verdicts) — so nobody, human +or agent, can *set* a status anywhere. There is no `set-status` subcommand, +deliberately and permanently. + +```bash +orun initiatives [flags] +``` + +Requires a linked workspace (see [`orun cloud`](./orun-cloud.md)) or explicit +`--workspace` / `--backend-url` flags. Every subcommand accepts `--json` +(pretty-encoded response structs) for scripting. + +:::note +`orun initiatives` replaces `orun work`. The old group survives one release +as a hidden alias that forwards to the same implementations, then it is +removed. +::: + +## Subcommands + +| Subcommand | Purpose | +| --- | --- | +| `list` | The portfolio: every initiative with derived status, progress, needs-you | +| `view ` | One initiative's tree, rendered as an indented ladder | +| `create` | Create an initiative envelope (`--title`, repeatable `--why`) | +| `edit ` | Edit an item's envelope (title/description/owner/target/…) | +| `cancel ` | Retire a task or epic — the append-only "delete" | +| `import ` | Map a `specs/` tree to the hierarchy and apply to the workspace | +| `task view ` | Task detail: rung ladder, evidence, components, activity | +| `task create` | Create a task (`--epic`, `--milestone`, contract flags) | +| `activity ` | The tagged activity tail for any noun | +| `doc pull ` | Print an epic spec / design doc (markdown) to stdout | +| `design list ` | List an initiative's design runs | +| `design propose ` | Start a Draft design run | + +## `orun initiatives list` + +```bash +orun initiatives list --workspace my-org +``` + +Prints the portfolio table — `KEY · TITLE · STATUS · PROGRESS · NEEDS YOU · +TARGET` — plus the workspace fold-stats footer. Everything is derived on +read: **status** is `planning` until the initiative's first approved epic +and the v4 health fold (`on_track` / `at_risk` / `off_track`) afterwards; +**progress** is `done/total` over non-canceled member tasks; **needs you** +names the first reason the initiative waits on a human (approval drifted, +awaiting approval, review open, milestone idle, design in review). + +## `orun initiatives view` + +```bash +orun initiatives view my-initiative +``` + +Renders one initiative's whole tree as an indented ladder: + +- the initiative header — status, owner, target, progress, needs-you lines; +- each **epic** with its intent chip (`intent approved @b2d4aa00`, drift + named when the doc or ladder moved since approval) and progress; +- each **milestone** with derived state (`complete` / `active` / + `upcoming` — pure ladder arithmetic) and `done/total`; +- each **task** with its rung word and the evidence that put it there + (`branch …; PR #9 open, checks 3/4`), plus a `backlog:` section for + tasks outside any milestone; +- the epic's documents (spec + designs, with revision and open threads) + and the initiative's design runs. + +## `orun initiatives create` + +```bash +orun initiatives create --title "Payments v2" \ + --why "checkout conversion +2pt" --why "PCI scope shrinks" \ + --owner usr_ab12 --target 2026-12-01 +``` + +Creates the strategic envelope. `--why` is repeatable — each occurrence +becomes one success criterion. `--slug` defaults to the lowercased, +hyphenated title. An initiative has no lifecycle and no contract: its +status, progress, and health derive from its member epics' logs. + +## `orun initiatives edit` / `orun initiatives cancel` + +```bash +orun initiatives edit my-epic --target 2026-10-01 --initiative payments-v2 +orun initiatives cancel WRK-12 --yes +``` + +`edit` sends only the flags you pass through the one `item_edited` mutator +(pass an empty value to clear a field). `cancel` folds a terminal +`canceled` state onto a task or epic — attributed, append-only, permanent; +initiatives have no lifecycle to cancel. + +## `orun initiatives import` + +Parses a spec tree into a deterministic import plan for the planning +hierarchy: + +| In the repo | Becomes | +| --- | --- | +| Epic folder's `README.md` | An **Epic** with a content-addressed doc digest | +| `implementation-plan.md` `## ` headings | **Milestones** on that epic | +| Checklist items under a heading | **Tasks** inside that milestone | +| `roadmap.md` cluster rows | **Initiatives** grouping the epics | + +```bash +orun initiatives import specs/ --dry-run # print the plan, change nothing +orun initiatives import specs/ --workspace my-org # apply (idempotent re-runs) +orun initiatives import specs/ --prefix PAY # task-key prefix (default WRK) +``` + +Import writes intent, never decisions: no lifecycle, reviews, or approvals +cross the wire, and re-imports are idempotent (created entities carry their +import provenance). Pre-v4 corpora migrate key-preservingly into the newly +minted milestones. + +## `orun initiatives task` + +```bash +orun initiatives task view WRK-42 +orun initiatives task create --epic my-epic --milestone M2 \ + --title "wire the reader" --contract-done-when "GET returns the fold" \ + --contract-done-when "tests pass" +``` + +`task view` prints the whole task page: the rung ladder with the current +rung bracketed, ancestry (initiative/epic/milestone), the folded delivery +evidence (branch, PR with checks, gate results), components affected +(observation diffstats — empty when the world reported none, never +invented), and the task-scoped activity tail. `task create` authors the +envelope and contract (`--contract-goal`, repeatable `--contract-done-when` +/ `--contract-affects` / `--contract-gate` / `--contract-dep`); the rung +derives from observations afterwards. + +## `orun initiatives activity` + +```bash +orun initiatives activity my-epic --limit 100 +``` + +The tagged tail: both logs folded into one reverse-chronological list of +`TIME TEXT [TAG]` lines. The tag trail is ancestry — filtering by an epic +covers its milestones' tasks, docs, and designs; an initiative covers its +whole subtree. Page with `--cursor`. + +## `orun initiatives doc pull` + +```bash +orun initiatives doc pull my-epic > SPEC.md +orun initiatives doc pull my-epic --rev sha256:b2d4… +``` + +Prints an item's content-addressed cloud document (an epic's spec or a +design's doc) as markdown on stdout — latest revision unless `--rev` pins +one. For the *approval-sealed, verified* brief, use +[`orun epic pull`](./orun-epic.md). + +## `orun initiatives design` + +```bash +orun initiatives design list payments-v2 +orun initiatives design propose payments-v2 --title "Split the ledger" \ + --doc-ref sha256:ab12… --proposal '{"epics":[…]}' +``` + +A design is a **proposal** — humans review, compare, and adopt; adoption +mints the epics and stays human-only (there is no adopt subcommand, and the +cloud refuses agent adoption with a typed `human_only` verdict). + +## Related + +- [`orun spec`](./orun-spec.md) — frozen, content-addressed spec briefs +- [`orun epic`](./orun-epic.md) — the approval-sealed epic brief (v4) +- [`orun mcp`](./orun-mcp.md) — the agent tool surface over the same fold diff --git a/website/docs/cli/orun-mcp.md b/website/docs/cli/orun-mcp.md index 7ee1ac9f..e7190da5 100644 --- a/website/docs/cli/orun-mcp.md +++ b/website/docs/cli/orun-mcp.md @@ -7,12 +7,13 @@ title: orun mcp dependency-free JSON-RPC 2.0 server over stdio that gives an agent hands on everything orun through a single connection. -One loop composes two tool planes — **40 tools under one initialize**: +One loop composes two tool planes — **46 tools under one initialize**: -- **The work plane** (15 tools) — orun's delivery-derived work tracker and - its planning hierarchy: tasks with *derived* lifecycle and evidence, - sealed spec and epic briefs, initiative/design/milestone reads, - mutator-only writes. Mounted when a workspace scope resolves. +- **The work plane** (21 tools) — orun's delivery-derived work tracker and + its planning hierarchy: the initiatives portfolio and tree, tasks with + *derived* lifecycle and evidence, sealed spec and epic briefs, + initiative/design/milestone reads, mutator-only writes. Mounted when a + workspace scope resolves. - **The platform plane** (25 tools) — the Orun Cloud public API: catalog, runs and logs, audit, events, access, usage, billing, config, secret metadata, webhooks. 19 reads plus 6 policy-gated writes. Mounted whenever @@ -90,7 +91,7 @@ orun mcp serve [--workspace <ref>] [--backend-url <url>] [--read-only] | --- | --- | | `--workspace <ref>` | Target workspace (org id or slug; defaults to the linked repo's). Mounts the work plane and becomes the platform tools' default `workspace`. | | `--backend-url <url>` | Backend URL (Orun Cloud or self-hosted). | -| `--read-only` | Drop the 6 platform write tools from the roster (34 tools instead of 40). Filtered from `tools/list` *and* blocked at execution. | +| `--read-only` | Drop the 6 platform write tools from the roster (40 tools instead of 46). Filtered from `tools/list` *and* blocked at execution. | `--read-only` deliberately does **not** touch the work plane's write tools: they are mutator-shaped by design (one audited mutator surface for UI, MCP, @@ -108,10 +109,14 @@ orun mcp tools --json # the same rows as JSON orun mcp tools --read-only # the roster as `serve --read-only` advertises it ``` -## The work plane (15 tools) +## The work plane (21 tools) | Tool | Kind | Purpose | | --- | --- | --- | +| `initiatives_list` | read | The portfolio: every initiative with derived status, progress, needs-you reasons, agent assignees, epic and design rows | +| `initiative_tree` | read | One initiative's full hierarchy: epics with intent, milestones with derived state, tasks with rungs and evidence, docs, designs | +| `task_get` | read | One task's whole page: rung with evidence, ancestry, delivery evidence, components affected, activity tail | +| `activity_get` | read | The tagged activity tail for any noun — both logs folded, ancestry-scoped, cursor-paged | | `work_query` | read | The fold summary — every task's derived rung WITH its evidence | | `work_get` | read | One task in full (contract, lifecycle, evidence, pins) | | `work_timeline` | read | One item's unified timeline: coordination and observation logs interleaved by time, evidence attached | @@ -127,8 +132,10 @@ orun mcp tools --read-only # the roster as `serve --read-only` advertises it | `contract_propose` | write | Edit a task contract — applied AND flagged with a review comment | | `design_propose` | write | Create a Draft design under an initiative (doc ref + structured proposal) — a *proposal*; humans review, compare, adopt | | `task_regenerate` | write | Re-plan one milestone in one verdict batch: planned tasks cancel, in-flight tasks survive, every contract flagged for review | +| `initiative_create` | write | Create an initiative envelope (slug, title, why) — agents may draft it; the why stays human-edited | +| `milestone_upsert` | write | One ladder edit (create/edit/reorder/remove) on an epic's milestones — authored intent; progress stays derived | -Four properties are structural, not policy: +Five properties are structural, not policy: - **No `task_update_status` exists.** Lifecycle derives from delivery facts; an agent moves a task to In Review by *opening a PR*, not by calling a tool. @@ -144,6 +151,10 @@ Four properties are structural, not policy: applied through the normal mutator *and* flagged for human review in the same call — an agent cannot silently redefine its own definition of done, or its own plan. +- **Decision refusals are typed.** When a write brushes a human-only + decision, the cloud answers with its typed `human_only` verdict and the + MCP surfaces it verbatim — so a model can tell "not allowed for you" + from "does not exist". ## The platform plane (25 tools) @@ -235,7 +246,7 @@ from `orun-work` when the surface unified). ## Related -- [`orun work`](./orun-work.md) — the same work fold in the terminal +- [`orun initiatives`](./orun-initiatives.md) — the same work fold in the terminal - [`orun spec`](./orun-spec.md) — sealed briefs (`spec_get`'s CLI twin) - [`orun auth`](./orun-auth.md) / [`orun cloud`](./orun-cloud.md) — the session and repo link the server mounts from diff --git a/website/docs/cli/orun-spec.md b/website/docs/cli/orun-spec.md index 6d6e9399..2e4fe070 100644 --- a/website/docs/cli/orun-spec.md +++ b/website/docs/cli/orun-spec.md @@ -51,6 +51,6 @@ remotely are skipped, and the work ref advances atomically. ## Related -- [`orun work`](./orun-work.md) — import and list the work lens +- [`orun initiatives`](./orun-initiatives.md) — import and inspect the work lens - [`orun epic`](./orun-epic.md) — the approval-sealed epic brief, this command's v4 superset - [`orun mcp`](./orun-mcp.md) — `spec_get` serves the same sealed snapshot to agents diff --git a/website/docs/cli/orun-work.md b/website/docs/cli/orun-work.md index 3e4b572b..5eae0bce 100644 --- a/website/docs/cli/orun-work.md +++ b/website/docs/cli/orun-work.md @@ -1,78 +1,21 @@ --- -title: orun work +title: orun work (deprecated) --- -`orun work` is the CLI face of the **work lens** — orun's delivery-derived -work tracker (specs/orun-work v2). Its central invariant: **lifecycle is a -derived query, not a stored status**. A task's rung (Draft → Ready → -In Progress → In Review → Done → Released) is computed by folding two -append-only logs — the coordination log (human/agent events) and the -observation log (facts the platform observed: branches, PRs, merges, gate -verdicts, live revisions) — so nobody, human or agent, can *set* a status -anywhere. The CLI has no `orun work set-status`, deliberately and permanently. +`orun work` has been renamed to [`orun initiatives`](./orun-initiatives.md) +(specs/orun-initiatives). The old group survives one release as a hidden +alias whose subcommands (`import`, `list`, `edit`, `cancel`) forward to the +same implementations, then it is removed. ```bash -orun work <subcommand> [flags] +# before # now +orun work import specs/ orun initiatives import specs/ +orun work list orun initiatives list +orun work edit <key> orun initiatives edit <key> +orun work cancel <key> orun initiatives cancel <key> ``` -Requires a linked workspace (see [`orun cloud`](./orun-cloud.md)) or explicit -`--workspace` / `--backend-url` flags. - -## Subcommands - -| Subcommand | Purpose | -| --- | --- | -| `import` | Map a `specs/` tree to the full hierarchy (Initiatives → Epics → Milestones → Tasks) and apply to the workspace | -| `list` | Render the workspace's tasks with their derived rungs and evidence | - -## `orun work import` - -Parses a spec tree into a deterministic import plan for the **planning -hierarchy** (orun-work v4): - -| In the repo | Becomes | -| --- | --- | -| Epic folder's `README.md` | An **Epic** with a content-addressed doc digest | -| `implementation-plan.md` `## <KEY> — <Title>` headings | **Milestones** on that epic (Goal / Done-when / Deps → the milestone contract) | -| Checklist items under a heading | **Tasks** inside that milestone (one task per milestone materialized where none exist — the v2 mapping, preserved 1:1 under the new level) | -| `roadmap.md` cluster rows | **Initiatives** grouping the epics | - -```bash -orun work import specs/ --dry-run # print the plan, change nothing -orun work import specs/ --workspace my-org # apply (idempotent re-runs) -orun work import specs/ --prefix PAY # task-key prefix (default WRK) -``` - -Dry-run prints the plan's shape (`initiatives: / specs: / milestones: / -tasks:`); apply reports created / skipped counts per level plus how many -pre-existing tasks were **migrated into milestones**. - -- **Import writes intent, never decisions.** No lifecycle, no reviews, no - approvals are imported — tasks surface wherever the logs say they are - (usually Draft/Ready), and a fixture asserts no `approved` or - `design_adopted` event is ever emitted `via: import`. -- Apply is **idempotent**: every created entity is labeled with its import - provenance, so re-importing the same tree is a no-op. -- **Key-preserving migration.** A workspace imported under v2 (one task per - milestone, no milestone level) upgrades in place: existing tasks keep - their keys and history and are attached to the newly minted milestones — - nothing is recreated. -- Milestone dependency tokens rewrite to the allocated keys. -- `--json` emits the plan/result as JSON for scripting. - -## `orun work list` - -```bash -orun work list --workspace my-org -``` - -Prints each task's key, title, derived rung **with the evidence that put it -there** (e.g. `in_review PR #123 open @ abc1234`), pins rendered beside -observed truth, and blocked flags. What you see is the fold's output — the -same fold the console and the MCP serve. - -## Related - -- [`orun spec`](./orun-spec.md) — frozen, content-addressed spec briefs -- [`orun epic`](./orun-epic.md) — the approval-sealed epic brief (v4) -- [`orun mcp`](./orun-mcp.md) — the agent tool surface over the same fold +See [`orun initiatives`](./orun-initiatives.md) for the full group — the +portfolio, tree view, task detail, activity tail, doc pull, and design +runs. The invariant is unchanged: lifecycle is a derived query over two +append-only logs, never a stored status. diff --git a/website/sidebars.js b/website/sidebars.js index bc76ece5..ceb6e506 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -110,7 +110,7 @@ const sidebars = { 'cli/orun-secrets', 'cli/orun-integrations', 'cli/orun-policy', - 'cli/orun-work', + 'cli/orun-initiatives', 'cli/orun-spec', 'cli/orun-epic', 'cli/orun-mcp',