diff --git a/changelog.d/sprint-proposals.md b/changelog.d/sprint-proposals.md new file mode 100644 index 00000000..c4d92c26 --- /dev/null +++ b/changelog.d/sprint-proposals.md @@ -0,0 +1,19 @@ +Agents can propose a sprint for something the repo needs outside their task, and the owner browses proposals on the +site. + +### Added + +- `pm sprint propose --title "…"` records a proposal: an open sprint with its frame and an `## Evidence` + section, which becomes its first finding. Its output lists the project's other proposals and open sprints, so an + agent can drop its own as a duplicate. +- A proposal stays out of `pm show` (every level, `--json` too) and `pm task ready`; `pm show --sprint ID` shows it. + It takes no task until `pm sprint promote `, run when the owner asks, makes it an ordinary sprint. +- `pm sprint close --dropped --reason "…"` drops a proposal; `pm sprint reopen` makes it a + proposal again. It refuses while an item under the proposal is open or a design-page draft is tagged with it. +- The site lists proposals in a collapsed Proposed section on the overview and each project page, shows a dropped one + among the closed sprints with its reason, and the day page names a sprint proposed, promoted or dropped. + +### Changed + +- A sprint page with no tasks says "No tasks yet." in place of an empty chart. +- `pm feedback add` is for pm itself; the rules now send what the repo needs to `pm sprint propose`. diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 6e4621eb..93fe750f 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -5,10 +5,7 @@ import ( "fmt" "github.com/Yeeef/pm/internal/cli/fsutil" "io" - "os" - "path/filepath" "reflect" - "regexp" "strconv" "strings" "time" @@ -260,21 +257,6 @@ const ( liveWindow = int(session.LiveWindow / time.Second) // seconds, as refusals name it ) -func homeDir() string { - if h := os.Getenv("HOME"); h != "" { - return h - } - h, _ := os.UserHomeDir() - return h -} - -func codexHome() string { - if h := os.Getenv("CODEX_HOME"); h != "" { - return h - } - return filepath.Join(homeDir(), ".codex") -} - // holder is who holds an item, as pm show gives it: the session pm task claim recorded, when, and whether it is live. // The work store has no assignee, which Python pm gives from bd; it is always null here. type holder struct { @@ -319,17 +301,3 @@ func (e *env) body(p *Parsed) (string, error) { } return readTextFile(path[len(path)-1], e.stdin) } - -var sentenceEnd = regexp.MustCompile(`^(.+?[.!?])(` + records.PyS + `|$)`) // Python's \s - -// firstSentence is the first sentence of text's first paragraph, cut to limit characters with "…". -func firstSentence(text string, limit int) string { - s := records.FirstPara(text) - if m := sentenceEnd.FindStringSubmatch(s); m != nil { - s = m[1] - } - if r := []rune(s); len(r) > limit { - return strings.TrimRightFunc(string(r[:limit-1]), config.IsPySpace) + "…" - } - return s -} diff --git a/internal/cli/agentcmds.go b/internal/cli/agentcmds.go index 2360f218..ee4bbf50 100644 --- a/internal/cli/agentcmds.go +++ b/internal/cli/agentcmds.go @@ -34,6 +34,8 @@ var agentCommands = map[string]agentCommand{ "project close": cmdProjectClose, "project reopen": cmdProjectReopen, "sprint open": cmdSprintOpen, + "sprint propose": cmdSprintPropose, + "sprint promote": cmdSprintPromote, "sprint close": cmdSprintClose, "sprint move": cmdSprintMove, "sprint edit": cmdSprintEdit, diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 417e317a..75acae07 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -31,7 +31,7 @@ var tree = &command{ { Name: "show", Help: "compact status of open projects for agents, level by level", - Description: "Project state, level by level. Without a flag, the top level: push failures, tasks other live sessions hold, the site, the repo's pm feedback doc, and per open project a line with each open owner request and undelivered reply. Each level names the command for the next: --project, then --sprint, then --record, then --record with --section. pm show ID [--json] prints one item of any type instead: its fields, holder, blockers, children, needs and comments (pm show ID --help).", + Description: "Project state, level by level. Without a flag, the top level: push failures, tasks other live sessions hold, the site, the repo's pm feedback doc, and per open project a line with each open owner request and undelivered reply. Each level names the command for the next: --project, then --sprint, then --record, then --record with --section. pm show ID [--json] prints one item of any type instead: its fields, holder, blockers, children, needs and comments (pm show ID --help). A sprint proposal (pm sprint propose) shows at no level, --json included: the site lists proposals; --sprint ID and pm show ID show one by id.", Args: []arg{ {Flags: []string{"--json"}, Dest: "json", Kind: switchOn, Help: "every level's data as one JSON object"}, {Flags: []string{"--project"}, Dest: "project", Metavar: []string{"NAME"}, Kind: value, Help: "one open project (its name or id): its goal, owner requests in full, the repo's pm feedback doc, open sprints with their tasks, and last decisions"}, @@ -84,7 +84,7 @@ var tree = &command{ { Name: "add", Help: "append an entry to the repo's pm feedback doc; the text with --text", - Description: "When pm got in the way (a confusing refusal, a missing command, a rule that cost time), say once what happened and what would have helped. Appends a dated entry with this session's id to the repo's one feedback doc, records/docs/pm-feedback.md, creating it on first use; --project, --sprint and --task tag the entry with what it is about. pm show and pm show --project link the doc.", + Description: "When pm itself got in the way (a confusing refusal, a missing command, a rule that cost time), say once what happened and what would have helped; what the repo needs is a sprint proposal (pm sprint propose). Appends a dated entry with this session's id to the repo's one feedback doc, records/docs/pm-feedback.md, creating it on first use; --project, --sprint and --task tag the entry with what it is about. pm show and pm show --project link the doc.", Groups: []bool{false}, Args: []arg{ {Flags: []string{"--project"}, Dest: "project", Metavar: []string{"NAME"}, Kind: value, Help: "the project the feedback is about"}, @@ -363,16 +363,37 @@ var tree = &command{ {Flags: []string{"--text"}, Dest: "text", Kind: value, Def: "", Group: 1, Help: "the same body inline, for one plain line only; several lines, backticks, $ or quotes go in --text-file"}, }, }, + { + Name: "propose", + Help: "propose a sprint for the owner to promote; frame and evidence with --text ('## Goal', '## Scope', '## Done when', '## Evidence')", + Description: "Propose a sprint for something the repo needs outside your task: an open sprint epic labelled proposal, with its record, as pm sprint open makes them, whose body's '## Evidence' (what you saw: a command and its output, a file and line, a number) becomes the record's first finding. A proposal is an open sprint labelled proposal: pm show and pm task ready leave it out, pm show --sprint ID and pm show ID show it, and the site lists it in a collapsed Proposed section. It takes no task (pm task add --sprint, pm task move --to) until pm sprint promote, run when the owner asks, makes it an ordinary sprint. Then it prints the project's other proposals and open sprints, each with its state, id, title and goal: if one covers yours, drop yours with pm sprint close --dropped --reason \"duplicate of \". Refuses as pm sprint open does, and a missing or empty '## Evidence'.", + Groups: []bool{false}, + Args: []arg{ + {Dest: "project", Required: true, Kind: value, Help: "project record name, e.g. pm-harness"}, + {Flags: []string{"--title"}, Dest: "title", Required: true, Kind: value}, + {Flags: []string{"--text-file"}, Dest: "text_file", Metavar: []string{"PATH"}, Kind: value, Group: 1, Help: "required: the frame, '## Goal', '## Scope' and '## Done when' sections, then '## Evidence'; read from PATH, or with - from stdin as a quoted heredoc: --text-file - <<'EOF' … EOF"}, + {Flags: []string{"--text"}, Dest: "text", Kind: value, Def: "", Group: 1, Help: "the same body inline, for one plain line only; several lines, backticks, $ or quotes go in --text-file"}, + }, + }, + { + Name: "promote", + Help: "make a proposal an ordinary sprint; only when the owner asks", + Description: "Make an open proposal (pm sprint propose) an ordinary sprint of its project: its label proposal is deleted and a dated note, pm sprint promote, is left on it, which the day page shows as the sprint's start. Run it only when the owner asks; tasks go under it after that. Refuses a sprint that is not a proposal, and a closed one (pm sprint reopen makes a dropped proposal a proposal again).", + Args: []arg{ + {Dest: "sprint_id", Required: true, Kind: value, Help: "the proposal's id"}, + }, + }, { Name: "close", - Help: "close a sprint epic once its report is written, every task is closed and each PR review is closed as merged", - Description: "Close a sprint epic at the committed records. Refuses until the Delivery report is written and every task is closed, and each PR review naming the sprint is closed with pm need close --reason \"merged as \" once its PR is on main; it asks GitHub only for the files each of this repo's PRs changes, and refuses, as pm action need --pr does, while a design page covering one is neither updated nor waived with pm design unchanged (with --merged and no --pr, the files a merge commit brought against its first parent; a commit with one parent, a squash or rebase merge, is refused there, as its delivery cannot be told, so give --pr). A PR an agent merged has no review: --merged SHA [--pr URL] gives its merge commit, which must be on the remote's main branch (pm fetches it first), and stamps 'Merged as .' ('Merged as (PR #N).' with --pr); with another GitHub repo's PR as --pr (see pm action need), SHA must be its merge commit into that repo's default branch, as gh gives it, and the stamp names it OWNER/REPO#N, as a review's close does; it refuses a sprint that holds a PR review, whose close stamps the merge instead. Then it publishes the sprint's design pages: every page a draft commit of this clone tags with the sprint (pm commit drafts them in a worktree's records workspace), from every worktree and every drafts/ branch whose worktree is gone, merged onto the records branch. It refuses, changing nothing, while this worktree's records workspace has uncommitted edits to a page the sprint links; while a draft overlaps what the records branch published since the draft's base, or another draft of the sprint, naming the commits or the branch and the fix (a pm write in that worktree brings the records branch in, leaving conflict markers to resolve and pm commit); and while a page to publish has a covers: pattern that matches no file on the remote's main (fetched): fix the pattern, or merge the PR that adds the file first. --discard SLUG --reason \"…\" drops the sprint's drafts of that page instead, puts back this workspace's uncommitted edits to it, and records the reason as pm design unchanged does; repeat both for several pages. One records commit then holds the pages, the 'Merged as' stamp in the Outcome for each review, and the waivers; the drafts lose the published pages, a drafts/ branch with no worktree left empty is deleted, and the close reason names the commit. A sprint with no PR publishes the same way. Run again after a stop, it finds nothing left to publish. Only the Outcome's first paragraph becomes the close reason.", + Help: "close a sprint epic once its report is written, every task is closed and each PR review is closed as merged; drop a proposal with --dropped", + Description: "Close a sprint epic at the committed records. Refuses until the Delivery report is written and every task is closed, and each PR review naming the sprint is closed with pm need close --reason \"merged as \" once its PR is on main; it asks GitHub only for the files each of this repo's PRs changes, and refuses, as pm action need --pr does, while a design page covering one is neither updated nor waived with pm design unchanged (with --merged and no --pr, the files a merge commit brought against its first parent; a commit with one parent, a squash or rebase merge, is refused there, as its delivery cannot be told, so give --pr). A PR an agent merged has no review: --merged SHA [--pr URL] gives its merge commit, which must be on the remote's main branch (pm fetches it first), and stamps 'Merged as .' ('Merged as (PR #N).' with --pr); with another GitHub repo's PR as --pr (see pm action need), SHA must be its merge commit into that repo's default branch, as gh gives it, and the stamp names it OWNER/REPO#N, as a review's close does; it refuses a sprint that holds a PR review, whose close stamps the merge instead. Then it publishes the sprint's design pages: every page a draft commit of this clone tags with the sprint (pm commit drafts them in a worktree's records workspace), from every worktree and every drafts/ branch whose worktree is gone, merged onto the records branch. It refuses, changing nothing, while this worktree's records workspace has uncommitted edits to a page the sprint links; while a draft overlaps what the records branch published since the draft's base, or another draft of the sprint, naming the commits or the branch and the fix (a pm write in that worktree brings the records branch in, leaving conflict markers to resolve and pm commit); and while a page to publish has a covers: pattern that matches no file on the remote's main (fetched): fix the pattern, or merge the PR that adds the file first. --discard SLUG --reason \"…\" drops the sprint's drafts of that page instead, puts back this workspace's uncommitted edits to it, and records the reason as pm design unchanged does; repeat both for several pages. One records commit then holds the pages, the 'Merged as' stamp in the Outcome for each review, and the waivers; the drafts lose the published pages, a drafts/ branch with no worktree left empty is deleted, and the close reason names the commit. A sprint with no PR publishes the same way. Run again after a stop, it finds nothing left to publish. Only the Outcome's first paragraph becomes the close reason. A proposal (pm sprint propose) closes only with --dropped --reason \"…\": as not done (dismissed), with no report and no records write; drop your own proposal when another sprint covers it, any other only when the owner asks; pm sprint reopen makes it a proposal again. --dropped on a sprint that is not a proposal is refused: it closes with its report, voided when a finding voided it.", Args: []arg{ {Dest: "sprint_id", Required: true, Kind: value, Help: "the sprint's id"}, {Flags: []string{"--merged"}, Dest: "merged", Metavar: []string{"SHA"}, Kind: value, Help: "the merge commit of the sprint's PR, merged with no PR review: a commit on the remote's main branch, or another repo's PR's merge commit"}, {Flags: []string{"--pr"}, Dest: "pr", Metavar: []string{"URL"}, Kind: value, Help: "with --merged: the merged pull request, named in the stamp"}, {Flags: []string{"--discard"}, Dest: "discard", Metavar: []string{"SLUG"}, Kind: repeated, Help: "a design page whose drafts for the sprint are dropped, not published; one --reason with each"}, - {Flags: []string{"--reason"}, Dest: "reason", Kind: repeated, Help: "with --discard: why the page stays as it is, in one line"}, + {Flags: []string{"--reason"}, Dest: "reason", Kind: repeated, Help: "with --discard: why the page stays as it is, in one line; with --dropped: why the proposal is not done"}, + {Flags: []string{"--dropped"}, Dest: "dropped", Kind: switchOn, Help: "drop a proposal: close it as not done, with one --reason"}, }, }, { @@ -402,7 +423,7 @@ var tree = &command{ { Name: "reopen", Help: "set a closed sprint open again; reason with --text", - Description: "Set a closed sprint open again: its work-store epic first, then one records commit that puts the record's '## Delivery report' back to 'Not closed yet.' in both parts, so the next pm sprint close needs a fresh report (the old one stays in git history), and adds the reopen, with the close reason it had, as a decision to the sprint's Decisions: source=agent, or source=owner with --confirmed. When the records step fails, the epic is closed again with its old close reason, at a new close time. The reason (--text) states why, on at least two lines. Refuses a sprint that is open, and a sprint whose project is closed: reopen the project first with pm project reopen. Its tasks stay closed; add new work with pm task add.", + Description: "Set a closed sprint open again: its work-store epic first, then one records commit that puts the record's '## Delivery report' back to 'Not closed yet.' in both parts, so the next pm sprint close needs a fresh report (the old one stays in git history), and adds the reopen, with the close reason it had, as a decision to the sprint's Decisions: source=agent, or source=owner with --confirmed. When the records step fails, the epic is closed again with its old close reason, at a new close time. The reason (--text) states why, on at least two lines. Refuses a sprint that is open, and a sprint whose project is closed: reopen the project first with pm project reopen. Its tasks stay closed; add new work with pm task add. A dropped proposal reopened is a proposal again.", Groups: []bool{false}, Args: []arg{ {Dest: "sprint_id", Required: true, Kind: value, Help: "the sprint's id"}, diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 6f988594..ea7c1a07 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -133,8 +133,9 @@ var ( // citeFlags name an item or a session without scoping the write: the need a decision answers, the session that // acts, and the new parent of a move (move --to). citeFlags = map[string]bool{"--need": true, "--session": true, "--to": true} - // createVerbs is each create verb and the nouns it serves; nil serves every noun. - createVerbs = map[string][]string{"open": {"project", "sprint"}, "add": nil, + // createVerbs is each create verb and the nouns it serves; nil serves every noun. propose creates a sprint that + // nobody has started. + createVerbs = map[string][]string{"open": {"project", "sprint"}, "propose": {"sprint"}, "add": nil, "new": {"design", "doc", "postmortem"}, "need": {"decision", "action"}} // offVerbs are create or close verbs outside the grammar: a container opens, an item is added, a record is new, // and everything closes with close. diff --git a/internal/cli/day.go b/internal/cli/day.go index ac30e448..5d86061c 100644 --- a/internal/cli/day.go +++ b/internal/cli/day.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "sort" "strings" "time" @@ -16,7 +15,6 @@ import ( "github.com/Yeeef/pm/internal/records" "github.com/Yeeef/pm/internal/site" "github.com/Yeeef/pm/internal/store" - "github.com/Yeeef/pm/internal/work" ) // pm day summarize: yesterday's and today's summary, each asked of the model when the day's activity changed since its @@ -41,7 +39,7 @@ func cmdDaySummarize(e *env, p *Parsed) (string, error) { yesterday, today := now.AddDate(0, 0, -1).Format(time.DateOnly), now.Format(time.DateOnly) activity := map[string]string{} for _, day := range []string{yesterday, today} { - if activity[day], err = r.dayActivity(day); err != nil { + if activity[day], err = site.DayActivity(day, r.records, r.recs, r.x); err != nil { return "", err } } @@ -121,127 +119,3 @@ func (e *env) summarizeOne(r *repo, runtime ask.Runtime, day, activity string, d } return r.apply([]store.Write{{Path: path, Text: body}}, "summarized "+day+" in "+r.rel(path), "pm: ") } - -// dayActivity is plain text of what happened on the local day: per project, the sprints finished (with their -// Outcome) or opened, the tasks finished (with their close reason), started or opened, and the owner requests raised -// or closed that day; the docs dated that day; and the records committed that day, not counting the generated -// summaries. The summary's input and, hashed, its digest. It holds only events dated that day, never current state, -// so a day's digest changes only when that day's events do. Empty when nothing happened that day. -func (r *repo) dayActivity(day string) (string, error) { - on := func(t time.Time) bool { return site.LocalDay(t) == day } - var out []string - for _, p := range r.recs { - if p.Type() != "project" { - continue - } - var lines []string - for _, sp := range r.dayChildren(p.Bead(), func(it *work.Item) bool { return it.Type == work.Sprint }) { - rows, err := r.sprintActivity(sp, on) - if err != nil { - return "", err - } - if rows != nil { - lines = append(append(lines, " sprint "+sp.Title), rows...) - } - } - if lines != nil { - out = append(append(out, "project "+p.Title()), lines...) - } - } - for _, rec := range r.recs { - if rec.Type() == "doc" && rec.Meta["date"].Str == day { - out = append(out, "doc "+rec.Title()+" ("+rec.Rel+")") - } - } - log, err := store.Git(r.records, "log", "--since="+day+"T00:00", "--until="+day+"T23:59:59", "--format=%x00%s", - "--name-only") - if err != nil { - return "", err - } - for _, chunk := range strings.Split(log, "\x00")[1:] { - var lines []string - for _, l := range strings.Split(chunk, "\n") { - if l != "" { - lines = append(lines, l) - } - } - if len(lines) == 0 { - continue - } - var names []string - for _, n := range lines[1:] { - if !strings.HasSuffix(n, ".summary.json") { - names = append(names, n) - } - } - if names != nil { - out = append(out, "records commit: "+lines[0]+" ["+strings.Join(names, ", ")+"]") - } - } - return strings.Join(out, "\n"), nil -} - -func (r *repo) dayChildren(parent string, keep func(*work.Item) bool) []*work.Item { - var out []*work.Item - for i := range r.items { - if it := &r.items[i]; it.Parent == parent && keep(it) { - out = append(out, it) - } - } - sort.SliceStable(out, func(i, j int) bool { return work.CompareIDs(out[i].ID, out[j].ID) < 0 }) - return out -} - -func (r *repo) sprintActivity(sp *work.Item, on func(time.Time) bool) ([]string, error) { - var rows []string - if on(sp.ClosedAt) { - row := " sprint finished" - for _, rec := range r.recs { - if rec.Type() == "sprint" && rec.Bead() == sp.ID { - o, err := records.Outcome(rec) - if err != nil { - return nil, err - } - if o != "" { - row += ": " + o - } - break - } - } - rows = append(rows, row) - } - if on(sp.CreatedAt) { - rows = append(rows, " sprint opened") - } - // a dismissed need is a [TEST] or replaced one; a dismissed task is a dropped one, which moved the day - for _, t := range r.dayChildren(sp.ID, func(it *work.Item) bool { - return it.Resolution != work.Dismissed || it.Type == work.Task - }) { - request := t.Type == work.Need - noun := "task" - if request { - noun = "request to the owner (" + site.Kind(t) + ")" - } - if on(t.ClosedAt) { - verb := "closed" - if t.Resolution == work.Dismissed { - verb = "dropped" - } - row := " " + noun + " " + verb + ": " + t.Title - if t.CloseReason != "" { - row += " (" + t.CloseReason + ")" - } - rows = append(rows, row) - } else if on(t.StartedAt) { - rows = append(rows, " "+noun+" started: "+t.Title) - } - if on(t.CreatedAt) { - if request { - rows = append(rows, " "+noun+" raised: "+t.Title) - } else { - rows = append(rows, " task opened: "+t.Title) - } - } - } - return rows, nil -} diff --git a/internal/cli/project.go b/internal/cli/project.go index 2bdeb47e..dfe07d37 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -91,14 +91,17 @@ func cmdProjectClose(e *env, p *Parsed) (string, error) { } var open []string for _, k := range r.x.Children(epicID) { - if k.Status != work.Closed { + if r.x.Proposal(k) { + open = append(open, fmt.Sprintf("%s (%s; a proposal: pm sprint close %s --dropped --reason \"…\")", k.ID, + k.Title, k.ID)) + } else if k.Status != work.Closed { open = append(open, fmt.Sprintf("%s (%s)", k.ID, k.Title)) } } if open != nil { return "", refuse("open sprints in the project: %s; close them with pm sprint close first", strings.Join(open, ", ")) } - reason := fmt.Sprintf("%s (commit %s)", firstSentence(text, 200), head) + reason := fmt.Sprintf("%s (commit %s)", records.FirstSentence(text, 200), head) if err := r.checkPlanned(nil, r.with(closedCopy(epic, reason))); err != nil { return "", err } diff --git a/internal/cli/proposal_test.go b/internal/cli/proposal_test.go new file mode 100644 index 00000000..c339a3c1 --- /dev/null +++ b/internal/cli/proposal_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/spf13/pflag" + + "github.com/Yeeef/pm/internal/work" + "github.com/Yeeef/pm/internal/work/worktest" +) + +// The class mechanism of the sprint-proposals design page (Every command on a proposal): every command argument that +// takes an item id is classed for a proposal. testdata/proposal_commands.json holds the cases, each a command run +// with a proposal's id in that argument and its expected outcome; tests/test_proposals.py runs them against a real +// store. This test holds the cases to the command tree: a new id argument without a case fails here. + +// idArgs is each argument of c that takes an item id: a positional sprint, task, need or item id, or a flag whose +// metavar names one (--sprint ID, --task TASK, --on BLOCKER, task move's --to SPRINT_ID); a project name, a path and +// a session id (--session ID) are not one. +func idArgs(c *command) []string { + var out []string + for _, a := range c.Args { + meta := strings.Join(a.Metavar, " ") + switch { + case a.Positional() && slices.Contains([]string{"sprint_id", "task_id", "need_id", "id", "ids", "target"}, a.Dest): + out = append(out, a.Dest) + case !a.Positional() && a.Flags[0] != "--session" && + slices.Contains([]string{"ID", "TASK", "SPRINT_ID", "BLOCKER"}, meta): + out = append(out, a.Flags[0]) + } + } + return out +} + +// TestEveryItemIDArgumentIsClassedForAProposal: the cases cover each id argument of every command, the tree's and +// the work-store commands' (their forms included), and name no other. +func TestEveryItemIDArgumentIsClassedForAProposal(t *testing.T) { + want := map[string]bool{} + var walk func(c *command, path []string) + walk = func(c *command, path []string) { + if c.Subs == nil && c.Store == nil { + for _, a := range idArgs(c) { + want[strings.Join(path, " ")+" "+a] = true + } + } + for _, s := range c.Subs { + walk(s, append(append([]string{}, path...), s.Name)) + } + } + walk(tree, nil) + for name, sc := range storeCommands { + fs := pflag.NewFlagSet(name, pflag.ContinueOnError) + if sc.flags != nil { + sc.flags(fs) + } + path := strings.TrimSuffix(strings.TrimSuffix(name, " ID"), " --task") // a form's path is its command's + for _, a := range idArgs(storeHelpCommand(sc, fs)) { + want[path+" "+a] = true + } + } + b, err := os.ReadFile("testdata/proposal_commands.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Covers string `json:"covers"` + Argv []string `json:"argv"` + Outcome string `json:"outcome"` + } + if err := json.Unmarshal(b, &cases); err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, c := range cases { + got[c.Covers] = true + if !want[c.Covers] { + t.Errorf("case %q covers no id argument of the command tree", c.Covers) + } + if !slices.Contains([]string{"allowed", "promotes", "drops", "refused"}, c.Outcome) { + t.Errorf("case %q: outcome %q is none of allowed, promotes, drops, refused", c.Covers, c.Outcome) + } + } + for k := range want { + if !got[k] { + t.Errorf("pm %s takes an item id, and no case in testdata/proposal_commands.json classes it for a "+ + "proposal", k) + } + } +} + +// A drop writes no records, so it refuses what it would strand or leave open (the sprint-proposals design page, +// Commands): an open need under the proposal, named with how to close it, and a design-page draft tagged with it, +// until a commit tags the page with another sprint; then the drop closes it as dismissed. +func TestADropRefusesAnOpenNeedAndADraftTaggedWithTheProposal(t *testing.T) { + _, storeDir, one, _ := workspaces(t) + writeFile(t, filepath.Join(storeDir, "sprints/demo-2.md"), recordsSprint("Two", "demo.2", + "[Parser](../design/parser.md)")) + run(t, storeDir, "git commit -qam 'sprint 2 links the parser'") + at := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + items := []work.Item{{ID: "demo", Type: work.Project, Title: "Demo", Status: work.Open, CreatedAt: at}, + {ID: "demo.1", Type: work.Sprint, Parent: "demo", Title: "One", Number: 1, Status: work.Open, CreatedAt: at}, + {ID: "demo.2", Type: work.Sprint, Parent: "demo", Title: "Two", Number: 2, Status: work.Open, CreatedAt: at, + Labels: []string{work.ProposalLabel}}, + {ID: "demo.2.1", Type: work.Need, Parent: "demo.2", Title: "Which retry?", Status: work.Open, CreatedAt: at, + Need: &work.NeedInfo{Kind: work.Decision}}, + {ID: "demo.1.1", Type: work.Task, Parent: "demo.1", Title: "T", Status: work.Open, CreatedAt: at, + Holder: &work.Holder{Session: "s1", ClaimedAt: at}}} + closed := &[]string{} + use := func(items []work.Item) { + OpenWork = func(string) (work.Store, error) { return closingStore{worktest.New(items), closed}, nil } + } + drop := []string{"sprint", "close", "demo.2", "--dropped", "--reason", "duplicate of demo.1"} + use(items) + if _, stderr, code := runPM(t, one.Code, drop...); code == 0 || !strings.Contains(stderr, "proposal demo.2 holds "+ + `an open need, demo.2.1 (Which retry?); close it first: pm need close demo.2.1 --dropped`) { + t.Errorf("a drop with an open need: exit %d, %q", code, stderr) + } + use(slices.Delete(items, 3, 4)) // the need closed + page := filepath.Join(one.Dir, "design/parser.md") + edit(t, page, "\nP1\n", "\nP1 for the proposal\n") + mustPM(t, one.Code, "commit", "-m", "the parser, for the proposal", "--sprint", "demo.2", "records/design/parser.md") + if _, stderr, code := runPM(t, one.Code, drop...); code == 0 || !strings.Contains(stderr, + "proposal demo.2 holds design-page drafts only its close publishes (design/parser.md)") { + t.Errorf("a drop with a draft tagged with it: exit %d, %q", code, stderr) + } + edit(t, page, "\nP1 for the proposal\n", "\nP1\n") // the draft put back to the published page: no draft left + mustPM(t, one.Code, "commit", "-m", "the parser as published", "--sprint", "demo.2", "records/design/parser.md") + if out := mustPM(t, one.Code, drop...); !strings.Contains(out, "dropped proposal demo.2") || len(*closed) != 1 { + t.Errorf("the drop said %q, closed %v", out, *closed) + } +} diff --git a/internal/cli/review.go b/internal/cli/review.go index bca406bc..38098850 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "github.com/Yeeef/pm/internal/cli/where" "github.com/Yeeef/pm/internal/config" "github.com/Yeeef/pm/internal/design" "github.com/Yeeef/pm/internal/proc" @@ -79,7 +80,7 @@ func raiseReview(e *env, p *Parsed) (string, error) { desc += "\n\nDesign pages: " + strings.Join(designs, ", ") } if verdicts != nil { - site, err := showSiteURL(e.here, store.MainOf(r.records)) + site, err := where.SiteURL(e.here, store.MainOf(r.records)) if err != nil { return "", err } @@ -116,6 +117,8 @@ func (r *repo) reviewReports(sprints []string) error { if it := r.item(sid); it != nil && it.Status == work.Closed { return refuse("sprint %s is closed, but a sprint closes only after its PR merges; a PR under review "+ "delivers only open sprints", sid) + } else if it != nil && r.x.Proposal(it) { + return refuse("sprint %s is a proposal, not promoted; it has no work to review", sid) } line, err := records.Outcome(rec) if err != nil { diff --git a/internal/cli/show.go b/internal/cli/show.go index fe43734b..9c1b7971 100644 --- a/internal/cli/show.go +++ b/internal/cli/show.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/Yeeef/pm/internal/cli/where" "github.com/Yeeef/pm/internal/config" "github.com/Yeeef/pm/internal/records" "github.com/Yeeef/pm/internal/service" @@ -153,7 +154,7 @@ func cmdRecordLink(e *env, p *Parsed) (string, error) { if p.Get("local") != "" { return fmt.Sprintf("http://127.0.0.1:%d/%s", port, rec.Out()), nil } - url, err := showSiteURL(e.here, main) + url, err := where.SiteURL(e.here, main) if err != nil { return "", err } @@ -234,7 +235,7 @@ func showDecisionsOf(rec *records.Record, level string) []showDecision { var out []showDecision for _, d := range records.Decisions(rec.Text) { out = append(out, showDecision{date: d.Attrs["date"], source: d.Attrs["source"], level: level, record: rec.Rel, - text: firstSentence(d.Body, 100)}) + text: records.FirstSentence(d.Body, 100)}) } return out } @@ -245,7 +246,7 @@ var showListItem = regexp.MustCompile(`(?m)^(?:[-*]|\d+\.) `) // feedback, the push flags and today's summary. func showDataOf(e *env, r *repo) (*showData, error) { main := store.MainOf(r.records) - url, err := showSiteURL(e.here, main) + url, err := where.SiteURL(e.here, main) if err != nil { return nil, err } @@ -276,7 +277,7 @@ func showDataOf(e *env, r *repo) (*showData, error) { } var sprints []*showSprint for _, sp := range r.x.Children(epic) { - if sp.Type != work.Sprint { + if sp.Type != work.Sprint || r.x.Proposal(sp) { // a proposal is on the site, never in pm show continue } rec := sprintRecs[sp.ID] @@ -318,7 +319,7 @@ func showDataOf(e *env, r *repo) (*showData, error) { session: session, replied: service.ReplyWaiting(n) || service.MergeWaiting(n) != ""}) } data.projects = append(data.projects, &showProject{name: p.Name(), bead: epic, title: p.Title(), - url: url + "/" + p.Out(), goal: firstSentence(records.SectionText(p.Body, "Goal"), 110), sprints: sprints, + url: url + "/" + p.Out(), goal: records.FirstSentence(records.SectionText(p.Body, "Goal"), 110), sprints: sprints, needs: needs, decisions: last}) } for _, n := range site.OwnerTasks(r.x, "") { @@ -578,11 +579,11 @@ func (r *repo) showSprintOf(sp *work.Item, rec *records.Record, name, url string doneWhen = records.SectionText(rec.Body, "Done when") rel, out := rec.Rel, url+"/"+rec.Out() s.record, s.url = &rel, &out - s.goal = firstSentence(records.SectionText(rec.Body, "Goal"), 110) + s.goal = records.FirstSentence(records.SectionText(rec.Body, "Goal"), 110) } s.doneWhenItems = len(showListItem.FindAllStringIndex(doneWhen, -1)) if s.doneWhenItems == 0 { - s.doneWhen = firstSentence(doneWhen, 110) + s.doneWhen = records.FirstSentence(doneWhen, 110) } for _, t := range tasks { if t.Status == work.Closed { diff --git a/internal/cli/show_json.go b/internal/cli/show_json.go index 513b339b..dbb1f971 100644 --- a/internal/cli/show_json.go +++ b/internal/cli/show_json.go @@ -132,7 +132,7 @@ func (d *showData) json() showJSON { out.Projects = append(out.Projects, pj) } if d.summary != nil { - summary := firstSentence(records.SummaryLine(d.summary.Text), 160) + summary := records.FirstSentence(records.SummaryLine(d.summary.Text), 160) out.Today.Summary, out.Today.GeneratedAt = &summary, &d.summary.GeneratedAt } if d.feedback != nil { diff --git a/internal/cli/show_site.go b/internal/cli/show_site.go index 88b33603..d733595f 100644 --- a/internal/cli/show_site.go +++ b/internal/cli/show_site.go @@ -1,8 +1,7 @@ package cli import ( - "fmt" - + "github.com/Yeeef/pm/internal/cli/where" "github.com/Yeeef/pm/internal/config" "github.com/Yeeef/pm/internal/service" "github.com/Yeeef/pm/internal/store" @@ -17,21 +16,5 @@ func showPort(here, main string) (int, error) { return service.PortFor(main, int(cfg.Port)) } -// showSiteURL is Python's site_url(): the repo's site_url, else http://localhost:. -func showSiteURL(here, main string) (string, error) { - cfg, err := config.Load(here) - if err != nil { - return "", err - } - if cfg.SiteURL != "" { - return cfg.SiteURL, nil - } - port, err := service.PortFor(main, int(cfg.Port)) - if err != nil { - return "", err - } - return fmt.Sprintf("http://localhost:%d", port), nil -} - // siteURL is the site's URL for this store, as pm show links a record. -func (e *env) siteURL() (string, error) { return showSiteURL(e.here, store.MainOf(e.records)) } +func (e *env) siteURL() (string, error) { return where.SiteURL(e.here, store.MainOf(e.records)) } diff --git a/internal/cli/sprint.go b/internal/cli/sprint.go index 031e008f..aa5c1c52 100644 --- a/internal/cli/sprint.go +++ b/internal/cli/sprint.go @@ -10,6 +10,7 @@ import ( "github.com/Yeeef/pm/internal/site" "github.com/Yeeef/pm/internal/store" "github.com/Yeeef/pm/internal/work" + "maps" "path/filepath" "regexp" "slices" @@ -45,23 +46,51 @@ func parseSections(text string, allowed []string) (map[string]string, error) { var trailingNumber = regexp.MustCompile(`^[0-9]+$`) -func cmdSprintOpen(e *env, p *Parsed) (string, error) { - title := strip(p.Get("title")) - if title == "" { - return "", refuse("--title is empty") - } - fr, err := parseSections(p.Get("text"), frame) +func cmdSprintOpen(e *env, p *Parsed) (string, error) { return createSprint(e, p, false) } + +// cmdSprintPropose is pm sprint propose: pm sprint open's sprint, labelled proposal, its body's Evidence the record's +// first finding, then the duplicate list (proposalPeers) in place of the design pages. +func cmdSprintPropose(e *env, p *Parsed) (string, error) { return createSprint(e, p, true) } + +// sprintFrame is the body's sections: the frame, and with propose its Evidence; refused when one is missing or empty +// or the Scope lacks its lists. +func sprintFrame(p *Parsed, propose bool) (map[string]string, error) { + sections := frame + if propose { + sections = append(append([]string{}, frame...), "Evidence") + } + fr, err := parseSections(p.Get("text"), sections) if err != nil { - return "", err + return nil, err } - for _, name := range frame { - if fr[name] == "" { - return "", refuse("'## %s' is missing or empty in --text; %s", name, frameShape) + for _, name := range sections { + if fr[name] == "" && name == "Evidence" { + return nil, refuse("'## Evidence' is missing or empty in --text; give what you saw after the frame: a " + + "command and its output, a file and line, a number") + } else if fr[name] == "" { + return nil, refuse("'## %s' is missing or empty in --text; %s", name, frameShape) } } //nolint:gocritic // weakCond: FindStringSubmatch gives nil or a slice holding every group if m := scopeRE.FindStringSubmatch(fr["Scope"]); m == nil || strip(m[1]) == "" || strip(m[2]) == "" { - return "", refuse("Scope needs a non-empty **In:** list followed by a non-empty **Out:** list; %s", frameShape) + return nil, refuse("Scope needs a non-empty **In:** list followed by a non-empty **Out:** list; %s", frameShape) + } + return fr, nil +} + +// createSprint is pm sprint open, or with propose pm sprint propose (the sprint-proposals design page). +func createSprint(e *env, p *Parsed, propose bool) (string, error) { + title := strip(p.Get("title")) + if title == "" { + return "", refuse("--title is empty") + } + labels := []string(nil) + if propose { + labels = []string{work.ProposalLabel} + } + fr, err := sprintFrame(p, propose) + if err != nil { + return "", err } r, err := e.load(false) if err != nil { @@ -91,7 +120,7 @@ func cmdSprintOpen(e *env, p *Parsed) (string, error) { full := fmt.Sprintf("Sprint %d: %s", n, title) plannedID := fmt.Sprintf("%s.%d", epicID, suffix+1) plan := planned(plannedID, work.Sprint, epicID, full) - plan.Number = n + plan.Number, plan.Labels = n, labels text := func(id string) string { return records.SprintText(records.YAMLStr(title), id, fr) } if err := r.checkPlanned([]store.Write{{Path: path, Text: text(plannedID)}}, r.with(plan)); err != nil { return "", err @@ -100,7 +129,7 @@ func cmdSprintOpen(e *env, p *Parsed) (string, error) { if err != nil { return "", err } - made, err := ws.Create(work.New{Type: work.Sprint, Parent: epicID, Title: full}) + made, err := ws.Create(work.New{Type: work.Sprint, Parent: epicID, Title: full, Labels: labels}) if err != nil { return "", err } @@ -108,14 +137,31 @@ func cmdSprintOpen(e *env, p *Parsed) (string, error) { if err := r.checkPlanned(w, r.with(made)); err != nil { return "", undoCreate(ws, made.ID, err) } - out, err := r.apply(w, fmt.Sprintf("opened sprint %d of %s: epic %s, record %s", n, project, made.ID, r.rel(path)), + verb := "opened" + if propose { + verb = "proposed" + } + out, err := r.apply(w, fmt.Sprintf("%s sprint %d of %s: epic %s, record %s", verb, n, project, made.ID, r.rel(path)), "pm: ") if err != nil { return "", undoCreate(ws, made.ID, err) } + if propose { + return out + "\n" + r.proposalPeers(epicID, made.ID), nil + } return out + designindex.Block(func() (string, error) { return designindex.Of(r.recs, e.siteURL, prec) }), nil } +// proposalPeers is pm sprint propose's duplicate list (site.OpenSprintRows), and how to drop id as a duplicate. +func (r *repo) proposalPeers(epicID, id string) string { + rows := site.OpenSprintRows(epicID, id, r.recs, r.x) + if rows == nil { + rows = []string{" none"} + } + return "the project's other proposals and open sprints; if one covers yours, drop yours: pm sprint close " + id + + ` --dropped --reason "duplicate of "` + "\n" + strings.Join(rows, "\n") +} + // requireCommitted is the records commit a close names; the closed record itself must be committed. func (r *repo) requireCommitted(path string) (string, error) { dirty, err := store.Uncommitted(r.records, []string{path}) @@ -150,6 +196,9 @@ func statusText(it *work.Item) string { } func cmdSprintClose(e *env, p *Parsed) (string, error) { + if p.Get("dropped") != "" { + return dropProposal(e, p) + } merged, agentMerge := given(p, "merged") pr, prGiven := given(p, "pr") if prGiven && !agentMerge { @@ -177,16 +226,13 @@ func cmdSprintClose(e *env, p *Parsed) (string, error) { return "", err } id := p.Get("sprint_id") - rec, err := r.sprint(id) + rec, epic, err := r.closingSprint(id) if err != nil { return "", err } - epic := r.item(id) - if epic == nil { - return "", refuse("sprint %s is not in the work store", id) - } - if epic.Status == work.Closed { - return "", refuse("sprint %s is already closed", id) + if r.x.Proposal(epic) { + return "", refuse("sprint %s is a proposal; drop it with pm sprint close %s --dropped --reason "+ + "\"…\", or, when the owner asks, promote it with pm sprint promote %s", id, id, id) } head, err := r.requireCommitted(rec.Path) if err != nil { @@ -252,6 +298,99 @@ func cmdSprintClose(e *env, p *Parsed) (string, error) { return out, nil } +// cmdSprintPromote is pm sprint promote: an open proposal becomes an ordinary sprint of its project, on the owner's +// word (Store.Promote: the label deleted, a dated note left). +func cmdSprintPromote(e *env, p *Parsed) (string, error) { + r, err := e.load(false) + if err != nil { + return "", err + } + id := p.Get("sprint_id") + if it := r.item(id); it == nil || !r.x.Proposal(it) { + return "", refuse("sprint %s is not an open proposal; pm sprint promote makes an open proposal (pm sprint "+ + "propose) an ordinary sprint", id) + } + ws, err := e.work() + if err != nil { + return "", err + } + by := currentSession() + if by == "" { + by = "owner" + } + if err := ws.Promote(id, by); err != nil { + return "", err + } + return fmt.Sprintf("promoted %s: an ordinary sprint now; add its tasks with pm task add --sprint %s", id, id), nil +} + +// closingSprint is the record and the epic of the open sprint id, which pm sprint close closes. +func (r *repo) closingSprint(id string) (*records.Record, *work.Item, error) { + rec, err := r.sprint(id) + if err != nil { + return nil, nil, err + } + epic := r.item(id) + if epic == nil { + return nil, nil, refuse("sprint %s is not in the work store", id) + } + if epic.Status == work.Closed { + return nil, nil, refuse("sprint %s is already closed", id) + } + return rec, epic, nil +} + +// dropProposal is pm sprint close --dropped: the proposal closes as not done (dismissed) with its one-line --reason, +// with no records write and no report. It refuses a sprint that is not a proposal. +func dropProposal(e *env, p *Parsed) (string, error) { + if _, merged := given(p, "merged"); merged || p.Get("pr") != "" || len(p.Values["discard"]) > 0 { + return "", refuse("--dropped closes a proposal, with one --reason; it takes no --merged, --pr or " + + "--discard") + } + r, err := e.load(false) + if err != nil { + return "", err + } + id := p.Get("sprint_id") + _, epic, err := r.closingSprint(id) + if err != nil { + return "", err + } + if !r.x.Proposal(epic) { + return "", refuse("sprint %s is not a proposal, so --dropped does not close it: it closes with its "+ + "Delivery report, voided when a finding voided it", id) + } + if len(p.Values["reason"]) != 1 { + return "", refuse("--dropped needs one --reason: why the proposal is not done, e.g. \"duplicate of \"") + } + reason, err := decisionLine("--reason", p.Values["reason"][0]) + if err != nil { + return "", err + } + for _, k := range r.x.Children(id) { + if k.Status != work.Closed { + return "", refuse("proposal %s holds an open %s, %s (%s); close it first: pm %s close %s --dropped "+ + "--reason \"…\" when the drop makes it moot", id, k.Type, k.ID, k.Title, k.Type, k.ID) + } + } + if drafted, _, err := store.Drafted(r.records); err != nil { + return "", err + } else if pages := slices.Sorted(maps.Keys(drafted[id])); pages != nil { + return "", refuse("proposal %s holds design-page drafts only its close publishes (%s), which a drop would strand; "+ + "in the draft's worktree, put each page back as the records branch has it (git show records:) and pm "+ + "commit --sprint %s it, then drop again", id, strings.Join(pages, ", "), id) + } + ws, err := e.work() + if err != nil { + return "", err + } + if err := ws.Close(epic.ID, reason, work.Dismissed, closer(epic)); err != nil { + return "", err + } + return fmt.Sprintf("dropped proposal %s: %s; pm sprint reopen %s makes it a proposal again", epic.ID, reason, + epic.ID), nil +} + // closeDone finishes the close's drafts (design.Closing.Done); a variable so a test can fail it. var closeDone = (*design.Closing).Done @@ -479,6 +618,10 @@ func (r *repo) openSprint(id string) (*records.Record, error) { if it.Status == work.Closed { return nil, refuse("sprint %s is closed; use an open sprint", id) } + if r.x.Proposal(it) { + return nil, refuse("sprint %s is a proposal; its tasks wait until the owner asks to promote it: pm sprint "+ + "promote %s", id, id) + } return rec, nil } diff --git a/internal/cli/testdata/help.golden b/internal/cli/testdata/help.golden index d72c9f2e..88a4e70c 100644 --- a/internal/cli/testdata/help.golden +++ b/internal/cli/testdata/help.golden @@ -91,7 +91,7 @@ pm runs the pm version the repo pins in .pm/config.toml: another version's relea usage: pm show [-h] [--json] [--project NAME] [--sprint ID] [--record PATH] [--section NAME] [--refresh-inbox] -Project state, level by level. Without a flag, the top level: push failures, tasks other live sessions hold, the site, the repo's pm feedback doc, and per open project a line with each open owner request and undelivered reply. Each level names the command for the next: --project, then --sprint, then --record, then --record with --section. pm show ID [--json] prints one item of any type instead: its fields, holder, blockers, children, needs and comments (pm show ID --help). +Project state, level by level. Without a flag, the top level: push failures, tasks other live sessions hold, the site, the repo's pm feedback doc, and per open project a line with each open owner request and undelivered reply. Each level names the command for the next: --project, then --sprint, then --record, then --record with --section. pm show ID [--json] prints one item of any type instead: its fields, holder, blockers, children, needs and comments (pm show ID --help). A sprint proposal (pm sprint propose) shows at no level, --json included: the site lists proposals; --sprint ID and pm show ID show one by id. options: -h, --help show this help message and exit @@ -154,7 +154,7 @@ options: usage: pm feedback add [-h] [--project NAME] [--sprint ID] [--task ID] [--text-file PATH | --text TEXT] [--session ID] -When pm got in the way (a confusing refusal, a missing command, a rule that cost time), say once what happened and what would have helped. Appends a dated entry with this session's id to the repo's one feedback doc, records/docs/pm-feedback.md, creating it on first use; --project, --sprint and --task tag the entry with what it is about. pm show and pm show --project link the doc. +When pm itself got in the way (a confusing refusal, a missing command, a rule that cost time), say once what happened and what would have helped; what the repo needs is a sprint proposal (pm sprint propose). Appends a dated entry with this session's id to the repo's one feedback doc, records/docs/pm-feedback.md, creating it on first use; --project, --sprint and --task tag the entry with what it is about. pm show and pm show --project link the doc. options: -h, --help show this help message and exit @@ -470,12 +470,14 @@ options: --text-file PATH required: why the project reopens, on at least two lines; read from PATH, or with - from stdin as a quoted heredoc: --text-file - <<'EOF' … EOF --text TEXT the same body inline, for one plain line only; several lines, backticks, $ or quotes go in --text-file ===== pm sprint --help -usage: pm sprint [-h] {open,close,edit,move,reopen} ... +usage: pm sprint [-h] {open,propose,promote,close,edit,move,reopen} ... positional arguments: - {open,close,edit,move,reopen} + {open,propose,promote,close,edit,move,reopen} open create a sprint epic and record; frame with --text ('## Goal', '## Scope' with **In:**/**Out:**, '## Done when') - close close a sprint epic once its report is written, every task is closed and each PR review is closed as merged + propose propose a sprint for the owner to promote; frame and evidence with --text ('## Goal', '## Scope', '## Done when', '## Evidence') + promote make a proposal an ordinary sprint; only when the owner asks + close close a sprint epic once its report is written, every task is closed and each PR review is closed as merged; drop a proposal with --dropped edit rename an open sprint; reason with --text move move an open sprint, with its tasks, frame, decisions and findings, to another open project; reason with --text reopen set a closed sprint open again; reason with --text @@ -496,12 +498,36 @@ options: --title TITLE --text-file PATH required: the frame, '## Goal', '## Scope' and '## Done when' sections; read from PATH, or with - from stdin as a quoted heredoc: --text-file - <<'EOF' … EOF --text TEXT the same body inline, for one plain line only; several lines, backticks, $ or quotes go in --text-file +===== pm sprint propose --help +usage: pm sprint propose [-h] --title TITLE [--text-file PATH | --text TEXT] + project + +Propose a sprint for something the repo needs outside your task: an open sprint epic labelled proposal, with its record, as pm sprint open makes them, whose body's '## Evidence' (what you saw: a command and its output, a file and line, a number) becomes the record's first finding. A proposal is an open sprint labelled proposal: pm show and pm task ready leave it out, pm show --sprint ID and pm show ID show it, and the site lists it in a collapsed Proposed section. It takes no task (pm task add --sprint, pm task move --to) until pm sprint promote, run when the owner asks, makes it an ordinary sprint. Then it prints the project's other proposals and open sprints, each with its state, id, title and goal: if one covers yours, drop yours with pm sprint close --dropped --reason "duplicate of ". Refuses as pm sprint open does, and a missing or empty '## Evidence'. + +positional arguments: + project project record name, e.g. pm-harness + +options: + -h, --help show this help message and exit + --title TITLE + --text-file PATH required: the frame, '## Goal', '## Scope' and '## Done when' sections, then '## Evidence'; read from PATH, or with - from stdin as a quoted heredoc: --text-file - <<'EOF' … EOF + --text TEXT the same body inline, for one plain line only; several lines, backticks, $ or quotes go in --text-file +===== pm sprint promote --help +usage: pm sprint promote [-h] sprint_id + +Make an open proposal (pm sprint propose) an ordinary sprint of its project: its label proposal is deleted and a dated note, pm sprint promote, is left on it, which the day page shows as the sprint's start. Run it only when the owner asks; tasks go under it after that. Refuses a sprint that is not a proposal, and a closed one (pm sprint reopen makes a dropped proposal a proposal again). + +positional arguments: + sprint_id the proposal's id + +options: + -h, --help show this help message and exit ===== pm sprint close --help usage: pm sprint close [-h] [--merged SHA] [--pr URL] [--discard SLUG] - [--reason REASON] + [--reason REASON] [--dropped] sprint_id -Close a sprint epic at the committed records. Refuses until the Delivery report is written and every task is closed, and each PR review naming the sprint is closed with pm need close --reason "merged as " once its PR is on main; it asks GitHub only for the files each of this repo's PRs changes, and refuses, as pm action need --pr does, while a design page covering one is neither updated nor waived with pm design unchanged (with --merged and no --pr, the files a merge commit brought against its first parent; a commit with one parent, a squash or rebase merge, is refused there, as its delivery cannot be told, so give --pr). A PR an agent merged has no review: --merged SHA [--pr URL] gives its merge commit, which must be on the remote's main branch (pm fetches it first), and stamps 'Merged as .' ('Merged as (PR #N).' with --pr); with another GitHub repo's PR as --pr (see pm action need), SHA must be its merge commit into that repo's default branch, as gh gives it, and the stamp names it OWNER/REPO#N, as a review's close does; it refuses a sprint that holds a PR review, whose close stamps the merge instead. Then it publishes the sprint's design pages: every page a draft commit of this clone tags with the sprint (pm commit drafts them in a worktree's records workspace), from every worktree and every drafts/ branch whose worktree is gone, merged onto the records branch. It refuses, changing nothing, while this worktree's records workspace has uncommitted edits to a page the sprint links; while a draft overlaps what the records branch published since the draft's base, or another draft of the sprint, naming the commits or the branch and the fix (a pm write in that worktree brings the records branch in, leaving conflict markers to resolve and pm commit); and while a page to publish has a covers: pattern that matches no file on the remote's main (fetched): fix the pattern, or merge the PR that adds the file first. --discard SLUG --reason "…" drops the sprint's drafts of that page instead, puts back this workspace's uncommitted edits to it, and records the reason as pm design unchanged does; repeat both for several pages. One records commit then holds the pages, the 'Merged as' stamp in the Outcome for each review, and the waivers; the drafts lose the published pages, a drafts/ branch with no worktree left empty is deleted, and the close reason names the commit. A sprint with no PR publishes the same way. Run again after a stop, it finds nothing left to publish. Only the Outcome's first paragraph becomes the close reason. +Close a sprint epic at the committed records. Refuses until the Delivery report is written and every task is closed, and each PR review naming the sprint is closed with pm need close --reason "merged as " once its PR is on main; it asks GitHub only for the files each of this repo's PRs changes, and refuses, as pm action need --pr does, while a design page covering one is neither updated nor waived with pm design unchanged (with --merged and no --pr, the files a merge commit brought against its first parent; a commit with one parent, a squash or rebase merge, is refused there, as its delivery cannot be told, so give --pr). A PR an agent merged has no review: --merged SHA [--pr URL] gives its merge commit, which must be on the remote's main branch (pm fetches it first), and stamps 'Merged as .' ('Merged as (PR #N).' with --pr); with another GitHub repo's PR as --pr (see pm action need), SHA must be its merge commit into that repo's default branch, as gh gives it, and the stamp names it OWNER/REPO#N, as a review's close does; it refuses a sprint that holds a PR review, whose close stamps the merge instead. Then it publishes the sprint's design pages: every page a draft commit of this clone tags with the sprint (pm commit drafts them in a worktree's records workspace), from every worktree and every drafts/ branch whose worktree is gone, merged onto the records branch. It refuses, changing nothing, while this worktree's records workspace has uncommitted edits to a page the sprint links; while a draft overlaps what the records branch published since the draft's base, or another draft of the sprint, naming the commits or the branch and the fix (a pm write in that worktree brings the records branch in, leaving conflict markers to resolve and pm commit); and while a page to publish has a covers: pattern that matches no file on the remote's main (fetched): fix the pattern, or merge the PR that adds the file first. --discard SLUG --reason "…" drops the sprint's drafts of that page instead, puts back this workspace's uncommitted edits to it, and records the reason as pm design unchanged does; repeat both for several pages. One records commit then holds the pages, the 'Merged as' stamp in the Outcome for each review, and the waivers; the drafts lose the published pages, a drafts/ branch with no worktree left empty is deleted, and the close reason names the commit. A sprint with no PR publishes the same way. Run again after a stop, it finds nothing left to publish. Only the Outcome's first paragraph becomes the close reason. A proposal (pm sprint propose) closes only with --dropped --reason "…": as not done (dismissed), with no report and no records write; drop your own proposal when another sprint covers it, any other only when the owner asks; pm sprint reopen makes it a proposal again. --dropped on a sprint that is not a proposal is refused: it closes with its report, voided when a finding voided it. positional arguments: sprint_id the sprint's id @@ -511,7 +537,8 @@ options: --merged SHA the merge commit of the sprint's PR, merged with no PR review: a commit on the remote's main branch, or another repo's PR's merge commit --pr URL with --merged: the merged pull request, named in the stamp --discard SLUG a design page whose drafts for the sprint are dropped, not published; one --reason with each - --reason REASON with --discard: why the page stays as it is, in one line + --reason REASON with --discard: why the page stays as it is, in one line; with --dropped: why the proposal is not done + --dropped drop a proposal: close it as not done, with one --reason ===== pm sprint edit --help usage: pm sprint edit [-h] --title TITLE [--text-file PATH | --text TEXT] sprint_id @@ -544,7 +571,7 @@ options: usage: pm sprint reopen [-h] [--confirmed] [--text-file PATH | --text TEXT] sprint_id -Set a closed sprint open again: its work-store epic first, then one records commit that puts the record's '## Delivery report' back to 'Not closed yet.' in both parts, so the next pm sprint close needs a fresh report (the old one stays in git history), and adds the reopen, with the close reason it had, as a decision to the sprint's Decisions: source=agent, or source=owner with --confirmed. When the records step fails, the epic is closed again with its old close reason, at a new close time. The reason (--text) states why, on at least two lines. Refuses a sprint that is open, and a sprint whose project is closed: reopen the project first with pm project reopen. Its tasks stay closed; add new work with pm task add. +Set a closed sprint open again: its work-store epic first, then one records commit that puts the record's '## Delivery report' back to 'Not closed yet.' in both parts, so the next pm sprint close needs a fresh report (the old one stays in git history), and adds the reopen, with the close reason it had, as a decision to the sprint's Decisions: source=agent, or source=owner with --confirmed. When the records step fails, the epic is closed again with its old close reason, at a new close time. The reason (--text) states why, on at least two lines. Refuses a sprint that is open, and a sprint whose project is closed: reopen the project first with pm project reopen. Its tasks stay closed; add new work with pm task add. A dropped proposal reopened is a proposal again. positional arguments: sprint_id the sprint's id diff --git a/internal/cli/testdata/proposal_commands.json b/internal/cli/testdata/proposal_commands.json new file mode 100644 index 00000000..f1fbe975 --- /dev/null +++ b/internal/cli/testdata/proposal_commands.json @@ -0,0 +1,587 @@ +[ + { + "covers": "action need --sprint", + "argv": [ + "action", + "need", + "--title", + "[TEST] run the check", + "--sprint", + "{P}", + "--text=Run the check." + ], + "outcome": "allowed" + }, + { + "covers": "action need --sprint", + "argv": [ + "action", + "need", + "--pr", + "https://github.com/o/r/pull/1", + "--sprint", + "{P}", + "--focus", + "the diff" + ], + "outcome": "refused", + "says": "sprint {P} is a proposal, not promoted; it has no work to review" + }, + { + "covers": "action need --task", + "argv": [ + "action", + "need", + "--title", + "[TEST] run it", + "--task", + "{P}", + "--text=Run it." + ], + "outcome": "refused", + "says": "is a sprint, not a task" + }, + { + "covers": "commit --sprint", + "argv": [ + "commit", + "--sprint", + "{P}", + "-m", + "refine the goal", + "{P_RECORD}" + ], + "outcome": "allowed", + "edit": true + }, + { + "covers": "decision add --sprint", + "argv": [ + "decision", + "add", + "--sprint", + "{P}", + "--decision", + "Retry the setup", + "--reason", + "The flake is in setup" + ], + "outcome": "allowed" + }, + { + "covers": "decision add --need", + "argv": [ + "decision", + "add", + "--sprint", + "{S}", + "--need", + "{P}", + "--decision", + "Retry", + "--reason", + "Cheap" + ], + "outcome": "refused" + }, + { + "covers": "decision need --sprint", + "argv": [ + "decision", + "need", + "--title", + "[TEST] which retry", + "--sprint", + "{P}", + "--question", + "Which retry?", + "--fact", + "It fails in setup.", + "--option", + "A", + "retry the setup", + "--cost", + "A", + "a slower test", + "--option", + "B", + "fix the race", + "--cost", + "B", + "a day", + "--default", + "A", + "cheap" + ], + "outcome": "allowed" + }, + { + "covers": "decision need --task", + "argv": [ + "decision", + "need", + "--title", + "[TEST] which retry", + "--task", + "{P}", + "--question", + "Which retry?", + "--fact", + "It fails in setup.", + "--option", + "A", + "retry the setup", + "--cost", + "A", + "a slower test", + "--option", + "B", + "fix the race", + "--cost", + "B", + "a day", + "--default", + "A", + "cheap" + ], + "outcome": "refused", + "says": "is a sprint, not a task" + }, + { + "covers": "dep add id", + "argv": [ + "dep", + "add", + "{P}", + "--on", + "{S}" + ], + "outcome": "allowed" + }, + { + "covers": "dep add --on", + "argv": [ + "dep", + "add", + "{S}", + "--on", + "{P}" + ], + "outcome": "allowed" + }, + { + "covers": "dep rm id", + "argv": [ + "dep", + "rm", + "{P}", + "--on", + "{S}" + ], + "outcome": "allowed", + "setup": [ + [ + "dep", + "add", + "{P}", + "--on", + "{S}" + ] + ] + }, + { + "covers": "dep rm --on", + "argv": [ + "dep", + "rm", + "{S}", + "--on", + "{P}" + ], + "outcome": "allowed", + "setup": [ + [ + "dep", + "add", + "{S}", + "--on", + "{P}" + ] + ] + }, + { + "covers": "design unchanged --sprint", + "argv": [ + "design", + "unchanged", + "parser", + "--sprint", + "{P}", + "--reason", + "The parser stays as it is" + ], + "outcome": "allowed" + }, + { + "covers": "doc new --sprint", + "argv": [ + "doc", + "new", + "flake-note", + "--title", + "Flake note", + "--sprint", + "{P}", + "--text=Where it fails." + ], + "outcome": "allowed" + }, + { + "covers": "doc new --task", + "argv": [ + "doc", + "new", + "flake-note", + "--title", + "Flake note", + "--task", + "{P}", + "--text=Where it fails." + ], + "outcome": "refused" + }, + { + "covers": "feedback add --sprint", + "argv": [ + "feedback", + "add", + "--sprint", + "{P}", + "--text=A refusal was unclear." + ], + "outcome": "allowed" + }, + { + "covers": "feedback add --task", + "argv": [ + "feedback", + "add", + "--task", + "{P}", + "--text=A refusal was unclear." + ], + "outcome": "allowed" + }, + { + "covers": "finding add --sprint", + "argv": [ + "finding", + "add", + "--sprint", + "{P}", + "--text=It fails in setup only." + ], + "outcome": "allowed" + }, + { + "covers": "need close need_id", + "argv": [ + "need", + "close", + "{P}", + "--reason", + "done" + ], + "outcome": "refused" + }, + { + "covers": "need edit need_id", + "argv": [ + "need", + "edit", + "{P}", + "--text=A new body." + ], + "outcome": "refused" + }, + { + "covers": "postmortem new --sprint", + "argv": [ + "postmortem", + "new", + "flake", + "--title", + "The flake", + "--sprint", + "{P}" + ], + "outcome": "allowed" + }, + { + "covers": "record link target", + "argv": [ + "record", + "link", + "{P}" + ], + "outcome": "allowed", + "service": true, + "says": "https://pm.example.test/sprints/demo-" + }, + { + "covers": "reply read ids", + "argv": [ + "reply", + "read", + "{P}" + ], + "outcome": "refused" + }, + { + "covers": "reply add id", + "argv": [ + "reply", + "add", + "{P}", + "--text=An answer." + ], + "outcome": "refused" + }, + { + "covers": "show --sprint", + "argv": [ + "show", + "--sprint", + "{P}" + ], + "outcome": "allowed", + "says": "{P} proposed" + }, + { + "covers": "show id", + "argv": [ + "show", + "{P}" + ], + "outcome": "allowed", + "says": "labels: proposal" + }, + { + "covers": "sprint close sprint_id", + "argv": [ + "sprint", + "close", + "{P}" + ], + "outcome": "refused", + "says": "sprint {P} is a proposal; drop it with pm sprint close {P} --dropped" + }, + { + "covers": "sprint close sprint_id", + "argv": [ + "sprint", + "close", + "{P}", + "--dropped", + "--reason", + "duplicate of {S}" + ], + "outcome": "drops" + }, + { + "covers": "sprint edit sprint_id", + "argv": [ + "sprint", + "edit", + "{P}", + "--title", + "Fix the setup flake", + "--text-file", + "-" + ], + "outcome": "allowed", + "stdin": "Refine it.\nThe proposal said too little.\n" + }, + { + "covers": "sprint move sprint_id", + "argv": [ + "sprint", + "move", + "{P}", + "--to", + "other", + "--text-file", + "-" + ], + "outcome": "allowed", + "stdin": "Refine it.\nThe proposal said too little.\n" + }, + { + "covers": "sprint promote sprint_id", + "argv": [ + "sprint", + "promote", + "{P}" + ], + "outcome": "promotes", + "says": "promoted {P}" + }, + { + "covers": "sprint promote sprint_id", + "argv": [ + "sprint", + "promote", + "{P}" + ], + "outcome": "refused", + "says": "sprint {P} is not an open proposal", + "setup": [ + [ + "sprint", + "close", + "{P}", + "--dropped", + "--reason", + "duplicate" + ] + ] + }, + { + "covers": "sprint reopen sprint_id", + "argv": [ + "sprint", + "reopen", + "{P}", + "--text-file", + "-" + ], + "outcome": "allowed", + "setup": [ + [ + "sprint", + "close", + "{P}", + "--dropped", + "--reason", + "duplicate" + ] + ], + "stdin": "Refine it.\nThe proposal said too little.\n" + }, + { + "covers": "task add --sprint", + "argv": [ + "task", + "add", + "--sprint", + "{P}", + "--title", + "[TEST] retry the setup" + ], + "outcome": "refused", + "says": "pm sprint promote {P}" + }, + { + "covers": "task add --task", + "argv": [ + "task", + "add", + "--task", + "{P}", + "--title", + "[TEST] retry the setup" + ], + "outcome": "refused" + }, + { + "covers": "task claim task_id", + "argv": [ + "task", + "claim", + "{P}" + ], + "outcome": "refused" + }, + { + "covers": "task close task_id", + "argv": [ + "task", + "close", + "{P}", + "--reason", + "done" + ], + "outcome": "refused" + }, + { + "covers": "task edit id", + "argv": [ + "task", + "edit", + "{P}", + "--title", + "Retry" + ], + "outcome": "refused", + "says": "is a sprint, not a task" + }, + { + "covers": "task move task_id", + "argv": [ + "task", + "move", + "{P}", + "--to", + "{S}", + "--text-file", + "-" + ], + "outcome": "refused", + "stdin": "Refine it.\nThe proposal said too little.\n" + }, + { + "covers": "task move --to", + "argv": [ + "task", + "move", + "{T}", + "--to", + "{P}", + "--text-file", + "-" + ], + "outcome": "refused", + "stdin": "Refine it.\nThe proposal said too little.\n", + "says": "pm sprint promote {P}" + }, + { + "covers": "task ready --sprint", + "argv": [ + "task", + "ready", + "--sprint", + "{P}" + ], + "outcome": "allowed", + "says": "no ready tasks" + }, + { + "covers": "task release id", + "argv": [ + "task", + "release", + "{P}" + ], + "outcome": "refused" + }, + { + "covers": "comment add id", + "argv": [ + "comment", + "add", + "{P}", + "--text=Seen it fail twice." + ], + "outcome": "allowed" + } +] diff --git a/internal/cli/where.go b/internal/cli/where.go index 81f2258c..4217ed44 100644 --- a/internal/cli/where.go +++ b/internal/cli/where.go @@ -2,26 +2,16 @@ package cli import ( "fmt" - "github.com/Yeeef/pm/internal/cli/fsutil" "io" - "path/filepath" - "strings" - "github.com/Yeeef/pm/internal/buildinfo" - "github.com/Yeeef/pm/internal/config" - "github.com/Yeeef/pm/internal/install" - "github.com/Yeeef/pm/internal/launch" - "github.com/Yeeef/pm/internal/proc" - "github.com/Yeeef/pm/internal/service" + "github.com/Yeeef/pm/internal/cli/where" "github.com/Yeeef/pm/internal/store" - pmsync "github.com/Yeeef/pm/internal/sync" - "github.com/Yeeef/pm/internal/work" ) // cmdWhere is pm where: with records, the store's path alone, for scripts; without, every location with its state. func cmdWhere(p *Parsed, here string, stdout io.Writer) error { if p.Get("what") == "" { - out, err := whereAll(here) + out, err := where.All(here) if err != nil { return err } @@ -35,124 +25,3 @@ func cmdWhere(p *Parsed, here string, stdout io.Writer) error { _, err = fmt.Fprintln(stdout, records) return err } - -const ( - whereSetup = "pm init" - whereBranch = "records" -) - -// whereGit is git's stripped stdout in dir, or "" when it fails. -func whereGit(dir string, args ...string) string { - res, err := proc.Run(append([]string{"git"}, args...), proc.Options{Cwd: &dir}) - if err != nil || res.Code != 0 { - return "" - } - return config.PyStrip(res.Stdout) -} - -// whereAll is every location an agent or the owner needs, each with its state; it works before setup, to show what -// is missing. The work store replaces Beads, so its line takes the place of Python's beads line. -func whereAll(here string) (string, error) { - storeDir, err := store.PathOf(here) - if err != nil { - return "", err - } - main := store.MainOf(storeDir) - cfg, err := config.Load(here) - if err != nil { - return "", err - } - - out := []string{fmt.Sprintf("pm %s %s", buildinfo.Version, launch.How())} - if !fsutil.IsDir(storeDir) { - out = append(out, fmt.Sprintf("store %s missing; run %s", storeDir, whereSetup)) - } else { - branch := whereGit(storeDir, "rev-parse", "--abbrev-ref", "HEAD") - line := fmt.Sprintf("store %s branch %s", storeDir, branch) - if branch != whereBranch { - line += fmt.Sprintf(" (must be %s)", whereBranch) - } - upstream := cfg.Remote + "/" + whereBranch - counts := strings.Fields(whereGit(storeDir, "rev-list", "--left-right", "--count", "HEAD..."+upstream)) - if len(counts) > 0 { - line += fmt.Sprintf(", %s ahead, %s behind %s (as of the last fetch)", counts[0], counts[1], upstream) - } else { - line += ", no " + upstream - } - out = append(out, line) - } - out = append(out, "clone "+store.DescribeClone(main, cfg.Remote)) - - top := whereGit(here, "rev-parse", "--show-toplevel") - if top == "" { - top = "." - } - if fsutil.Resolved(top) == fsutil.Resolved(storeDir) { - top = main - } else if store.IsWorkspace(top) { - top = filepath.Dir(top) - } - link := filepath.Join(top, "records") - ws, wsErr := store.WorkspaceAt(top) - var state string - switch { - case wsErr != nil: - state = wsErr.Error() - case ws != nil: - if state, err = ws.Describe(storeDir); err != nil { - state = "records workspace unreadable: " + err.Error() - } - case fsutil.IsLink(link) && fsutil.Resolved(link) == fsutil.Resolved(storeDir) && fsutil.Resolved(top) != fsutil.Resolved(main): - state = "records link to the shared store; " + whereSetup + " makes it this worktree's records workspace" - case fsutil.IsLink(link) && fsutil.Resolved(link) == fsutil.Resolved(storeDir): - state = "records link set up" - case !fsutil.Exists(link) && !fsutil.IsLink(link) && fsutil.Resolved(top) != fsutil.Resolved(main): - state = "no records workspace; run " + whereSetup - case !fsutil.Exists(link) && !fsutil.IsLink(link): - state = "no records link; run " + whereSetup - case !fsutil.IsLink(link) && whereGit(top, "ls-files", "--", "records") != "": - state = "records/ is this branch's tracked copy, not the link; merge the main branch, which tracks none, then run " + - whereSetup - default: - state = "records is not a link to the store; move it away and run " + whereSetup - } - out = append(out, fmt.Sprintf("checkout %s branch %s, %s", top, whereGit(top, "rev-parse", "--abbrev-ref", "HEAD"), state)) - out = append(out, store.Orphans(storeDir)...) - - workDir, _ := work.Locations(main) - workState, err := install.WorkState(main, cfg.Remote) - if err != nil { - return "", err - } - out = append(out, fmt.Sprintf("work %s %s", workDir, workState)) - - home := codexHome() - switch { - case !fsutil.IsDir(home): - out = append(out, fmt.Sprintf("codex %s missing, so Codex is not used here", home)) - case !fsutil.IsDir(storeDir): - out = append(out, fmt.Sprintf("codex %s writable_roots not checked without the store; run %s", - filepath.Join(home, "config.toml"), whereSetup)) - default: - path := filepath.Join(home, "config.toml") - state, err := install.CodexState(path, main) - if err != nil { - return "", err - } - out = append(out, fmt.Sprintf("codex %s %s", path, state)) - } - - _, health := service.Health(main) - out = append(out, health) - described, err := pmsync.Describe(main) - if err != nil { - return "", err - } - out = append(out, described...) - url, err := showSiteURL(here, main) - if err != nil { - return "", err - } - out = append(out, fmt.Sprintf("site %s (served by the pm service)", url)) - return strings.Join(out, "\n"), nil -} diff --git a/internal/cli/where/where.go b/internal/cli/where/where.go new file mode 100644 index 00000000..49ccf289 --- /dev/null +++ b/internal/cli/where/where.go @@ -0,0 +1,184 @@ +// Package where is pm where's report: every location an agent or the owner needs, each with its state (All), and the +// site's URL (SiteURL), which pm show and pm record link name too. +package where + +import ( + "fmt" + "github.com/Yeeef/pm/internal/cli/fsutil" + "os" + "path/filepath" + "strings" + + "github.com/Yeeef/pm/internal/buildinfo" + "github.com/Yeeef/pm/internal/config" + "github.com/Yeeef/pm/internal/install" + "github.com/Yeeef/pm/internal/launch" + "github.com/Yeeef/pm/internal/proc" + "github.com/Yeeef/pm/internal/service" + "github.com/Yeeef/pm/internal/store" + pmsync "github.com/Yeeef/pm/internal/sync" + "github.com/Yeeef/pm/internal/work" +) + +const ( + whereSetup = "pm init" + whereBranch = "records" +) + +// whereGit is git's stripped stdout in dir, or "" when it fails. +func whereGit(dir string, args ...string) string { + res, err := proc.Run(append([]string{"git"}, args...), proc.Options{Cwd: &dir}) + if err != nil || res.Code != 0 { + return "" + } + return config.PyStrip(res.Stdout) +} + +// All is every location an agent or the owner needs, each with its state; it works before setup, to show what +// is missing. The work store replaces Beads, so its line takes the place of Python's beads line. +func All(here string) (string, error) { + storeDir, err := store.PathOf(here) + if err != nil { + return "", err + } + main := store.MainOf(storeDir) + cfg, err := config.Load(here) + if err != nil { + return "", err + } + + out := []string{fmt.Sprintf("pm %s %s", buildinfo.Version, launch.How())} + if !fsutil.IsDir(storeDir) { + out = append(out, fmt.Sprintf("store %s missing; run %s", storeDir, whereSetup)) + } else { + branch := whereGit(storeDir, "rev-parse", "--abbrev-ref", "HEAD") + line := fmt.Sprintf("store %s branch %s", storeDir, branch) + if branch != whereBranch { + line += fmt.Sprintf(" (must be %s)", whereBranch) + } + upstream := cfg.Remote + "/" + whereBranch + counts := strings.Fields(whereGit(storeDir, "rev-list", "--left-right", "--count", "HEAD..."+upstream)) + if len(counts) > 0 { + line += fmt.Sprintf(", %s ahead, %s behind %s (as of the last fetch)", counts[0], counts[1], upstream) + } else { + line += ", no " + upstream + } + out = append(out, line) + } + out = append(out, "clone "+store.DescribeClone(main, cfg.Remote)) + + top := whereGit(here, "rev-parse", "--show-toplevel") + if top == "" { + top = "." + } + if fsutil.Resolved(top) == fsutil.Resolved(storeDir) { + top = main + } else if store.IsWorkspace(top) { + top = filepath.Dir(top) + } + link := filepath.Join(top, "records") + ws, wsErr := store.WorkspaceAt(top) + var state string + switch { + case wsErr != nil: + state = wsErr.Error() + case ws != nil: + if state, err = ws.Describe(storeDir); err != nil { + state = "records workspace unreadable: " + err.Error() + } + case fsutil.IsLink(link) && fsutil.Resolved(link) == fsutil.Resolved(storeDir) && fsutil.Resolved(top) != fsutil.Resolved(main): + state = "records link to the shared store; " + whereSetup + " makes it this worktree's records workspace" + case fsutil.IsLink(link) && fsutil.Resolved(link) == fsutil.Resolved(storeDir): + state = "records link set up" + case !fsutil.Exists(link) && !fsutil.IsLink(link) && fsutil.Resolved(top) != fsutil.Resolved(main): + state = "no records workspace; run " + whereSetup + case !fsutil.Exists(link) && !fsutil.IsLink(link): + state = "no records link; run " + whereSetup + case !fsutil.IsLink(link) && whereGit(top, "ls-files", "--", "records") != "": + state = "records/ is this branch's tracked copy, not the link; merge the main branch, which tracks none, then run " + + whereSetup + default: + state = "records is not a link to the store; move it away and run " + whereSetup + } + out = append(out, fmt.Sprintf("checkout %s branch %s, %s", top, whereGit(top, "rev-parse", "--abbrev-ref", "HEAD"), state)) + out = append(out, store.Orphans(storeDir)...) + + workDir, _ := work.Locations(main) + workState, err := install.WorkState(main, cfg.Remote) + if err != nil { + return "", err + } + out = append(out, fmt.Sprintf("work %s %s", workDir, workState)) + + codex, err := codexLine(storeDir, main) + if err != nil { + return "", err + } + out = append(out, codex) + + _, health := service.Health(main) + out = append(out, health) + described, err := pmsync.Describe(main) + if err != nil { + return "", err + } + out = append(out, described...) + url, err := SiteURL(here, main) + if err != nil { + return "", err + } + out = append(out, fmt.Sprintf("site %s (served by the pm service)", url)) + return strings.Join(out, "\n"), nil +} + +// codexLine is All's line for Codex's config: missing, unchecked before setup, or its writable roots' state. +func codexLine(storeDir, main string) (string, error) { + home := codexHome() + switch { + case !fsutil.IsDir(home): + return fmt.Sprintf("codex %s missing, so Codex is not used here", home), nil + case !fsutil.IsDir(storeDir): + return fmt.Sprintf("codex %s writable_roots not checked without the store; run %s", + filepath.Join(home, "config.toml"), whereSetup), nil + } + path := filepath.Join(home, "config.toml") + state, err := install.CodexState(path, main) + if err != nil { + return "", err + } + return fmt.Sprintf("codex %s %s", path, state), nil +} + +// SiteURL is Python's site_url(): the repo's site_url, else http://localhost:. +func SiteURL(here, main string) (string, error) { + cfg, err := config.Load(here) + if err != nil { + return "", err + } + if cfg.SiteURL != "" { + return cfg.SiteURL, nil + } + port, err := service.PortFor(main, int(cfg.Port)) + if err != nil { + return "", err + } + return fmt.Sprintf("http://localhost:%d", port), nil +} + +func homeDir() string { + if h := os.Getenv("HOME"); h != "" { + return h + } + h, err := os.UserHomeDir() + if err != nil { + return "" // as before the move: no home, so a relative .codex + } + return h +} + +func codexHome() string { + if h := os.Getenv("CODEX_HOME"); h != "" { + return h + } + return filepath.Join(homeDir(), ".codex") +} diff --git a/internal/cli/writes_test.go b/internal/cli/writes_test.go index b20db5b4..2bc360f4 100644 --- a/internal/cli/writes_test.go +++ b/internal/cli/writes_test.go @@ -26,7 +26,7 @@ func TestWrapFillPutsALongWordOnItsOwnLine(t *testing.T) { } }) t.Run("FirstSentenceEndsAtAUnicodeSpace", func(t *testing.T) { - if got := firstSentence("Ends here. Next one.", 110); got != "Ends here." { + if got := records.FirstSentence("Ends here. Next one.", 110); got != "Ends here." { t.Errorf("got %q", got) } }) diff --git a/internal/item/item.go b/internal/item/item.go index 0ecebf3a..bcde81e0 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -8,6 +8,7 @@ package item import ( "fmt" "regexp" + "slices" "strconv" "strings" "time" @@ -73,7 +74,7 @@ type Item struct { CloseReason string `json:"close_reason,omitempty"` // a review keeps "merged as " Number int `json:"number,omitempty"` // a sprint's number in its record name; sprints only BlockedBy []string `json:"blocked_by,omitempty"` // ids of the items that must close first - Labels []string `json:"labels,omitempty"` // free tags; pm's logic reads none + Labels []string `json:"labels,omitempty"` // free tags; pm's logic reads one, ProposalLabel Holder *Holder `json:"holder,omitempty"` // nil on every closed item StartedAt time.Time `json:"started_at,omitzero"` // the first claim CreatedAt time.Time `json:"created_at"` @@ -84,6 +85,26 @@ type Item struct { Need *NeedInfo `json:"need,omitempty"` // needs only } +// ProposalLabel marks a proposal: a sprint pm sprint propose made, which pm sprint promote makes ordinary by +// deleting the label (the sprint-proposals design page). +const ProposalLabel = "proposal" + +// PromoteNote is the note pm sprint promote leaves on the sprint, by the promoting session: the promote's date. +const PromoteNote = "pm sprint promote" + +// IsProposal is whether s is a sprint labelled ProposalLabel: open, a proposal; closed, a dropped proposal. +func IsProposal(s *Item) bool { return s.Type == Sprint && slices.Contains(s.Labels, ProposalLabel) } + +// Promoted is when sprint s was promoted (its PromoteNote); zero when it never was. +func Promoted(s *Item) time.Time { + for _, c := range s.Comments { + if c.Kind == Note && c.Text == PromoteNote { + return c.CreatedAt + } + } + return time.Time{} +} + // HasReply is whether the item holds an owner's reply: a need the owner answered. func HasReply(it *Item) bool { for _, c := range it.Comments { diff --git a/internal/item/item_test.go b/internal/item/item_test.go index 4812becb..b2ab259c 100644 --- a/internal/item/item_test.go +++ b/internal/item/item_test.go @@ -4,6 +4,7 @@ import ( "os/exec" "strings" "testing" + "time" ) // The packages that only read items build, and link their test binaries, without Dolt: a Dolt link costs 5-8 s of @@ -29,3 +30,31 @@ func TestTheItemReadersLinkNoDolt(t *testing.T) { } } } + +// A proposal is a sprint carrying the label (the sprint-proposals design page, Representation), whatever is under it; +// a task carrying it is none. Promoted dates the promote by its note, and is zero without one. +func TestAProposalIsASprintLabelledProposalAndItsPromoteIsDatedByItsNote(t *testing.T) { + at := time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC) + for _, c := range []struct { + name string + it *Item + want bool + }{ + {"labelled", &Item{Type: Sprint, Labels: []string{ProposalLabel}}, true}, + {"labelled and closed", &Item{Type: Sprint, Labels: []string{"bug", ProposalLabel}, Status: Closed}, true}, + {"promoted", &Item{Type: Sprint}, false}, + {"a task labelled proposal", &Item{Type: Task, Labels: []string{ProposalLabel}}, false}, + } { + if got := IsProposal(c.it); got != c.want { + t.Errorf("%s: IsProposal = %v, want %v", c.name, got, c.want) + } + } + promoted := &Item{Type: Sprint, Comments: []Comment{{Kind: Reply, Text: PromoteNote, CreatedAt: at.AddDate(0, 0, 1)}, + {Kind: Note, Author: "sess", Text: PromoteNote, CreatedAt: at}}} + if got := Promoted(promoted); !got.Equal(at) { + t.Errorf("Promoted = %v, want %v", got, at) + } + if got := Promoted(&Item{Type: Sprint}); !got.IsZero() { + t.Errorf("Promoted of a sprint never promoted = %v, want zero", got) + } +} diff --git a/internal/records/checks.go b/internal/records/checks.go index df4301d6..56cee0f5 100644 --- a/internal/records/checks.go +++ b/internal/records/checks.go @@ -67,6 +67,9 @@ func (x *Items) Children(parent string) []*item.Item { return out } +// Proposal is whether it is an open proposal: a sprint labelled proposal (item.IsProposal). +func (x *Items) Proposal(it *item.Item) bool { return it.Status == item.Open && item.IsProposal(it) } + // ProjectOf is the project record a record belongs to: by the item tree for sprints and docs with a bead, by its // sprint for a postmortem with one, by `project:` otherwise; nil when there is none. func ProjectOf(rec *Record, recs []*Record, items *Items) *Record { diff --git a/internal/records/sections.go b/internal/records/sections.go index 8ba80ca7..7e831e1a 100644 --- a/internal/records/sections.go +++ b/internal/records/sections.go @@ -158,6 +158,20 @@ func FirstPara(text string) string { return Strip(spaceRun.ReplaceAllString(first, " ")) } +var sentenceEnd = regexp.MustCompile(`^(.+?[.!?])(` + pyS + `|$)`) // Python's \s + +// FirstSentence is the first sentence of text's first paragraph, cut to limit characters with "…". +func FirstSentence(text string, limit int) string { + s := FirstPara(text) + if m := sentenceEnd.FindStringSubmatch(s); m != nil { + s = m[1] + } + if r := []rune(s); len(r) > limit { + return strings.TrimRightFunc(string(r[:limit-1]), config.IsPySpace) + "…" + } + return s +} + var bulletRE = regexp.MustCompile(`(?m)^` + pyS + `*[-*]` + pyS + `+(.+?)` + pyS + `*$`) // SummaryLine is a day summary on one line for lists: its bullets ("- " or "* ") joined with " · ", else its first diff --git a/internal/records/templates.go b/internal/records/templates.go index 1e087cf4..a84da413 100644 --- a/internal/records/templates.go +++ b/internal/records/templates.go @@ -49,9 +49,20 @@ var ( } ) -// SprintText is a new sprint record: the frame's Goal, Scope and Done when, every other section empty. +// SprintText is a new sprint record: the frame's Goal, Scope and Done when, every other section empty but Findings, +// which holds a proposal's Evidence (pm sprint propose) as its first finding. func SprintText(title, bead string, frame map[string]string) string { p := SprintPrompts + findings := NoneYet + if ev := frame["Evidence"]; ev != "" { // a proposal's: its first finding, indented so a fence stays in the bullet + lines := strings.Split(ev, "\n") + for i := range lines { + if lines[i] != "" { + lines[i] = " " + lines[i] + } + } + findings = "- Proposal evidence:\n\n" + strings.Join(lines, "\n") + } return "---\ntype: sprint\ntitle: " + title + "\nbead: " + bead + "\n---\n\n" + "## Goal\n\n" + p["Goal"] + "\n\n" + frame["Goal"] + "\n\n" + "## Scope\n\n" + p["Scope"] + "\n\n" + frame["Scope"] + "\n\n" + @@ -59,7 +70,7 @@ func SprintText(title, bead string, frame map[string]string) string { "## Design pages\n\n" + p["Design pages"] + "\n\n" + NoneYet + "\n\n" + "## Progress\n\n" + p["Progress"] + "\n\n" + "## Decisions\n\n" + p["Decisions"] + "\n\n" + NoneYet + "\n\n" + - "## Findings\n\n" + p["Findings"] + "\n\n" + NoneYet + "\n\n" + + "## Findings\n\n" + p["Findings"] + "\n\n" + findings + "\n\n" + "## Delivery report\n\n" + p["Delivery report"] + "\n\n" + "### Outcome\n\n" + p["Outcome"] + "\n\n" + NotClosed + "\n\n" + "### Against \"Done when\"\n\n" + p[`Against "Done when"`] + "\n\n" + NotClosed + "\n" diff --git a/internal/service/fake_test.go b/internal/service/fake_test.go index a568b875..6d7366cf 100644 --- a/internal/service/fake_test.go +++ b/internal/service/fake_test.go @@ -245,6 +245,7 @@ func (s *fakeStore) SetResolution(string, work.Resolution) error { return errUnu func (s *fakeStore) SetCloseReason(string, string) error { return errUnused } func (s *fakeStore) Reopen(string) error { return errUnused } func (s *fakeStore) Move(string, string) error { return errUnused } +func (s *fakeStore) Promote(string, string) error { return errUnused } func (s *fakeStore) MoveSprint(string, string, string) (work.Item, error) { return work.Item{}, errUnused } diff --git a/internal/site/activity.go b/internal/site/activity.go new file mode 100644 index 00000000..217b2520 --- /dev/null +++ b/internal/site/activity.go @@ -0,0 +1,153 @@ +package site + +import ( + "strings" + "time" + + "github.com/Yeeef/pm/internal/item" + "github.com/Yeeef/pm/internal/records" + "github.com/Yeeef/pm/internal/store" +) + +// A day's activity: what the day page shows of a sprint, and DayActivity, the text pm day summarize sends the model. + +// sprintMarks is what a sprint itself did on the day, oldest first: "proposed" (a proposal, or a sprint promoted from +// one) or "opened" on its create date; "started" on the date pm sprint promote made it ordinary (its note); "dropped" +// on the date a proposal closed. A finished sprint is the caller's: the page shows no row for it, the text its Outcome. +// Each mark is dated by its own event, so a day's marks never change later. +func sprintMarks(sp *Item, on func(time.Time) bool) []string { + var marks []string + promoted := item.Promoted(sp) + proposal := item.IsProposal(sp) || !promoted.IsZero() + switch { + case on(sp.CreatedAt) && proposal: + marks = append(marks, "proposed") + case on(sp.CreatedAt): + marks = append(marks, "opened") + } + if !promoted.IsZero() && on(promoted) { + marks = append(marks, "started") + } + if sp.Status == item.Closed && item.IsProposal(sp) && on(sp.ClosedAt) { + marks = append(marks, "dropped") + } + return marks +} + +// DayActivity is plain text of what happened on the local day: per project, the sprints finished (with their +// Outcome), proposed, opened, started or dropped (sprintMarks), the tasks finished (with their close reason), started +// or opened, and the owner requests raised or closed that day; the docs dated that day; and the records committed that +// day in the records store at dir, not counting the generated summaries. The summary's input and, hashed, its digest. +// It holds only events dated that day, never current state, so a day's digest changes only when that day's events do. +// Empty when nothing happened that day. +func DayActivity(day, dir string, recs []*Record, items *Items) (string, error) { + on := func(t time.Time) bool { return LocalDay(t) == day } + var out []string + for _, p := range recs { + if p.Type() != "project" { + continue + } + var lines []string + for _, sp := range sprintsOf(items, p.Bead()) { + rows, err := sprintActivity(sp, recs, items, on) + if err != nil { + return "", err + } + if rows != nil { + lines = append(append(lines, " sprint "+sp.Title), rows...) + } + } + if lines != nil { + out = append(append(out, "project "+p.Title()), lines...) + } + } + for _, rec := range recs { + if rec.Type() == "doc" && rec.Meta["date"].Str == day { + out = append(out, "doc "+rec.Title()+" ("+rec.Rel+")") + } + } + log, err := store.Git(dir, "log", "--since="+day+"T00:00", "--until="+day+"T23:59:59", "--format=%x00%s", + "--name-only") + if err != nil { + return "", err + } + for _, chunk := range strings.Split(log, "\x00")[1:] { + var lines []string + for _, l := range strings.Split(chunk, "\n") { + if l != "" { + lines = append(lines, l) + } + } + if len(lines) == 0 { + continue + } + var names []string + for _, n := range lines[1:] { + if !strings.HasSuffix(n, ".summary.json") { + names = append(names, n) + } + } + if names != nil { + out = append(out, "records commit: "+lines[0]+" ["+strings.Join(names, ", ")+"]") + } + } + return strings.Join(out, "\n"), nil +} + +func sprintActivity(sp *Item, recs []*Record, items *Items, on func(time.Time) bool) ([]string, error) { + var rows []string + if on(sp.ClosedAt) && !item.IsProposal(sp) { + row := " sprint finished" + for _, rec := range recs { + if rec.Type() == "sprint" && rec.Bead() == sp.ID { + o, err := records.Outcome(rec) + if err != nil { + return nil, err + } + if o != "" { + row += ": " + o + } + break + } + } + rows = append(rows, row) + } + for _, m := range sprintMarks(sp, on) { + if m == "dropped" { + rows = append(rows, " proposal dropped ("+sp.CloseReason+")") + } else { + rows = append(rows, " sprint "+m) + } + } + // a dismissed need is a [TEST] or replaced one; a dismissed task is a dropped one, which moved the day + for _, t := range childrenWhere(items, sp.ID, func(it *Item) bool { + return it.Resolution != item.Dismissed || it.Type == item.Task + }) { + request := t.Type == item.Need + noun := "task" + if request { + noun = "request to the owner (" + Kind(t) + ")" + } + if on(t.ClosedAt) { + verb := "closed" + if t.Resolution == item.Dismissed { + verb = "dropped" + } + row := " " + noun + " " + verb + ": " + t.Title + if t.CloseReason != "" { + row += " (" + t.CloseReason + ")" + } + rows = append(rows, row) + } else if on(t.StartedAt) { + rows = append(rows, " "+noun+" started: "+t.Title) + } + if on(t.CreatedAt) { + if request { + rows = append(rows, " "+noun+" raised: "+t.Title) + } else { + rows = append(rows, " task opened: "+t.Title) + } + } + } + return rows, nil +} diff --git a/internal/cli/day_test.go b/internal/site/activity_test.go similarity index 56% rename from internal/cli/day_test.go rename to internal/site/activity_test.go index 510af9de..b22b4ed1 100644 --- a/internal/cli/day_test.go +++ b/internal/site/activity_test.go @@ -1,4 +1,4 @@ -package cli +package site import ( "os" @@ -7,8 +7,8 @@ import ( "testing" "time" + "github.com/Yeeef/pm/internal/item" "github.com/Yeeef/pm/internal/records" - "github.com/Yeeef/pm/internal/work" ) const dayProject = "---\ntype: project\ntitle: Demo\nbead: repo-demo\n---\n\n## Goal\n\nA demo.\n" @@ -55,26 +55,35 @@ func TestDayActivityHoldsOnlyThatDaysEvents(t *testing.T) { t.Fatal(err) } - items := []work.Item{ - {ID: "repo-demo", Type: work.Project, Title: "Demo", CreatedAt: before}, - {ID: "repo-demo.2", Type: work.Sprint, Parent: "repo-demo", Title: "Sprint 2: Second", CreatedAt: before, - Status: work.Closed, ClosedAt: at}, - {ID: "repo-demo.10", Type: work.Sprint, Parent: "repo-demo", Title: "Sprint 10: Tenth", CreatedAt: at}, - {ID: "repo-demo.2.10", Type: work.Task, Parent: "repo-demo.2", Title: "Late task", CreatedAt: at}, - {ID: "repo-demo.2.9", Type: work.Task, Parent: "repo-demo.2", Title: "Done task", CreatedAt: before, - StartedAt: at, Status: work.Closed, ClosedAt: at, CloseReason: "commit abc"}, - {ID: "repo-demo.2.8", Type: work.Task, Parent: "repo-demo.2", Title: "Started task", CreatedAt: before, + all := []item.Item{ + {ID: "repo-demo", Type: item.Project, Title: "Demo", CreatedAt: before}, + {ID: "repo-demo.2", Type: item.Sprint, Parent: "repo-demo", Title: "Sprint 2: Second", CreatedAt: before, + Status: item.Closed, ClosedAt: at}, + {ID: "repo-demo.10", Type: item.Sprint, Parent: "repo-demo", Title: "Sprint 10: Tenth", CreatedAt: at}, + {ID: "repo-demo.2.10", Type: item.Task, Parent: "repo-demo.2", Title: "Late task", CreatedAt: at}, + {ID: "repo-demo.2.9", Type: item.Task, Parent: "repo-demo.2", Title: "Done task", CreatedAt: before, + StartedAt: at, Status: item.Closed, ClosedAt: at, CloseReason: "commit abc"}, + {ID: "repo-demo.2.8", Type: item.Task, Parent: "repo-demo.2", Title: "Started task", CreatedAt: before, StartedAt: at}, - {ID: "repo-demo.2.7", Type: work.Need, Parent: "repo-demo.2", Title: "Run it", CreatedAt: at, - Need: &work.NeedInfo{Kind: work.Action}}, - {ID: "repo-demo.2.6", Type: work.Need, Parent: "repo-demo.2", Title: "Dismissed", CreatedAt: at, - Status: work.Closed, ClosedAt: at, Resolution: work.Dismissed, Need: &work.NeedInfo{Kind: work.Decision}}, - {ID: "repo-demo.2.5", Type: work.Task, Parent: "repo-demo.2", Title: "Old task", CreatedAt: before}, - {ID: "repo-demo.2.4", Type: work.Task, Parent: "repo-demo.2", Title: "Dropped task", CreatedAt: before, - Status: work.Closed, ClosedAt: at, Resolution: work.Dismissed, CloseReason: "a library does it"}, + {ID: "repo-demo.2.7", Type: item.Need, Parent: "repo-demo.2", Title: "Run it", CreatedAt: at, + Need: &item.NeedInfo{Kind: item.Action}}, + {ID: "repo-demo.2.6", Type: item.Need, Parent: "repo-demo.2", Title: "Dismissed", CreatedAt: at, + Status: item.Closed, ClosedAt: at, Resolution: item.Dismissed, Need: &item.NeedInfo{Kind: item.Decision}}, + {ID: "repo-demo.2.5", Type: item.Task, Parent: "repo-demo.2", Title: "Old task", CreatedAt: before}, + {ID: "repo-demo.2.4", Type: item.Task, Parent: "repo-demo.2", Title: "Dropped task", CreatedAt: before, + Status: item.Closed, ClosedAt: at, Resolution: item.Dismissed, CloseReason: "a library does it"}, + // proposals: one made today, one promoted today (its note), one dropped today + {ID: "repo-demo.11", Type: item.Sprint, Parent: "repo-demo", Title: "Sprint 11: Proposed", CreatedAt: at, + Labels: []string{item.ProposalLabel}}, + {ID: "repo-demo.12", Type: item.Sprint, Parent: "repo-demo", Title: "Sprint 12: Started", CreatedAt: before, + Comments: []item.Comment{{Kind: item.Note, Author: "sess-1", Text: item.PromoteNote, CreatedAt: at}}}, + {ID: "repo-demo.12.1", Type: item.Task, Parent: "repo-demo.12", Title: "First", CreatedAt: at}, + {ID: "repo-demo.13", Type: item.Sprint, Parent: "repo-demo", Title: "Sprint 13: Dropped", CreatedAt: before, + Labels: []string{item.ProposalLabel}, Status: item.Closed, ClosedAt: at, Resolution: item.Dismissed, + CloseReason: "duplicate of sprint 11"}, } - r := &repo{records: dir, items: items, x: records.NewItems(items), recs: recs} - got, err := r.dayActivity(day) + items := records.NewItems(all) + got, err := DayActivity(day, dir, recs, items) if err != nil { t.Fatal(err) } @@ -88,12 +97,19 @@ func TestDayActivityHoldsOnlyThatDaysEvents(t *testing.T) { " task opened: Late task\n" + " sprint Sprint 10: Tenth\n" + " sprint opened\n" + + " sprint Sprint 11: Proposed\n" + + " sprint proposed\n" + + " sprint Sprint 12: Started\n" + + " sprint started\n" + + " task opened: First\n" + + " sprint Sprint 13: Dropped\n" + + " proposal dropped (duplicate of sprint 11)\n" + "doc A note (docs/" + day + "-note)\n" + "records commit: records [docs/" + day + "-note.md, projects/demo.md, sprints/demo-2.md]" if got != want { t.Errorf("got\n%s\nwant\n%s", got, want) } - if got, err := r.dayActivity(at.AddDate(0, 0, -1).Format(time.DateOnly)); err != nil || got != "" { + if got, err := DayActivity(at.AddDate(0, 0, -1).Format(time.DateOnly), dir, recs, items); err != nil || got != "" { t.Errorf("a day without events: %q, %v", got, err) } } diff --git a/internal/site/site.go b/internal/site/site.go index abdda5a5..24a99286 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -4,7 +4,8 @@ // // serve.go holds what only the service's pages have: the reply forms and the status line. versions.go serves each // draft beside the published records as a version, with the version bar and the URLs under DraftsDir; diff.go is a -// version's diff by section. The pages load Mermaid at one exact version, MermaidVersion. A page change shows in the +// version's diff by section. activity.go is a day's activity: the sprint rows a day page shows and the text pm day +// summarize sends the model. The pages load Mermaid at one exact version, MermaidVersion. A page change shows in the // golden pages (testdata/constructs/pages); a construct the fixture lacks gets a record or item in testdata/constructs. package site @@ -45,7 +46,7 @@ type Dates = map[string]store.Dates func errorf(format string, a ...any) error { return &records.Error{Msg: fmt.Sprintf(format, a...)} } var pills = map[string][2]string{"done": {"done", "DONE"}, "dropped": {"dropped", "DROPPED"}, - "running": {"run", "RUNNING"}, "blocked": {"blocked", "BLOCKED"}, "ready": {"queued", "READY"}} + "proposed": {"proposed", "PROPOSED"}, "running": {"run", "RUNNING"}, "blocked": {"blocked", "BLOCKED"}, "ready": {"queued", "READY"}} func pill(st string) string { p := pills[st] @@ -56,21 +57,26 @@ func pill(st string) string { func isEpic(it *Item) bool { return it.Type == item.Project || it.Type == item.Sprint } -// State is one of done, dropped, running, blocked and ready: done once closed, dropped for a task closed as dismissed -// (pm task close --dropped: not done), running while held or, for a project or sprint, once any child has started or -// finished; blocked while a blocker is not closed. +// State is one of done, dropped, proposed, running, blocked and ready: done once closed, dropped for a task closed as +// dismissed (pm task close --dropped: not done) and for a closed proposal, proposed for an open one +// (item.IsProposal), running while held or, for a project or sprint, once any child has started or finished; blocked +// while a blocker is not closed. func State(it *Item, items *Items) string { switch { case it.Status == item.Closed && it.Type == item.Task && it.Resolution == item.Dismissed: return "dropped" + case it.Status == item.Closed && item.IsProposal(it): + return "dropped" case it.Status == item.Closed: return "done" + case item.IsProposal(it): + return "proposed" case it.Holder != nil: return "running" } if isEpic(it) { for _, k := range items.All() { - if k.Parent == it.ID && (k.Holder != nil || k.Status == item.Closed) { + if k.Parent == it.ID && (k.Holder != nil || k.Status == item.Closed && !item.IsProposal(k)) { return "running" } } @@ -193,7 +199,7 @@ func inline(text string) string { func progress(project *Record, recs []*Record, items *Items, frm *Record) (string, error) { root := project.Bead() - graph := TaskGraph(childrenWhere(items, root, nil), items) + graph := TaskGraph(childrenWhere(items, root, func(it *Item) bool { return !item.IsProposal(it) }), items) var sprints []*Record for _, r := range recs { if r.Type() == "sprint" && records.ProjectOf(r, recs, items) == project { @@ -207,6 +213,9 @@ func progress(project *Record, recs []*Record, items *Items, frm *Record) (strin if b == nil { return "", errorf("%s: bead %s not found in the work store", r.Rel, r.Bead()) } + if items.Proposal(b) { // the Proposed section lists it + continue + } report, err := records.Outcome(r) if err != nil { return "", err @@ -214,6 +223,8 @@ func progress(project *Record, recs []*Record, items *Items, frm *Record) (strin out := "not closed" if report != "" { out = inline(report) + } else if item.IsProposal(b) { + out = "dropped: " + esc(b.CloseReason) } rows = append(rows, ``+esc(r.Title())+""+ ""+esc(utcDate(b.CreatedAt))+""+ @@ -225,7 +236,51 @@ func progress(project *Record, recs []*Record, items *Items, frm *Record) (strin table = `
` + "" + strings.Join(rows, "") + "
SprintOpenedGoalOutcomeStatus
\n" } - return graph + table, nil + return graph + table + proposedSection(childrenWhere(items, root, nil), recs, items, frm), nil +} + +// OpenSprintRows is pm sprint propose's duplicate list: a line per open sprint under project but except, proposals +// included, with its state, id, title and its goal's first sentence. +func OpenSprintRows(project, except string, recs []*Record, items *Items) []string { + var out []string + for _, sp := range sprintsOf(items, project) { + if sp.Status == item.Closed || sp.ID == except { + continue + } + line := fmt.Sprintf(" %-8s %s %s", State(sp, items), sp.ID, sp.Title) + for _, r := range recs { + if b, ok := r.ID("bead"); ok && b == sp.ID && r.Type() == "sprint" { + line += " " + records.FirstSentence(records.SectionText(r.Body, "Goal"), 110) + } + } + out = append(out, line) + } + return out +} + +// proposedSection is the collapsed Proposed section: a row per open proposal among sprints, with its pill, its title +// linking its record, and its goal's first sentence; empty when there is none. +func proposedSection(sprints []*Item, recs []*Record, items *Items, frm *Record) string { + var rows []string + for _, sp := range sprints { + if !items.Proposal(sp) { + continue + } + title, goal := esc(sp.Title), "" + for _, r := range recs { + if b, ok := r.ID("bead"); ok && b == sp.ID && r.Type() == "sprint" { + title = `` + title + "" + goal = `` + inline(records.FirstSentence(records.SectionText(r.Body, "Goal"), 200)) + + "" + } + } + rows = append(rows, "
  • "+pill("proposed")+" "+title+goal+"
  • ") + } + if rows == nil { + return "" + } + return fmt.Sprintf(`
    Proposed (%d)
      %s
    `+"\n", + len(rows), strings.Join(rows, "")) } type unsprintedItem struct { @@ -374,10 +429,18 @@ func dayActivity(day string, items *Items, under string, recs []*Record, frm *Re var out []string for _, sp := range sprintsOf(items, under) { var rows []string - if LocalDay(sp.CreatedAt) == day { - rows = append(rows, `
  • OPENED this sprint
  • `) + for _, m := range sprintMarks(sp, func(t time.Time) bool { return LocalDay(t) == day }) { + row := `
  • OPENED this sprint
  • ` + switch m { + case "proposed": + row = "
  • " + pill("proposed") + " this sprint
  • " + case "started": + row = `
  • STARTED this sprint: promoted from a proposal
  • ` + case "dropped": + row = "
  • " + pill("dropped") + ` this proposal ` + esc(sp.CloseReason) + "
  • " + } + rows = append(rows, row) } - moved := false for _, t := range items.Children(sp.ID) { for _, v := range dayVerbs { if LocalDay(v.at(t)) == day { @@ -386,12 +449,11 @@ func dayActivity(day string, items *Items, under string, recs []*Record, frm *Re } rows = append(rows, `
  • `+strings.ToUpper(v.verb)+" "+ esc(t.Title)+` `+esc(t.ID)+"
  • ") - moved = true break } } } - if moved || LocalDay(sp.CreatedAt) == day { + if rows != nil { out = append(out, `

    `+pill(State(sp, items))+sprintTitle(sp.ID, sp.Title, recs, frm)+ `

      `+strings.Join(rows, "")+"
    ") } @@ -417,6 +479,10 @@ func activityDays(recs []*Record, items *Items) map[string]bool { } for _, sp := range sprintsOf(items, p.Bead()) { days[LocalDay(sp.CreatedAt)] = true + if item.IsProposal(sp) { // a dropped proposal's day + days[LocalDay(sp.ClosedAt)] = true + } + days[LocalDay(item.Promoted(sp))] = true for _, t := range items.Children(sp.ID) { for _, v := range dayVerbs { days[LocalDay(v.at(t))] = true @@ -1021,7 +1087,10 @@ func sprintBody(rec, proj *Record, recs []*Record, items *Items, render func(str b := items.Get(rec.Bead()) pre := `

    ` + pill(State(b, items)) + "" + esc(rec.Bead()) + "" + "opened " + esc(utcDate(b.CreatedAt)) + "

    " - generated := TaskGraph([]*Item{b}, items) + generated := `

    No tasks yet.

    ` // a chart of one empty box says no more + if len(items.Children(b.ID)) > 0 { + generated = TaskGraph([]*Item{b}, items) + } body = replaceFirst(progressRE, body, func(g string) string { return g + "\n" + generated + "\n" }) var cards []card for _, it := range OwnerTasks(items, rec.Bead()) { @@ -1271,7 +1340,7 @@ func indexSprints(p *Record, recs []*Record, items *Items) string { } var rows []string for _, sp := range sprints { - if sp.Status != item.Closed || shown[sp.ID] { + if (sp.Status != item.Closed || shown[sp.ID]) && !items.Proposal(sp) { n, m := 0, 0 for _, t := range items.Children(sp.ID) { switch State(t, items) { @@ -1286,18 +1355,22 @@ func indexSprints(p *Record, recs []*Record, items *Items) string { if r := sprintRecs[sp.ID]; r != nil { title = `` + esc(sp.Title) + "" } - rows = append(rows, "
  • "+pill(State(sp, items))+" "+title+ - fmt.Sprintf(`%d of %d tasks done
  • `, n, m)) + count := fmt.Sprintf("%d of %d tasks done", n, m) + if item.IsProposal(sp) { + count = "dropped: " + esc(sp.CloseReason) + } + rows = append(rows, "
  • "+pill(State(sp, items))+" "+title+``+count+"
  • ") } } if hidden := len(done) - DoneSprintsShown; hidden > 0 { rows = append(rows, fmt.Sprintf(`
  • %d older done sprints not shown; `+ `the project page lists every sprint
  • `, hidden, p.Out())) } + list := "" if rows != nil { - return `

    Sprints

      ` + strings.Join(rows, "") + "
    " + list = `

    Sprints

      ` + strings.Join(rows, "") + "
    " } - return "" + return list + proposedSection(sprints, recs, items, nil) } func indexDesigns(p *Record, recs []*Record, items *Items, dates Dates) string { diff --git a/internal/site/testdata/constructs/items.json b/internal/site/testdata/constructs/items.json index b5d5c851..8056e35c 100644 --- a/internal/site/testdata/constructs/items.json +++ b/internal/site/testdata/constructs/items.json @@ -422,6 +422,102 @@ "comments": [], "need": null }, + { + "id": "demo.14", + "type": "sprint", + "title": "Sprint 14: Retry the flaky sync test", + "description": "", + "status": "open", + "resolution": null, + "close_reason": null, + "number": 14, + "parent": "demo", + "blocked_by": [], + "labels": [ + "proposal" + ], + "holder": null, + "started_at": null, + "created_at": "2026-10-05T12:00:00Z", + "updated_at": null, + "closed_at": null, + "closed_by": null, + "comments": [], + "need": null + }, + { + "id": "demo.15", + "type": "sprint", + "title": "Sprint 15: Index the docs", + "description": "", + "status": "open", + "resolution": null, + "close_reason": null, + "number": 15, + "parent": "demo", + "blocked_by": [], + "labels": [], + "holder": null, + "started_at": null, + "created_at": "2026-10-03T12:00:00Z", + "updated_at": null, + "closed_at": null, + "closed_by": null, + "comments": [ + { + "id": "00000000-0000-4000-8000-000000000015", + "kind": "note", + "author": "sess-1", + "text": "pm sprint promote", + "created_at": "2026-10-05T11:00:00Z" + } + ], + "need": null + }, + { + "id": "demo.15.1", + "type": "task", + "title": "Build the index", + "description": "", + "status": "open", + "resolution": null, + "close_reason": null, + "number": null, + "parent": "demo.15", + "blocked_by": [], + "labels": [], + "holder": null, + "started_at": null, + "created_at": "2026-10-05T12:00:00Z", + "updated_at": null, + "closed_at": null, + "closed_by": null, + "comments": [], + "need": null + }, + { + "id": "demo.16", + "type": "sprint", + "title": "Sprint 16: Cache the parser", + "description": "", + "status": "closed", + "resolution": "dismissed", + "close_reason": "duplicate of sprint 14", + "number": 16, + "parent": "demo", + "blocked_by": [], + "labels": [ + "proposal" + ], + "holder": null, + "started_at": null, + "created_at": "2026-10-05T12:00:00Z", + "updated_at": null, + "closed_at": "2026-10-05T15:00:00Z", + "closed_by": null, + "comments": [], + "need": null + }, { "id": "demo.20", "type": "task", diff --git a/internal/site/testdata/constructs/pages/days/2026-10-04.html b/internal/site/testdata/constructs/pages/days/2026-10-04.html index aefa5929..f5a82494 100644 --- a/internal/site/testdata/constructs/pages/days/2026-10-04.html +++ b/internal/site/testdata/constructs/pages/days/2026-10-04.html @@ -45,7 +45,7 @@

    Actions await you

    Reply below with the evidence once done; the agent checks it and closes this.

    Sprints

    Which sprints moved today, and what changed? Generated from the work store: tasks opened, started or closed on this date.

    -

    Demo & "friends"

    RUNNINGSprint 1: First

    • OPENED Follow-up task demo.1.7

    Old

    No sprint activity in the work store on this day.

    Docs

    +

    Demo & "friends"

    RUNNINGSprint 1: First

    • OPENED Follow-up task demo.1.7

    READYSprint 15: Index the docs

    • PROPOSED this sprint

    Old

    No sprint activity in the work store on this day.

    Docs

    + diff --git a/internal/site/testdata/constructs/pages/index.html b/internal/site/testdata/constructs/pages/index.html index 7383e7b4..cd0314e3 100644 --- a/internal/site/testdata/constructs/pages/index.html +++ b/internal/site/testdata/constructs/pages/index.html @@ -26,10 +26,11 @@

    Decisions await you

    Actions await you

    READYReview PR #7

    Demo & "friends" · Sprint 1: First · demo.1.6

    Focus

    The parser & its tables.

    Reply below with the evidence once done; the agent checks it and closes this.

    BLOCKEDRestart the site

    Demo & "friends" · Sprint 1: First · demo.1.5

    Restart it on port 8767.

    -

    Reply below with the evidence once done; the agent checks it and closes this.

    Days

    Not in a sprint

    +

    Reply below with the evidence once done; the agent checks it and closes this.

    Days

    Not in a sprint

    ItemTypeTitleProject
    demo.20taskA project-level taskDemo & "friends"
    demo.21bugFix <b> & "co"Demo & "friends"

    Feedback

    -

    Demo & "friends"

    Sprints

    • RUNNING Sprint 1: First5 of 10 tasks done
    • DONE Sprint 2: Second1 of 1 tasks done
    • DONE Sprint 6: Old 60 of 0 tasks done
    • DONE Sprint 7: Old 70 of 0 tasks done
    • DONE Sprint 8: Old 80 of 0 tasks done
    • DONE Sprint 9: Old 90 of 0 tasks done
    • DONE Sprint 10: Old 100 of 0 tasks done
    • DONE Sprint 11: Old 110 of 0 tasks done
    • DONE Sprint 12: Old 120 of 0 tasks done
    • READY Sprint 13: Say "hi"0 of 0 tasks done
    • 3 older done sprints not shown; the project page lists every sprint

    Design pages

    • Emptycreated 2026-10-05updated 2026-10-05
    • The parsercreated 2026-10-05updated 2026-10-05

    Docs

    +

    Demo & "friends"

    Sprints

    • RUNNING Sprint 1: First5 of 10 tasks done
    • DONE Sprint 2: Second1 of 1 tasks done
    • DONE Sprint 7: Old 70 of 0 tasks done
    • DONE Sprint 8: Old 80 of 0 tasks done
    • DONE Sprint 9: Old 90 of 0 tasks done
    • DONE Sprint 10: Old 100 of 0 tasks done
    • DONE Sprint 11: Old 110 of 0 tasks done
    • DONE Sprint 12: Old 120 of 0 tasks done
    • READY Sprint 13: Say "hi"0 of 0 tasks done
    • READY Sprint 15: Index the docs0 of 1 tasks done
    • DROPPED Sprint 16: Cache the parserdropped: duplicate of sprint 14
    • 4 older done sprints not shown; the project page lists every sprint
    Proposed (1)
    +

    Design pages

    • Emptycreated 2026-10-05updated 2026-10-05
    • The parsercreated 2026-10-05updated 2026-10-05

    Docs

    Postmortems

    Closed projects (1)

    Old

    diff --git a/internal/site/testdata/constructs/pages/projects/demo.html b/internal/site/testdata/constructs/pages/projects/demo.html index e035184b..a62dd684 100644 --- a/internal/site/testdata/constructs/pages/projects/demo.html +++ b/internal/site/testdata/constructs/pages/projects/demo.html @@ -80,6 +80,10 @@

    Progress

    direction TB demo_13_empty["no tasks yet"] end + subgraph demo_15["Sprint 15: Index the docs"] + direction TB + demo_15_1["Build the index"]:::ready + end subgraph demo_20["A project-level task"] direction TB demo_20_empty["no tasks yet"] @@ -100,7 +104,8 @@

    Progress

    classDef ready fill:#f6efd9,stroke:#8a6a12,color:#1d2321 classDef blocked fill:#f7e3e1,stroke:#a8322d,color:#1d2321

    DONE DROPPED RUNNING READY BLOCKEDarrows: must finish first

    -
    SprintOpenedGoalOutcomeStatus
    First2026-10-01Ship the parser, because the site needs tables and code.not closedRUNNING
    Second: the follow-up2026-10-01Follow up on the first.Done: shipped the follow-up, see the parser.DONE
    +
    SprintOpenedGoalOutcomeStatus
    First2026-10-01Ship the parser, because the site needs tables and code.not closedRUNNING
    Second: the follow-up2026-10-01Follow up on the first.Done: shipped the follow-up, see the parser.DONE
    Cache the parser2026-10-05Cache the parser output. A repeat render reparses every record.dropped: duplicate of sprint 14DROPPED
    +
    Proposed (1)

    Not in a sprint

    ItemTypeTitleProject
    demo.20taskA project-level taskDemo & "friends"
    demo.21bugFix <b> & "co"Demo & "friends"

    Decisions

    diff --git a/internal/site/testdata/constructs/pages/sprints/demo-14.html b/internal/site/testdata/constructs/pages/sprints/demo-14.html new file mode 100644 index 00000000..671274b5 --- /dev/null +++ b/internal/site/testdata/constructs/pages/sprints/demo-14.html @@ -0,0 +1,107 @@ + + +Retry the flaky sync test + + +
    + +
    sprint
    +

    Retry the flaky sync test

    +

    PROPOSEDdemo.14opened 2026-10-05

    Goal

    +
    +

    What should be true when this sprint ends, and why now?

    +
    +

    Make the sync test pass on every run. It fails one run in ten.

    +

    Scope

    +
    +

    What's in, and what's explicitly out? Keep this high level; implementation +details go in a design page.

    +
    +

    In: a.

    +

    Out: b.

    +

    Done when

    +
    +

    What evidence will show the goal is met?

    +
    +
      +
    • It works.
    • +
    +

    Design pages

    +
    +

    Where is the detail?

    +
    +

    None yet.

    +

    Progress

    +
    +

    Where is the sprint now? Generated from Beads when the page is rendered. +Do not write here.

    +
    +

    No tasks yet.

    +

    Sessions ask you

    +

    An agent session asks; your answer steers that session and records no decision. Generated from the work store: open question needs.

    +

    No session questions waiting on the owner.

    +

    Decisions await you

    +

    The owner chooses; the agent records the answer. Generated from the work store: open decision needs.

    +

    No decisions waiting on the owner.

    +

    Actions await you

    +

    The owner does something; the agent closes it once it sees it done. Generated from the work store: open action and PR review needs.

    +

    No actions waiting on the owner.

    +

    Decisions

    +
    +

    What did we choose inside this sprint, and why?

    +
    +

    None yet.

    +

    Findings

    +
    +

    What did we learn that changes the design, the plan, or how we work? Add +results with their numbers.

    +
    +
      +
    • +

      Proposal evidence:

      +

      go test -count 20 ./internal/work -run Sync failed 2 of 20 runs.

      +
    • +
    +

    Delivery report

    +
    +

    Written at close. Each part holds "Not closed yet." until then.

    +
    +

    Outcome

    +
    +

    Done, partial or voided, plus one sentence; then, optionally, bullets of what shipped.

    +
    +

    Not closed yet.

    +

    Against "Done when"

    +
    +

    Each item, met or not, with its evidence (a page, a command, a number).

    +
    +

    Not closed yet.

    + +
    + + diff --git a/internal/site/testdata/constructs/pages/sprints/demo-16.html b/internal/site/testdata/constructs/pages/sprints/demo-16.html new file mode 100644 index 00000000..50903302 --- /dev/null +++ b/internal/site/testdata/constructs/pages/sprints/demo-16.html @@ -0,0 +1,107 @@ + + +Cache the parser + + +
    + +
    sprint
    +

    Cache the parser

    +

    DROPPEDdemo.16opened 2026-10-05

    Goal

    +
    +

    What should be true when this sprint ends, and why now?

    +
    +

    Cache the parser output. A repeat render reparses every record.

    +

    Scope

    +
    +

    What's in, and what's explicitly out? Keep this high level; implementation +details go in a design page.

    +
    +

    In: a.

    +

    Out: b.

    +

    Done when

    +
    +

    What evidence will show the goal is met?

    +
    +
      +
    • It works.
    • +
    +

    Design pages

    +
    +

    Where is the detail?

    +
    +

    None yet.

    +

    Progress

    +
    +

    Where is the sprint now? Generated from Beads when the page is rendered. +Do not write here.

    +
    +

    No tasks yet.

    +

    Sessions ask you

    +

    An agent session asks; your answer steers that session and records no decision. Generated from the work store: open question needs.

    +

    No session questions waiting on the owner.

    +

    Decisions await you

    +

    The owner chooses; the agent records the answer. Generated from the work store: open decision needs.

    +

    No decisions waiting on the owner.

    +

    Actions await you

    +

    The owner does something; the agent closes it once it sees it done. Generated from the work store: open action and PR review needs.

    +

    No actions waiting on the owner.

    +

    Decisions

    +
    +

    What did we choose inside this sprint, and why?

    +
    +

    None yet.

    +

    Findings

    +
    +

    What did we learn that changes the design, the plan, or how we work? Add +results with their numbers.

    +
    +
      +
    • +

      Proposal evidence:

      +

      a render of 40 records parses each twice.

      +
    • +
    +

    Delivery report

    +
    +

    Written at close. Each part holds "Not closed yet." until then.

    +
    +

    Outcome

    +
    +

    Done, partial or voided, plus one sentence; then, optionally, bullets of what shipped.

    +
    +

    Not closed yet.

    +

    Against "Done when"

    +
    +

    Each item, met or not, with its evidence (a page, a command, a number).

    +
    +

    Not closed yet.

    + +
    + + diff --git a/internal/site/testdata/constructs/records/sprints/demo-14.md b/internal/site/testdata/constructs/records/sprints/demo-14.md new file mode 100644 index 00000000..6f2d6e9b --- /dev/null +++ b/internal/site/testdata/constructs/records/sprints/demo-14.md @@ -0,0 +1,68 @@ +--- +type: sprint +title: "Retry the flaky sync test" +bead: demo.14 +--- + +## Goal + +> What should be true when this sprint ends, and why now? + +Make the sync test pass on every run. It fails one run in ten. + +## Scope + +> What's in, and what's explicitly out? Keep this high level; implementation +> details go in a design page. + +**In:** a. + +**Out:** b. + +## Done when + +> What evidence will show the goal is met? + +- It works. + +## Design pages + +> Where is the detail? + +None yet. + +## Progress + +> Where is the sprint now? Generated from Beads when the page is rendered. +> Do not write here. + +## Decisions + +> What did we choose inside this sprint, and why? + +None yet. + +## Findings + +> What did we learn that changes the design, the plan, or how we work? Add +> results with their numbers. + +- Proposal evidence: + + `go test -count 20 ./internal/work -run Sync` failed 2 of 20 runs. + +## Delivery report + +> Written at close. Each part holds "Not closed yet." until then. + +### Outcome + +> Done, partial or voided, plus one sentence; then, optionally, bullets of what shipped. + +Not closed yet. + +### Against "Done when" + +> Each item, met or not, with its evidence (a page, a command, a number). + +Not closed yet. diff --git a/internal/site/testdata/constructs/records/sprints/demo-16.md b/internal/site/testdata/constructs/records/sprints/demo-16.md new file mode 100644 index 00000000..defef1aa --- /dev/null +++ b/internal/site/testdata/constructs/records/sprints/demo-16.md @@ -0,0 +1,68 @@ +--- +type: sprint +title: "Cache the parser" +bead: demo.16 +--- + +## Goal + +> What should be true when this sprint ends, and why now? + +Cache the parser output. A repeat render reparses every record. + +## Scope + +> What's in, and what's explicitly out? Keep this high level; implementation +> details go in a design page. + +**In:** a. + +**Out:** b. + +## Done when + +> What evidence will show the goal is met? + +- It works. + +## Design pages + +> Where is the detail? + +None yet. + +## Progress + +> Where is the sprint now? Generated from Beads when the page is rendered. +> Do not write here. + +## Decisions + +> What did we choose inside this sprint, and why? + +None yet. + +## Findings + +> What did we learn that changes the design, the plan, or how we work? Add +> results with their numbers. + +- Proposal evidence: + + a render of 40 records parses each twice. + +## Delivery report + +> Written at close. Each part holds "Not closed yet." until then. + +### Outcome + +> Done, partial or voided, plus one sentence; then, optionally, bullets of what shipped. + +Not closed yet. + +### Against "Done when" + +> Each item, met or not, with its evidence (a page, a command, a number). + +Not closed yet. diff --git a/internal/work/dolt.go b/internal/work/dolt.go index 510a1f50..ff07f700 100644 --- a/internal/work/dolt.go +++ b/internal/work/dolt.go @@ -1238,6 +1238,23 @@ func (d *Dolt) Move(id, parent string) error { }) } +// Promote makes an open proposal an ordinary sprint (Store.Promote); the label row's delete merges per entry. +func (d *Dolt) Promote(id, session string) error { + return d.update("promote", id, func(it *Item, _ *Index) error { + if it.Type != Sprint || it.Status != Open || !slices.Contains(it.Labels, ProposalLabel) { + return itemError(id, "is no open proposal; only an open sprint labelled %s is promoted", ProposalLabel) + } + cid, err := newUUID() + if err != nil { + return err + } + it.Labels = slices.DeleteFunc(it.Labels, func(l string) bool { return l == ProposalLabel }) + it.Comments = append(it.Comments, Comment{ID: cid, Kind: Note, Author: session, Text: PromoteNote, + CreatedAt: d.now()}) + return nil + }) +} + // Claim makes h the holder, as a compare-and-set in one transaction: it succeeds when the item has no holder, is held // by h.Session, or is held by a session that live reports not live; it sets StartedAt on the first claim. The claim // time is the store's clock. diff --git a/internal/work/item.go b/internal/work/item.go index a962b3c9..de45168e 100644 --- a/internal/work/item.go +++ b/internal/work/item.go @@ -67,6 +67,9 @@ const ( Note = model.Note MoveAuthor = model.MoveAuthor + + ProposalLabel = model.ProposalLabel + PromoteNote = model.PromoteNote ) // HasReply is item.HasReply. diff --git a/internal/work/store.go b/internal/work/store.go index dbb75891..0809fcee 100644 --- a/internal/work/store.go +++ b/internal/work/store.go @@ -30,6 +30,9 @@ type Store interface { Reopen(id string) error // Move gives a task or a need a new parent; its id stays. Move(id, parent string) error + // Promote makes an open proposal an ordinary sprint: it deletes the label proposal and leaves the note + // pm sprint promote by session, dated, in one write. + Promote(id, session string) error // MoveSprint moves an open sprint to an open project and returns it as it is after: its id stays, its number is // the project's next, and a move note records where it was (SprintMoves). A sprint under the project already is // returned as it is, with nothing written. diff --git a/internal/work/worktest/worktest.go b/internal/work/worktest/worktest.go index 06ce43b1..5b9e6c32 100644 --- a/internal/work/worktest/worktest.go +++ b/internal/work/worktest/worktest.go @@ -78,6 +78,7 @@ func (s *Store) SetResolution(string, work.Resolution) error { return er func (s *Store) SetCloseReason(string, string) error { return errReadOnly } func (s *Store) Reopen(string) error { return errReadOnly } func (s *Store) Move(string, string) error { return errReadOnly } +func (s *Store) Promote(string, string) error { return errReadOnly } func (s *Store) MoveSprint(string, string, string) (work.Item, error) { return work.Item{}, errReadOnly } diff --git a/prime.md b/prime.md index a0827d04..6ce6e0cc 100644 --- a/prime.md +++ b/prime.md @@ -103,6 +103,7 @@ Use raw HTML only for what Markdown cannot show, such as a mock-up. - Drill down one level at a time, only as far as your work needs: `pm show --project NAME`, then `pm show --sprint ID`, then `pm show --record ` for the record's outline, then the same with `--section ` for one section. Do not read the whole file for one part. - `pm show `: one item, its holder and its needs. `pm where`: every location and its state; `pm where records` prints the store's path. - open a sprint: `pm sprint open --title "…"`; body: the frame as `## Goal`, `## Scope` and `## Done when`. +- propose a sprint: for something the repo needs outside your task, once you can name the fix and its check, `pm sprint propose --title "…"`; body: the frame plus `## Evidence`, what you saw. If its output lists a sprint that covers yours, drop yours: `pm sprint close --dropped --reason "duplicate of …"`. Promote one with `pm sprint promote ` only when the owner asks; tasks go under it after that. - add tasks: `pm task add --sprint ID --title "…"`; body (optional): the description. A sub-task: `pm task add --task --title "…"`. A dependency: `pm dep add --on `. - claim a task: `pm task claim `, from your own worktree. Your first claim in a sprint prints its design pages: read the ones the task touches before changing their code. Do not take or brief work that another live session holds. - close a task: `pm task close --reason ""`. It names the commit that holds the work: HEAD when newer than the task, else `--commit REF` (a commit here, `OWNER/REPO@SHA` or a PR URL). A task not done: `pm task close --dropped --reason ""`, with no commit. @@ -120,7 +121,7 @@ Use raw HTML only for what Markdown cannot show, such as a mock-up. - close a need that sets no rule: `pm need close --reason "…"`; a decision need takes the owner's answer as its body; a need that became moot, or a replaced PR's review, takes `--dropped`. - close a sprint: `pm sprint close `, once its report is written, its tasks are closed and its PR review is closed as merged. For a PR merged with no review: `pm sprint close --merged --pr URL`. It then publishes the sprint's design page drafts, from every worktree, in one records commit. A draft that overlaps another commit is named: run any pm write in that worktree, resolve the conflict markers, `pm commit --sprint ` the page, and close again; `--discard --reason "…"` drops the draft instead. - find a link: Give the owner a record's URL from `pm record link `; never a `records/…` path or a URL you built. -- Report where pm got in your way: `pm feedback add [--project NAME]`, once, with what happened and what would have helped. +- Report where pm itself got in your way: `pm feedback add [--project NAME]`, once, with what happened and what would have helped; what the repo needs is a sprint proposal. - setup pm: `pm init` installs pm in the repo, clone and worktree, doing only what is missing: the repo's files and hooks, the records store and workspace, and the pm service. `pm init --site-url URL` sets the public site link. Less frequent, each detailed in its `--help`: diff --git a/size_test.go b/size_test.go index dc750511..abc4b4b0 100644 --- a/size_test.go +++ b/size_test.go @@ -15,7 +15,7 @@ const maxPackageLines = 5000 // oversized holds the packages already past the cap at the size each may reach, a ratchet: lower an entry as its // package shrinks, and drop it once the package fits the cap. var oversized = map[string]int{ - "internal/cli": 7301, + "internal/cli": 7168, } // TestPackagesStayUnderTheSizeCap counts each package directory's lines in its non-test .go files. diff --git a/style.css b/style.css index 652c98a1..5f744de8 100644 --- a/style.css +++ b/style.css @@ -81,6 +81,7 @@ td.n{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap} .run{color:var(--run);background:var(--run-bg)} .done{color:var(--done);background:var(--done-bg)} .queued{color:var(--queue);background:var(--queue-bg)} .blocked{color:var(--block);background:var(--block-bg)} .dropped{color:var(--muted);background:var(--line)} +.proposed{color:var(--muted);border:1px dashed var(--muted)} .card{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:10px 14px;margin:10px 0} .card h4{margin:0 0 4px;font-size:.95rem;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap} .card .k{color:var(--muted);font-size:.8rem} @@ -118,6 +119,8 @@ details.same{margin:.4rem 0} details.same summary{cursor:pointer;color:var(--muted);font-size:.9rem} details.closed-projects{margin-top:2.6rem} details.closed-projects>summary{cursor:pointer;font:600 1.35rem/1.25 var(--display);color:var(--muted)} +details.proposals{margin:.4rem 0} +details.proposals>summary{cursor:pointer;color:var(--muted);font-weight:600} /* classes used by hand-built mock blocks inside design pages */ .split{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start} diff --git a/tests/test_proposals.py b/tests/test_proposals.py new file mode 100644 index 00000000..bcfd1620 --- /dev/null +++ b/tests/test_proposals.py @@ -0,0 +1,200 @@ +"""Sprint proposals end to end against a temp repo, as the sprint-proposals design page gives them: pm sprint propose +writes the labelled sprint, its record and the evidence finding, refuses a frame or evidence it lacks, and lists the +project's other proposals and open sprints; pm show (text and --json) and pm task ready leave a proposal out while +pm show --sprint shows it; it takes no task until pm sprint promote makes it ordinary; pm sprint close --dropped drops it with its reason and pm sprint +reopen makes it a proposal again; pm project close names the drop. test_every_item_id_command_on_a_proposal runs each +case of internal/cli/testdata/proposal_commands.json, which TestEveryItemIDArgumentIsClassedForAProposal holds to the +command tree, against a fresh proposal; the cases that need the site served (pm record link) run in +test_the_served_commands_on_a_proposal, an integration test.""" + +from __future__ import annotations + +import json +import re +import subprocess + +import pytest + +from conftest import CHECKOUT, PM, write_config + +FRAME = ("## Goal\n\nMake the sync test pass on every run. It fails one run in ten.\n\n## Scope\n\n**In:** the test.\n\n" + "**Out:** the sync itself.\n\n## Done when\n\n- `go test -count 20` passes 20 of 20.\n\n") +EVIDENCE = "## Evidence\n\n```\nFAIL sync\n```\n\n`go test -count 20 ./internal/work -run Sync` failed 2 of 20.\n" +CASES = json.loads((CHECKOUT / "internal/cli/testdata/proposal_commands.json").read_text()) + + +def propose(repo, title: str = "Fix the sync flake", body: str = FRAME + EVIDENCE): + return repo.pm("sprint", "propose", "demo", "--title", title, "--text-file", "-", stdin=body) + + +def proposed(repo, title: str = "Fix the sync flake") -> str: + """A new proposal's id.""" + res = propose(repo, title) + assert res.returncode == 0, res.stderr + return re.search(r"epic (\S+),", res.stdout).group(1) + + +def shown_sprints(repo) -> set[str]: + data = json.loads(repo.pm("show", "--json").stdout) + return {s["id"] for p in data["projects"] for s in p["sprints"]} + + +def test_propose_writes_the_labelled_sprint_its_record_and_the_evidence_finding(repo): + proposed(repo, "Retry the setup") # an earlier proposal, for the duplicate list + res = propose(repo) + assert res.returncode == 0, res.stderr + out = res.stdout + assert out.startswith("proposed sprint 4 of demo: epic repo-demo.4, record records/sprints/demo-4.md\n") + assert ("the project's other proposals and open sprints; if one covers yours, drop yours: pm sprint close " + 'repo-demo.4 --dropped --reason "duplicate of "') in out + assert re.search(r"(?m)^ proposed repo-demo\.3 Sprint 3: Retry the setup Make the sync test pass on every " + r"run\.$", out) + assert re.search(r"(?m)^ \S+ +repo-demo\.1 Sprint 1: First", out) + item = repo.items()["repo-demo.4"] + assert (item["type"], item["status"], item["labels"]) == ("sprint", "open", ["proposal"]) + record = (repo.records / "sprints/demo-4.md").read_text() + assert "Make the sync test pass on every run." in record + assert ("- Proposal evidence:\n\n ```\n FAIL sync\n ```\n\n `go test -count 20 ./internal/work -run Sync` failed 2 " + "of 20.\n") in record + assert repo.pm("check").returncode == 0 + + +def test_propose_refuses_a_missing_frame_or_evidence_and_writes_nothing(repo): + repo.mark() + for body, says in ((FRAME, "'## Evidence' is missing or empty"), + (FRAME + "## Evidence\n\n", "'## Evidence' is missing or empty"), + (FRAME.replace("## Done when", "## Notes"), "unknown section '## Notes'"), + (EVIDENCE, "'## Goal' is missing or empty")): + res = propose(repo, body=body) + assert res.returncode != 0 and says in res.stderr, (body, res.stderr) + assert repo.unchanged() + assert not (repo.records / "sprints/demo-3.md").exists() + + +def test_pm_show_leaves_a_proposal_out_and_it_takes_tasks_once_promoted(repo): + repo.env = dict(repo.env, CLAUDE_CODE_SESSION_ID="sess-1") + pid = proposed(repo) + before = repo.pm("show").stdout + assert "2 open sprints" in before and pid not in before + assert pid not in repo.pm("show", "--project", "demo").stdout + assert pid not in shown_sprints(repo) and "proposal" not in repo.pm("show", "--json").stdout + assert repo.pm("task", "ready").stdout.strip() == "no ready tasks" + detail = repo.pm("show", "--sprint", pid).stdout + assert detail.startswith(f"Sprint 3: Fix the sync flake {pid} proposed ") and "Proposal evidence:" in detail + res = repo.pm("task", "add", "--sprint", pid, "--title", "[TEST] retry the setup") + assert res.returncode != 0 and f"sprint {pid} is a proposal; its tasks wait until the owner asks to promote it: " \ + f"pm sprint promote {pid}" in res.stderr + res = repo.pm("sprint", "promote", pid) + assert res.returncode == 0 and res.stdout.startswith(f"promoted {pid}"), res.stderr + item = repo.items()[pid] + assert item["labels"] == [] and [(c["kind"], c["author"], c["text"]) for c in item["comments"]] == [ + ("note", "sess-1", "pm sprint promote")] + assert pid in shown_sprints(repo) and "3 open sprints" in repo.pm("show").stdout + assert repo.pm("task", "add", "--sprint", pid, "--title", "[TEST] retry the setup").returncode == 0 + res = repo.pm("sprint", "promote", pid) + assert res.returncode != 0 and f"sprint {pid} is not an open proposal" in res.stderr + + +def test_a_dropped_proposal_keeps_its_reason_and_a_reopen_makes_it_a_proposal_again(repo): + repo.env = dict(repo.env, CLAUDE_CODE_SESSION_ID="sess-1") + pid = proposed(repo) + res = repo.pm("sprint", "close", pid) + assert res.returncode != 0 and f"sprint {pid} is a proposal; drop it with" in res.stderr + assert repo.pm("sprint", "close", "repo-demo.1", "--dropped", "--reason", "x").returncode != 0 + res = repo.pm("sprint", "close", pid, "--dropped", "--reason", "duplicate of repo-demo.1") + assert res.returncode == 0, res.stderr + item = repo.items()[pid] + assert (item["status"], item["resolution"], item["close_reason"]) == ("closed", "dismissed", + "duplicate of repo-demo.1") + assert repo.pm("check").returncode == 0 # its record keeps "Not closed yet.": a drop writes no report + res = repo.pm("sprint", "reopen", pid, "--text-file", "-", stdin="Reopen it.\nIt was no duplicate.\n") + assert res.returncode == 0, res.stderr + assert repo.items()[pid]["status"] == "open" and pid not in shown_sprints(repo) + + +def test_a_promoted_proposal_closes_as_a_sprint_and_refuses_dropped(repo): + pid = proposed(repo) + assert repo.pm("sprint", "promote", pid).returncode == 0 + res = repo.pm("sprint", "close", pid, "--dropped", "--reason", "x") + assert res.returncode != 0 and f"sprint {pid} is not a proposal" in res.stderr + + +def test_project_close_names_the_drop_of_an_open_proposal(repo): + for iid in ("repo-demo.1", "repo-demo.1.2", "repo-demo.2"): + repo.set_issue(iid, status="closed", closed_at="2026-10-02T12:00:00Z") + pid = proposed(repo) + project = (repo.records / "projects/demo.md").read_text() + repo.write("projects/demo.md", project.replace("Not closed yet.", "Done: shipped.")) + repo.commit("outcome") + res = repo.pm("project", "close", "demo") + assert res.returncode != 0 + assert f'{pid} (Sprint 3: Fix the sync flake; a proposal: pm sprint close {pid} --dropped --reason "…")' in res.stderr + + +def fill(text: str, values: dict[str, str]) -> str: + """text with each placeholder of values replaced, in values' order.""" + for k, v in values.items(): + text = text.replace(k, v) + return text + + +def run_cases(repo, cases: list[dict]) -> None: + """Run each case with a fresh proposal as {P}: allowed leaves it a proposal, promotes makes it a sprint without + the label that pm show lists, drops closes it as dismissed, refused fails and changes nothing of it; a case's says is in its output.""" + repo.env = dict(repo.env, CLAUDE_CODE_SESSION_ID="sess-1") + write_config(repo.root, site_url="https://pm.example.test") # what pm record link prints + (repo.records / "design").mkdir(exist_ok=True) + repo.write("design/parser.md", "---\ntype: design\ntitle: The parser\nproject: demo\n---\n\n" + "".join( + f"## {name}\n\nNone yet.\n\n" for name in ("Problem", "Goals and non-goals", "Constraints and key facts", + "Design", "Alternatives considered", "Open questions"))) + repo.commit("the parser page and the site") + res = repo.pm("project", "open", "other", "--title", "Other", "--text=Another standing goal.") + assert res.returncode == 0, res.stderr + failures = [] + for n, case in enumerate(cases): + pid = proposed(repo, f"Proposal {n}") + record = f"records/sprints/demo-{repo.items()[pid]['number']}.md" + task = "" + if any("{T}" in a for a in case["argv"]): + out = repo.pm("task", "add", "--sprint", "repo-demo.2", "--title", f"[TEST] task {n}").stdout + task = re.search(r"repo-demo\.2\.\d+", out).group(0) + values = {"{P_RECORD}": record, "{P}": pid, "{S}": "repo-demo.1", "{T}": task} # {P_RECORD} before {P} + + for argv in case.get("setup", []): + res = repo.pm(*(fill(a, values) for a in argv)) + assert res.returncode == 0, (case, res.stderr) + if case.get("edit"): + path = repo.root / record + path.write_text(path.read_text().replace("It fails one run in ten.", "It fails in setup.")) + before = repo.items()[pid] + res = repo.pm(*(fill(a, values) for a in case["argv"]), stdin=case.get("stdin")) + after = repo.items() + it, kids = after[pid], [i for i in after.values() if i.get("parent") == pid and i["type"] == "task"] + ok = {"allowed": res.returncode == 0 and it["status"] == "open" and not kids, + "promotes": res.returncode == 0 and "proposal" not in it["labels"] and pid in shown_sprints(repo), + "drops": res.returncode == 0 and (it["status"], it["resolution"]) == ("closed", "dismissed"), + "refused": res.returncode != 0 and it == before}[case["outcome"]] + says = fill(case.get("says", ""), values) + if not ok or says not in res.stdout + res.stderr: + failures.append(f"pm {' '.join(fill(a, values) for a in case['argv'])}: want {case['outcome']}" + f"{', saying ' + repr(says) if says else ''}; exit {res.returncode}, " + f"stdout {res.stdout[-300:]!r}, stderr {res.stderr[-300:]!r}") + assert not failures, "\n".join(failures) + + +def test_every_item_id_command_on_a_proposal(repo): + run_cases(repo, [c for c in CASES if not c.get("service")]) + + +@pytest.mark.integration +def test_the_served_commands_on_a_proposal(repo): + repo.stop_service() # a service that serves the site holds the work store instead + srv = subprocess.Popen([*PM, "service", "run"], cwd=repo.root, env=dict(repo.env, PORT="0"), + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + try: + repo.env = dict(repo.env, PORT=re.search(r"http://localhost:(\d+)", srv.stdout.readline()).group(1)) + run_cases(repo, [c for c in CASES if c.get("service")]) + finally: + srv.terminate() + srv.wait() + repo.start_service()