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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions changelog.d/sprint-proposals.md
Original file line number Diff line number Diff line change
@@ -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 <project> --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 <id>`, run when the owner asks, makes it an ordinary sprint.
- `pm sprint close <id> --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`.
32 changes: 0 additions & 32 deletions internal/cli/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import (
"fmt"
"github.com/Yeeef/pm/internal/cli/fsutil"
"io"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions internal/cli/agentcmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 27 additions & 6 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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 <id> --dropped --reason \"duplicate of <id>\". 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 <id> --reason \"merged as <sha>\" 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 <sha>.' ('Merged as <sha> (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 <id> --reason \"merged as <sha>\" 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 <sha>.' ('Merged as <sha> (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"},
},
},
{
Expand Down Expand Up @@ -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"},
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading