Examples

Real-world workflow walkthroughs

Step-by-step guides for common team setups โ€” copy the workflow scripts and adapt them to your project.

๐Ÿค–

Agent Ci Workflow

Autonomous agent workflow with CI integration

An AI coding agent that orients at startup via pm context, searches before creating, claims tasks, links evidence, and auto-updates status through CI pipelines.

Commands used
pm init pm context --jsonpm searchpm start-taskpm test --runpm close-task

Overview

This example shows how an AI coding agent integrates with pm-cli to manage its own work session: orienting itself at startup, searching before creating, claiming tasks, linking evidence, and closing with validation results. It also covers a CI/CD pipeline that uses pm-cli to update task status automatically as builds and tests pass.

The key principles for agent use are:

  1. Always orient first โ€” run pm context --json at the top of every session
  2. Search before creating โ€” run pm search to avoid duplicating existing items
  3. Claim before working โ€” pm start-task establishes ownership and prevents double-work
  4. Link evidence โ€” attach the files touched and test results via pm files
  5. Close with proof โ€” the closing reason should include measurable evidence

When to use this workflow

  • An AI agent (Claude, Codex, or similar) is writing code and needs to coordinate with a human-maintained task list
  • A CI/CD pipeline needs to automatically progress task status based on build results
  • You want a machine-readable audit trail of what the agent touched and why
  • Multiple agents may run concurrently and need to avoid claiming the same work

Key commands used

Command Purpose
pm context --json Machine-readable project state at session start
pm context --json --limit 10 Capped context snapshot (top 10 items per section)
pm search "query" --json Find existing items before creating new ones
pm create --json Create an item; parse the returned ID for use downstream
pm start-task <id> --json Lifecycle alias: claim ownership and move to in_progress
pm pause-task <id> --json Lifecycle alias: release claim and move back to open
pm close-task <id> "reason" --json Lifecycle alias: close with reason and release assignment
pm update <id> --json Update fields programmatically
pm comments <id> "text" Record agent reasoning and intermediate results
pm files <id> --add Attach files the agent read or modified
pm test <id> --add Link a test command to the task
pm test <id> --run Run linked test commands and record results
pm docs <id> --add Attach spec or doc references
pm activity --id <id> --limit 20 Review recent history on a specific item
pm test-all --background --progress Run linked tests in background with live progress
pm validate --json Check structural integrity after changes
pm health --json Machine-readable health check

Environment variable

Set PM_AUTHOR to the agent's identity so every action is attributed correctly in the audit trail. In a multi-agent setup each agent has a distinct PM_AUTHOR.

export PM_AUTHOR="agent/claude-sonnet"   # or "ci/github-actions", "agent/codex", etc.

Progressive context controls

pm context supports a --limit flag that controls how many items appear per section in the snapshot. Use this to trade breadth for token efficiency as the session narrows.

--limit: control row depth per section

# Full view โ€” good for morning orientation or when switching domains
pm context --json

# Capped view โ€” top 10 items per section, keeps output concise mid-session
pm context --json --limit 10

# Minimal snapshot โ€” top 5 items, useful when you already know your task
pm context --json --limit 5

All three variants return the same JSON shape. --limit only controls how many rows appear in each section (in_progress, open_high_priority, agenda, etc.).

Combining --limit with filters

Use filters alongside --limit to focus the snapshot further:

# Only show items assigned to this agent, top 5 per section
pm context --json --limit 5 --assignee "$PM_AUTHOR"

# Only show high-priority items
pm context --json --limit 10 --priority 1

Recommended pattern for agent sessions

# Step 1: broad orientation at session start
CONTEXT=$(pm context --json --limit 10)
echo "$CONTEXT" | jq '.in_progress, .open_high_priority'

# Step 2: if resuming a prior item, check its recent activity
RESUME_ID=$(echo "$CONTEXT" | jq -r \
  --arg author "$PM_AUTHOR" \
  '.in_progress[] | select(.owner == $author) | .id' | head -1)

if [ -n "$RESUME_ID" ]; then
  pm activity --id "$RESUME_ID" --limit 20
fi

# Step 3: mid-session quick check (already know your item)
pm context --json --limit 5

Step-by-step narrative

1. Agent session startup โ€” orient with context

The very first thing an agent does is call pm context --json and parse the result. This tells it: what is in progress (potentially its previous session), what is open and highest priority, and whether anything is blocked.

export PM_AUTHOR="agent/claude-sonnet"
CONTEXT=$(pm context --json --limit 10)
echo "$CONTEXT" | jq '.in_progress, .open_high_priority'

If there is an in_progress item owned by this agent from a prior session, the agent should resume it rather than starting something new.

2. Search before creating

Before creating a new task, the agent searches to avoid duplicating existing work.

pm search "add retry logic to HTTP client" --json | jq '.items'

If a matching item already exists, the agent claims and works it. Only if no match exists does the agent create.

3. Create items with JSON output for ID capture

When the agent does create an item, it uses --json to capture the assigned ID without parsing human-readable TOON output.

TASK_ID=$(pm create \
  --title "Add exponential backoff to HTTP client" \
  --description "Retry failed requests up to 3 times with jitter. Affects src/http/client.ts" \
  --type Task \
  --priority 2 \
  --json | jq -r '.id')

echo "Created task: $TASK_ID"

4. Claim the task

Use the pm start-task lifecycle alias, which combines claim + status transition in one step:

pm start-task "$TASK_ID" --json | jq '{id, status, owner}'

The agent verifies that status == "in_progress" and owner == PM_AUTHOR before proceeding. If the claim fails (another agent beat it), the agent picks the next item.

To release a task without closing it (e.g. agent is interrupted), use pm pause-task:

pm pause-task "$TASK_ID" --json   # moves back to open, releases owner

5. Record reasoning and intermediate state

As the agent works, it leaves comments explaining its reasoning. This is the machine equivalent of a commit message โ€” it answers "why did the agent make this choice?"

pm comments "$TASK_ID" \
  "AGENT REASONING: Analyzed HTTP client. Current retry logic is a simple fixed delay (500ms). Changing to exponential backoff: delay = min(base * 2^attempt + jitter, max_delay). Base=100ms, max=10s, jitter=random(0,100ms). This matches AWS SDK retry behavior."

pm comments "$TASK_ID" \
  "FILES MODIFIED: src/http/client.ts (retry logic), src/http/backoff.ts (new helper), tests/http/client.test.ts (6 new test cases)"

6. Attach files touched

pm files "$TASK_ID" --add path=src/http/client.ts,scope=project
pm files "$TASK_ID" --add path=src/http/backoff.ts,scope=project
pm files "$TASK_ID" --add path=tests/http/client.test.ts,scope=project

7. Link and run tests

Use pm test --add to link a test command to the task, then pm test --run to execute it. This creates a permanent record that the task has verified tests.

# Link a test command to the task
pm test "$TASK_ID" --add command="npx vitest run tests/http/client.test.ts",scope=project,timeout_seconds=60

# Run the linked tests and show progress
pm test "$TASK_ID" --run --progress

8. Close with evidence

Use the pm close-task lifecycle alias, which records the reason and releases assignment metadata in one step. Include measurable evidence so human reviewers can validate the agent's work without re-running tests.

pm close-task "$TASK_ID" \
  "Implemented exponential backoff with jitter in src/http/backoff.ts. 6 new unit tests added (all pass). Manual test: 3 retries on 503 response, delays were 147ms, 312ms, 891ms (matches expected distribution). PR #77 opened."

9. Run validation after session

pm validate --json | jq '.errors'
pm health --json | jq '.warnings'

The agent checks that it has not left any structural problems (orphaned items, invalid status transitions) before ending its session.


CI/CD Integration

GitHub Actions: update task status on build events

The workflow below is triggered on pull request events. It uses PM_AUTHOR=ci/github-actions to attribute CI updates separately from human and agent actions.

See github-actions-example.yml in this directory for the full YAML.

The CI pipeline:

  1. On PR open โ€” adds a comment to the linked task with the PR URL
  2. On CI pass โ€” progresses the task to reflect passing tests
  3. On merge โ€” closes the task with build evidence

Linking a PR to a task

Include the task ID in the PR title or body (e.g. [APP-42]), then the CI script can extract it and update the task.

# In CI: extract task ID from PR title
TASK_ID=$(echo "$PR_TITLE" | grep -oE '[A-Z]+-[0-9]+' | head -1)

if [ -n "$TASK_ID" ]; then
  PM_AUTHOR=ci/github-actions pm comments "$TASK_ID" \
    "CI: PR #${PR_NUMBER} opened. Build: ${BUILD_URL}"
fi
Show complete workflow script
agent-ci-workflow/workflow.sh
#!/usr/bin/env tsx
import { execFileSync } from "node:child_process";

function pm(args: string[]): string {
  const stdout = execFileSync("pm", args, { encoding: "utf8" });
  process.stdout.write(stdout);
  return stdout;
}

const query = process.env.PM_AGENT_QUERY ?? "failing CI";
const taskId = process.env.PM_TASK_ID;

pm(["context", "--json", "--depth", "full"]);
pm(["search", "--mode", "hybrid", query, "--json"]);

if (taskId) {
  pm(["start-task", taskId, "--json"]);
  pm(["test", taskId, "--add", "--command", process.env.PM_TEST_COMMAND ?? "npm test", "--description", "CI verification command"]);
  pm(["test", taskId, "--run", "--json"]);
  pm(["docs", taskId, "--add", "path=.github/workflows/ci.yml,scope=project,note=CI workflow under investigation"]);
  pm(["comments", taskId, "Agent CI pass completed. Linked evidence and test command are attached."]);
} else {
  pm([
    "create",
    "--type",
    "Task",
    "--title",
    "Investigate CI failure",
    "--description",
    "Created by agent CI workflow after context and hybrid search.",
    "--priority",
    "1",
    "--tags",
    "agent,ci",
  ]);
}
View source on GitHub agent-ci-workflow
๐Ÿง 

Ai Sprint Planner

Score backlog items and auto-assign a sprint with TypeScript

TypeScript script that fetches open backlog items, scores them by priority, age, estimate, and parent continuity, then assigns them to team members by capacity and creates a Sprint Milestone โ€” all in one command.

Commands used
pm init pm list-openpm list-in-progresspm create --type Milestonepm update --sprintTypeScript

Use an AI agent with the pm-cli SDK to score open backlog items and generate a sprint plan automatically.

What this does

  • Fetches all open items from pm and scores them by priority, estimate, and age
  • Groups items by Epic/Feature parent to avoid splitting related work across sprints
  • Assigns items to a sprint based on capacity and score
  • Creates a Sprint Milestone and updates each assigned item with sprint metadata
  • Prints a formatted sprint plan showing capacity usage, team assignments, and goals

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+
  • An Anthropic API key (set ANTHROPIC_API_KEY)

Setup

cd ai-sprint-planner
npm install

# Make sure pm is initialized in your project
pm init

# Run the planner with your sprint settings
npx tsx main.ts \
  --sprint "Sprint 12" \
  --capacity 80 \
  --team alice,bob,carol

How scoring works

Each open item receives a score from 0โ€“100:

Factor Weight Notes
Priority (0=max) 40 (4 - priority) * 10
Age (days open) 25 Capped at 90 days
Has estimate 20 Items without estimates score lower
Parent is in-progress Epic 15 Prefer continuing work over starting new

Items with blocked status are excluded. Items with no estimate are included but flagged.

Output

Sprint 12  โ€”  capacity: 80h  โ€”  team: alice, bob, carol

  Score  ID       Title                                  Est   Assignee
  โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   95    APP-14   Implement OAuth callback handler         4h   alice
   88    APP-17   Add refresh-token rotation               3h   alice
   82    APP-22   User profile settings page               6h   bob
   ...

Total: 67h / 80h  (13h remaining)
Unscheduled: 8 items

Sprint milestone APP-31 created.
Items updated with sprint=Sprint 12.
Show TypeScript source
ai-sprint-planner/main.ts TypeScript
/**
 * ai-sprint-planner โ€” AI-powered sprint planning with pm-cli
 *
 * Scores open backlog items, groups related work, assigns to team members
 * by capacity, creates a Sprint Milestone, and updates items with sprint
 * metadata โ€” all from a single command.
 *
 * Usage:
 *   npx tsx main.ts --sprint "Sprint 12" --capacity 80 --team alice,bob,carol
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    sprint: { type: "string" },
    capacity: { type: "string", default: "80" },
    team: { type: "string", default: "" },
    "dry-run": { type: "boolean", default: false },
  },
});

const SPRINT_NAME = args.sprint ?? `Sprint ${new Date().toISOString().slice(0, 7)}`;
const CAPACITY_HOURS = parseInt(args.capacity ?? "80", 10);
const TEAM = (args.team ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = args["dry-run"] ?? false;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A pm item as returned by `pm list --json`. */
interface PmItem {
  id: string;
  title: string;
  status: "open" | "in_progress" | "blocked" | "draft" | "closed" | "canceled";
  type: string;
  priority?: number;
  estimated_minutes?: number;
  parent?: string;
  assignee?: string;
  created_at: string;
  tags?: string[];
}

/** A scored backlog item ready for sprint assignment. */
interface ScoredItem extends PmItem {
  score: number;
  estimatedHours: number;
  ageDays: number;
}

/** Sprint assignment: item + chosen team member. */
interface Assignment {
  item: ScoredItem;
  assignee: string;
}

// ---------------------------------------------------------------------------
// pm-cli helpers
// ---------------------------------------------------------------------------

/** Run a pm-cli command and return stdout as a string. */
function pm(cmd: string): string {
  return execSync(`pm ${cmd}`, { encoding: "utf-8" }).trim();
}

/** Run a pm-cli command returning parsed JSON. */
function pmJson<T>(cmd: string): T {
  return JSON.parse(pm(`${cmd} --json`));
}

/** Fetch open (and in-progress) items, excluding blocked and archived types. */
function fetchBacklog(): PmItem[] {
  const all = pmJson<PmItem[]>("list-open");
  // Include in_progress items that aren't yet in a sprint
  const active = pmJson<PmItem[]>("list-in-progress");
  return [...all, ...active].filter(
    (item) =>
      !["Milestone", "Epic", "Meeting", "Event", "Reminder"].includes(item.type)
  );
}

// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------

const NOW_MS = Date.now();

/**
 * Score a backlog item 0โ€“100.
 *
 * Priority factor  (40 pts) โ€” priority 0 scores 40, priority 4 scores 0
 * Age factor       (25 pts) โ€” older items bubble up; capped at 90 days
 * Estimate factor  (20 pts) โ€” items with estimates score higher
 * Parent factor    (15 pts) โ€” prefer continuing in-progress parent work
 */
function scoreItem(item: PmItem, inProgressParents: Set<string>): number {
  // Priority: 0 = highest โ†’ 40 pts; 4 = lowest โ†’ 0 pts
  const priorityScore = ((4 - (item.priority ?? 2)) / 4) * 40;

  // Age: days since creation, capped at 90
  const ageMs = NOW_MS - new Date(item.created_at).getTime();
  const ageDays = Math.min(ageMs / (1000 * 60 * 60 * 24), 90);
  const ageScore = (ageDays / 90) * 25;

  // Estimate: reward items that have been sized
  const estimateScore = item.estimated_minutes != null ? 20 : 0;

  // Parent continuity: reward items whose parent Epic/Feature is already active
  const parentScore = item.parent && inProgressParents.has(item.parent) ? 15 : 0;

  return Math.round(priorityScore + ageScore + estimateScore + parentScore);
}

function buildScoredItems(items: PmItem[], inProgressParents: Set<string>): ScoredItem[] {
  return items
    .filter((item) => item.status !== "blocked")
    .map((item) => ({
      ...item,
      score: scoreItem(item, inProgressParents),
      estimatedHours: (item.estimated_minutes ?? 0) / 60,
      ageDays: Math.floor(
        (NOW_MS - new Date(item.created_at).getTime()) / (1000 * 60 * 60 * 24)
      ),
    }))
    .sort((a, b) => b.score - a.score);
}

// ---------------------------------------------------------------------------
// Capacity assignment
// ---------------------------------------------------------------------------

/**
 * Greedily assign scored items to team members by capacity.
 * Each member gets a equal share of the total capacity.
 * Items without estimates get a default 2h slot.
 */
function assignItems(
  scored: ScoredItem[],
  team: string[],
  totalHours: number
): { assignments: Assignment[]; unscheduled: ScoredItem[] } {
  const perMemberCapacity = totalHours / Math.max(team.length, 1);
  const remaining = new Map<string, number>(
    team.map((member) => [member, perMemberCapacity])
  );

  const assignments: Assignment[] = [];
  const unscheduled: ScoredItem[] = [];

  let memberIdx = 0;

  for (const item of scored) {
    const hours = item.estimatedHours > 0 ? item.estimatedHours : 2;

    // Round-robin: try each member in turn
    let assigned = false;
    for (let attempt = 0; attempt < team.length; attempt++) {
      const member = team[(memberIdx + attempt) % team.length];
      const cap = remaining.get(member) ?? 0;
      if (cap >= hours) {
        remaining.set(member, cap - hours);
        assignments.push({ item, assignee: member });
        memberIdx = (memberIdx + 1) % team.length;
        assigned = true;
        break;
      }
    }

    if (!assigned) {
      unscheduled.push(item);
    }
  }

  return { assignments, unscheduled };
}

// ---------------------------------------------------------------------------
// Sprint creation
// ---------------------------------------------------------------------------

/** Calculate sprint end date: 2 weeks from today. */
function sprintDeadline(): string {
  const d = new Date();
  d.setDate(d.getDate() + 14);
  return d.toISOString().slice(0, 10);
}

/** Create the sprint Milestone and return its ID. */
function createSprintMilestone(sprintName: string, assignments: Assignment[]): string {
  const totalHours = assignments.reduce((sum, a) => sum + a.item.estimatedHours, 0);
  const itemCount = assignments.length;
  const deadline = sprintDeadline();

  const cmd = [
    "create",
    `--type Milestone`,
    `--title "${sprintName}"`,
    `--description "Sprint goal: ${itemCount} items, ~${Math.round(totalHours)}h of work"`,
    `--deadline "${deadline}"`,
    `--priority 1`,
    "--json",
  ].join(" ");

  if (DRY_RUN) {
    console.log(`[dry-run] pm ${cmd}`);
    return "MILESTONE-DRY";
  }

  const result = JSON.parse(pm(cmd));
  return result.id as string;
}

/** Update each assigned item with sprint name and assignee. */
function applyAssignments(assignments: Assignment[], sprintName: string): void {
  for (const { item, assignee } of assignments) {
    const cmd = [
      `update ${item.id}`,
      `--sprint "${sprintName}"`,
      assignee ? `--assignee "${assignee}"` : "",
    ]
      .filter(Boolean)
      .join(" ");

    if (DRY_RUN) {
      console.log(`[dry-run] pm ${cmd}`);
    } else {
      pm(cmd);
    }
  }
}

// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------

function printReport(
  assignments: Assignment[],
  unscheduled: ScoredItem[],
  milestoneId: string
): void {
  const totalHours = assignments.reduce((sum, a) => sum + a.item.estimatedHours, 0);
  const usedCapacity = Math.round(totalHours * 10) / 10;

  console.log();
  console.log(`${SPRINT_NAME}  โ€”  capacity: ${CAPACITY_HOURS}h  โ€”  team: ${TEAM.join(", ") || "(solo)"}`);
  console.log();

  const col = (s: string, w: number) => s.slice(0, w).padEnd(w);

  console.log(
    `  ${col("Score", 6)} ${col("ID", 10)} ${col("Title", 38)} ${col("Est", 5)} ${col("Assignee", 12)}`
  );
  console.log(
    `  ${"โ”€".repeat(6)} ${"โ”€".repeat(10)} ${"โ”€".repeat(38)} ${"โ”€".repeat(5)} ${"โ”€".repeat(12)}`
  );

  for (const { item, assignee } of assignments) {
    const est =
      item.estimated_minutes != null
        ? `${Math.round(item.estimatedHours * 10) / 10}h`
        : "  ?h";
    console.log(
      `  ${col(String(item.score), 6)} ${col(item.id, 10)} ${col(item.title, 38)} ${col(est, 5)} ${col(assignee, 12)}`
    );
  }

  console.log();
  console.log(
    `Total: ${usedCapacity}h / ${CAPACITY_HOURS}h  (${Math.max(0, CAPACITY_HOURS - usedCapacity)}h remaining)`
  );
  console.log(`Unscheduled: ${unscheduled.length} items`);
  console.log();
  if (!DRY_RUN) {
    console.log(`Sprint Milestone: ${milestoneId}`);
    console.log(`Items updated with sprint="${SPRINT_NAME}".`);
  }
  console.log();
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  console.log(`Planning ${SPRINT_NAME} (capacity: ${CAPACITY_HOURS}h)โ€ฆ`);

  // 1. Fetch open items
  const backlog = fetchBacklog();
  console.log(`Fetched ${backlog.length} open items.`);

  // 2. Find in-progress parent IDs for continuity scoring
  const inProgress = pmJson<PmItem[]>("list-in-progress");
  const inProgressParents = new Set(inProgress.map((i) => i.id));

  // 3. Score and sort
  const scored = buildScoredItems(backlog, inProgressParents);

  // 4. Assign to team by capacity
  const { assignments, unscheduled } = assignItems(scored, TEAM, CAPACITY_HOURS);

  // 5. Create sprint Milestone
  const milestoneId = createSprintMilestone(SPRINT_NAME, assignments);

  // 6. Apply assignee + sprint tags to items
  applyAssignments(assignments, SPRINT_NAME);

  // 7. Print report
  printReport(assignments, unscheduled, milestoneId);

  if (DRY_RUN) {
    console.log("(dry-run: no changes written)");
  }
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub ai-sprint-planner
๐Ÿ”ฅ

Bug Triage

From incident intake to hotfix delivery

Incident intake through hotfix delivery. Ownership assignment, severity tracking, root-cause documentation, and post-mortem records. Designed for strict governance and full traceability.

Commands used
pm init pm create --type Issuepm start-taskpm learningspm validatepm close-task

Overview

This example shows how a team uses pm-cli to manage bug triage and incident response. It covers creating Issues with structured severity and reproduction information, prioritizing and blocking related work on a critical bug, tracking the resolution, and recording post-incident learnings. It also demonstrates pm comments-audit for a chronological status trail and the full lifecycle from "report received" to "post-mortem closed."

When to use this workflow

  • You receive bug reports that need structured triage before any code changes
  • A production incident requires coordination across multiple people
  • You want a searchable, permanent record of every bug's lifecycle including root cause
  • You need to block planned feature work until a critical bug is resolved

Key commands used

Command Purpose
pm create --type Issue File a bug with severity, repro, expected/actual results
pm update <id> --priority 0 Escalate to critical severity
pm update <id> --status blocked Block other work on a critical issue
pm update <id> --blocked-by <id> Record the blocking dependency
pm start-task <id> Lifecycle alias: claim + move to in_progress
pm close-task <id> "reason" Lifecycle alias: close with reason + release assignment
pm notes <id> "text" Add structured investigation notes
pm learnings <id> "text" Record post-incident lessons learned
pm comments <id> "text" Post chronological status updates
pm comments <id> View the full comment history (timeline) for an issue
pm close <id> "reason" Close a resolved issue with root cause and fix
pm search "query" Find related or similar bugs
pm health Identify stale or blocked items
pm list-open See all open bugs during triage session
pm stats Count open/closed issues by priority

Severity mapping to pm-cli priorities

Severity pm-cli priority Meaning
Critical / P0 --priority 0 Production down, data loss, security breach
High / P1 --priority 1 Major feature broken, significant user impact
Medium / P2 --priority 2 Degraded experience, workaround exists
Low / P3 --priority 3 Minor UI/cosmetic issue, rare edge case
Minimal / P4 --priority 4 Nice-to-fix, no user impact

Step-by-step narrative

1. Receiving and filing a bug report

When a bug report comes in (from a user, monitoring alert, or teammate), create an Issue immediately with as much structured information as the report contains. Use this template:

Severity: <critical|high|medium|low>
Repro steps:
  1. ...
  2. ...
Expected result: ...
Actual result: ...
Frequency: always / intermittent / rare
Reporter: @username / monitoring alert / internal
Environment: production / staging / local
# P0: production data corruption report
pm create \
  --title "CRITICAL: export function corrupts CSV when field contains comma" \
  --description "Severity: critical. Repro: 1) Create record with comma in 'company' field. 2) Export to CSV. 3) Open in spreadsheet. Expected: field wrapped in quotes, data intact. Actual: field splits across columns, downstream data corrupted. Frequency: 100% repro. Reporter: @customer-success (3 affected customers). Env: production." \
  --type Issue \
  --priority 0

2. Triage: confirm severity and assign

The on-call engineer reviews the report, confirms the severity, and starts working on the issue using pm start-task which claims it and moves it to in_progress in one step.

pm start-task APP-20   # on-call engineer takes ownership

pm notes APP-20 --add "Reproduced locally in under 2 minutes. P0 confirmed โ€” any export containing a comma in any field produces corrupted output. Root cause likely unescaped commas in csv-writer library call. Affected file: src/export/csv.ts."

pm comments APP-20 \
  "TRIAGE CONFIRMED: P0 confirmed. Full investigation notes attached. Starting root cause analysis. All export-related deployments halted."

3. Block related work on critical bugs

While a P0 is open, no new feature work should ship that touches the affected code paths. Create explicit blocking relationships so the dependency is visible.

# Block the "export enhancements" Feature on the critical bug
pm update APP-15 --status blocked
pm update APP-15 --blocked-by APP-20

pm comments APP-15 \
  "Blocked on APP-20 (P0 CSV corruption). No export changes should ship until root cause is fixed and validated."

4. Post regular status updates during investigation

Use pm comments to post time-stamped updates. This creates a searchable incident timeline that stakeholders can read without pinging the engineer.

pm comments APP-20 "T+15min: Confirmed root cause โ€” csv-writer call missing the 'quoted' option. One-line fix. Writing test now."
pm comments APP-20 "T+30min: Fix applied, 5 new test cases cover comma, quote, newline, empty, unicode in fields. All pass. Deploying to staging."
pm comments APP-20 "T+45min: Staging validation complete. CSV exports clean for all 3 customer scenarios. Deploying to production."
pm comments APP-20 "T+60min: Production deploy complete. Monitoring shows 0 errors on export endpoint. Issue resolved."

5. View the full incident timeline

pm comments APP-20

This prints every comment on the issue in chronological order โ€” the complete timeline from report to resolution. Useful for writing post-mortems and for anyone joining the incident late.

6. Close the bug with root cause and fix

Use pm close-task, the lifecycle alias that closes the item, records the reason, and releases assignment metadata.

pm close-task APP-20 \
  "ROOT CAUSE: csv-writer call in src/export/csv.ts was missing the 'quoted: true' option, causing fields containing commas to be written without RFC 4180 quoting. FIX: Added 'quoted: true' to all csv-writer calls. VALIDATION: 5 new unit tests (comma, quote char, newline, empty, unicode), all pass. Staging smoke test clean. Production deploy at T+60min. CUSTOMER IMPACT: 3 customers affected; data exports from the affected window need re-download. Customer success team notified."

7. Unblock dependent work

pm update APP-15 --status open

pm comments APP-15 \
  "Unblocked โ€” APP-20 (P0 CSV corruption) resolved and deployed. Export enhancements can resume."

8. File medium and lower-priority bugs discovered during investigation

Often investigation surfaces related bugs. File them immediately so they are not forgotten.

pm create \
  --title "CSV export: tab characters in fields not escaped" \
  --description "Severity: medium. Found during APP-20 investigation. Repro: field containing tab character exported without escaping. Expected: tab escaped per RFC 4180. Actual: raw tab breaks column alignment. Frequency: uncommon edge case. No customer reports yet." \
  --type Issue \
  --priority 2

pm create \
  --title "Export button shows no loading state during large exports" \
  --description "Severity: low. Repro: export with >10k rows. Expected: spinner or progress indicator. Actual: button stays active, looks like nothing happened. UI/UX issue." \
  --type Issue \
  --priority 3

9. Record post-incident learnings

After the incident is resolved, use pm learnings to attach structured lessons that can be searched and referenced later by the team.

pm learnings APP-20 --add "CSV export must always use 'quoted: true' option in csv-writer. Any change to export code requires RFC 4180 edge case tests."

pm learnings APP-20 --add "Token TTL configuration must validate minimum values at startup to catch unit mismatch early."

pm learnings APP-20 --add "Export endpoint needs response content monitoring to catch format regressions before customers report them."

Review all learnings on an item:

pm learnings APP-20 --limit 10

10. Post-incident write-up on the closed issue

After the dust settles, add a post-mortem comment to the closed issue.

pm comments APP-20 \
  "POST-MORTEM

TIMELINE: Report 09:15 โ†’ Repro confirmed 09:30 โ†’ Fix deployed prod 10:15. Total TTR: 60 minutes.

ROOT CAUSE: Missing 'quoted: true' option in csv-writer library call. The option was present in the original implementation but accidentally removed in the refactor during v1.3 (commit a1b2c3d).

CONTRIBUTING FACTORS:
- No unit test covering comma-in-field scenario
- CSV output format not in integration test suite
- Code review did not catch the option removal (silent behavior change)

CORRECTIVE ACTIONS:
1. Added unit tests for all RFC 4180 edge cases (done, merged in fix PR)
2. Add CSV export to integration test suite โ€” filed as APP-25
3. Add 'csv export options' to code review checklist โ€” filed as APP-26
4. Notify affected customers to re-download exports โ€” customer success team action"

11. Weekly triage session

Run a weekly triage pass to keep the bug backlog healthy.

pm list-open               # see all open issues
pm health                  # warns about stale in-progress, missing descriptions
pm stats                   # count open by priority โ€” helps spot P1 accumulation
pm search "export"         # check for related issues to cluster
pm comments-audit
Show complete workflow script
bug-triage/workflow.sh
#!/usr/bin/env tsx
import { execFileSync } from "node:child_process";

function pm(args: string[]): void {
  console.log(`pm ${args.map((arg) => (arg.includes(" ") ? JSON.stringify(arg) : arg)).join(" ")}`);
  execFileSync("pm", args, { stdio: "inherit" });
}

const incidentId = process.env.PM_INCIDENT_ID ?? "APP-20";
const blockedFeatureId = process.env.PM_BLOCKED_FEATURE_ID ?? "APP-15";

pm([
  "create",
  "--title",
  "CRITICAL: CSV export corrupts data when field contains a comma",
  "--type",
  "Issue",
  "--priority",
  "0",
  "--severity",
  "critical",
  "--reporter",
  "customer-success",
  "--repro-steps",
  "Create a record with a comma, export CSV, then open it in a spreadsheet.",
  "--expected-result",
  "Comma-containing fields are quoted and data remains aligned.",
  "--actual-result",
  "Field splits across columns and corrupts subsequent data.",
  "--affected-version",
  "v1.3",
  "--environment",
  "production",
  "--description",
  "100% reproducible. Three paying customers affected.",
]);

pm(["start-task", incidentId]);
pm(["comments", incidentId, "TRIAGE T+0: P0 confirmed. Export deployments halted while root cause is investigated."]);
pm(["notes", incidentId, "--add", "Likely affected file: src/export/csv.ts. Validate RFC 4180 quoting edge cases."]);
pm(["update", blockedFeatureId, "--status", "blocked", "--blocked-by", incidentId]);
pm(["comments", blockedFeatureId, `Blocked on ${incidentId}. No export changes should ship until the P0 is resolved.`]);
pm(["comments", incidentId, "T+15min: Root cause found. CSV writer called without forced quoting. Tests in progress."]);
pm(["learnings", incidentId, "--add", "Every export change requires CSV edge-case tests for comma, quotes, newline, empty, and unicode fields."]);
pm(["close-task", incidentId, "Resolved: restored CSV quoting, added edge-case tests, validated staging and production exports."]);
pm(["update", blockedFeatureId, "--status", "open"]);
View source on GitHub bug-triage
๐Ÿฉบ

Code Health Reporter

Aggregate pm health, validate, and duplicate-scan signals into one health score

Runs pm health, pm validate, and optional duplicate-scan checks, aggregates results into a weighted 0โ€“100 health score with a letter grade, prints a color-coded terminal report, and optionally posts to a webhook.

Commands used
pm init pm health --jsonpm validate --jsonduplicate scanhealth scorewebhookTypeScript

Run pm health, pm validate, and an optional duplicate scan in one shot, aggregate the results into a single health score, and get a color-coded terminal report. Optionally post the score to any webhook.

What this does

  • Runs pm health --json and surfaces errors/warnings from all health checks
  • Runs pm validate --json to catch schema and consistency errors
  • Attempts pm search when that command is available, otherwise treats duplicate scanning as unavailable
  • Aggregates all available checks into a single 0โ€“100 health score (weighted: health 40%, validate 40%, duplicate scan 20%)
  • Outputs a color-coded terminal report with per-section scores and a letter grade (Aโ€“F)
  • Optionally POSTs a JSON health payload to a webhook URL (e.g. Slack, PagerDuty, custom dashboard)
  • Exits with code 1 if the overall grade is D or F (suitable for CI gating)

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+

Setup

cd code-health-reporter
npm install

Usage

# Run all checks and print report
npx tsx main.ts

# Post results to a webhook
HEALTH_WEBHOOK_URL=https://hooks.example.com/pm-health npx tsx main.ts

# Disable ANSI color (for log files / CI)
npx tsx main.ts --no-color

# Combine
HEALTH_WEBHOOK_URL=https://hooks.example.com/pm-health npx tsx main.ts --no-color

Environment variables

Variable Default Description
HEALTH_WEBHOOK_URL (none) If set, POST the health report JSON to this URL

CLI flags

Flag Default Description
--webhook (none) Webhook URL (overrides HEALTH_WEBHOOK_URL)
--no-color false Disable ANSI color codes in output

Exit codes

Code Meaning
0 Grade A, B, or C โ€” healthy
1 Grade D or F โ€” action required

Webhook payload

When a webhook URL is configured, the script POSTs the following JSON:

{
  "timestamp": "2026-05-10T09:00:00.000Z",
  "overallScore": 82,
  "grade": "B",
  "summary": "Found: 1 validation warning(s).",
  "sections": {
    "health": 90,
    "validation": 80,
    "dedupe": 100
  },
  "issues": {
    "healthIssues": 0,
    "validationErrors": 0,
    "validationWarnings": 1,
    "duplicateGroups": 0
  }
}

Example output

Code Health Reporter  2026-05-10T09:00:00.000Z
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Overall Health Score
   B   โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 82
  All checks passed. Project data is clean.

pm health  90/100
  โœ“  Item reference integrity
  โœ“  Circular dependency check
  โœ“  Orphaned items check

pm validate  80/100
  โœ“ Valid โ€” no errors
  [warning] APP-14.deadline โ€” Deadline is in the past

duplicate scan  100/100
  โœ“ No duplicate items found.

Score Breakdown
  pm health  (40%)     โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 90
  pm validate (40%)    โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 80
  duplicate scan (20%) โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100
  Overall              โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 82

Using with CI

Add to your GitHub Actions workflow to gate deploys on project health:

- name: Check pm health
  run: |
    cd code-health-reporter
    npm ci
    HEALTH_WEBHOOK_URL=${{ secrets.HEALTH_WEBHOOK_URL }} npx tsx main.ts --no-color

The step will fail (exit code 1) if the health grade drops to D or F.

Show complete workflow script
code-health-reporter/workflow.sh
/**
 * code-health-reporter โ€” Aggregate pm health, validation, and duplicate-scan signals
 * into a single color-coded terminal report with an optional webhook post.
 *
 * Runs:
 *   pm health --json
 *   pm validate --json
 *   pm dedupe-audit --json (when available)
 *
 * Then aggregates results into a single health score summary, prints a
 * color-coded terminal report, and optionally POSTs to a webhook URL.
 *
 * Usage:
 *   npx tsx main.ts
 *   HEALTH_WEBHOOK_URL=https://hooks.example.com/... npx tsx main.ts
 *   npx tsx main.ts --webhook https://hooks.example.com/...
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    webhook: { type: "string" },
    "no-color": { type: "boolean", default: false },
  },
});

const WEBHOOK_URL =
  args.webhook ?? process.env.HEALTH_WEBHOOK_URL ?? "";
const NO_COLOR = args["no-color"] ?? false;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** Output shape of `pm health --json` */
interface HealthReport {
  score?: number;           // 0โ€“100 overall health score
  issues?: HealthIssue[];
  summary?: string;
  checks?: HealthCheck[];
}

interface HealthIssue {
  id?: string;
  level: "error" | "warning" | "info";
  message: string;
  item_id?: string;
}

interface HealthCheck {
  name: string;
  passed: boolean;
  message?: string;
}

/** Output shape of `pm validate --json` */
interface ValidateReport {
  valid?: boolean;
  errors?: ValidationError[];
  warnings?: ValidationWarning[];
}

interface ValidationError {
  item_id?: string;
  field?: string;
  message: string;
}

interface ValidationWarning {
  item_id?: string;
  field?: string;
  message: string;
}

/** Output shape of `pm dedupe-audit --json` when that optional command is available. */
interface DedupeReport {
  duplicates?: DuplicateGroup[];
  total_items?: number;
  checked?: number;
  duplicate_count?: number;
}

interface DuplicateGroup {
  ids: string[];
  titles: string[];
  similarity?: number;
  reason?: string;
}

/** Aggregated health score with breakdown. */
interface AggregatedReport {
  timestamp: string;
  overallScore: number;
  grade: "A" | "B" | "C" | "D" | "F";
  health: { raw: HealthReport; score: number; issueCount: number };
  validation: { raw: ValidateReport; score: number; errorCount: number; warningCount: number };
  dedupe: { raw: DedupeReport; score: number; duplicateCount: number };
  summary: string;
}

// ---------------------------------------------------------------------------
// pm-cli helpers
// ---------------------------------------------------------------------------

/** Run a pm command and return raw output. Returns null on non-zero exit. */
function pmRaw(args: string[]): string | null {
  try {
    return execSync(["pm", ...args].join(" "), {
      encoding: "utf-8",
      stdio: ["pipe", "pipe", "pipe"],
    }).trim();
  } catch (err: unknown) {
    // pm may exit non-zero but still write JSON to stdout
    if (err && typeof err === "object" && "stdout" in err) {
      const stdout = (err as { stdout: string }).stdout;
      if (typeof stdout === "string" && stdout.trim().startsWith("{")) {
        return stdout.trim();
      }
    }
    return null;
  }
}

function pmJson<T>(args: string[]): T | null {
  const out = pmRaw(args);
  if (!out) return null;
  try {
    return JSON.parse(out) as T;
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// Score calculation helpers
// ---------------------------------------------------------------------------

function calcHealthScore(report: HealthReport | null): { score: number; issueCount: number } {
  if (!report) return { score: 50, issueCount: 0 }; // unknown

  // If the report provides its own score, trust it
  if (typeof report.score === "number") {
    const errorCount = (report.issues ?? []).filter((i) => i.level === "error").length;
    return { score: report.score, issueCount: errorCount };
  }

  const issues = report.issues ?? [];
  const checks = report.checks ?? [];
  const errors = issues.filter((i) => i.level === "error").length;
  const warnings = issues.filter((i) => i.level === "warning").length;

  // Score: start at 100, deduct per issue
  let score = 100;
  score -= errors * 15;
  score -= warnings * 5;

  // Factor in check results
  if (checks.length > 0) {
    const failedChecks = checks.filter((c) => !c.passed).length;
    score -= failedChecks * 10;
  }

  return { score: Math.max(0, Math.min(100, score)), issueCount: errors };
}

function calcValidationScore(
  report: ValidateReport | null
): { score: number; errorCount: number; warningCount: number } {
  if (!report) return { score: 50, errorCount: 0, warningCount: 0 };

  const errors = report.errors?.length ?? 0;
  const warnings = report.warnings?.length ?? 0;
  let score = 100;
  score -= errors * 20;
  score -= warnings * 5;

  return {
    score: Math.max(0, Math.min(100, score)),
    errorCount: errors,
    warningCount: warnings,
  };
}

function calcDedupeScore(
  report: DedupeReport | null
): { score: number; duplicateCount: number } {
  if (!report) return { score: 50, duplicateCount: 0 };

  const duplicates = report.duplicates?.length ?? 0;
  const total = report.total_items ?? report.checked ?? 1;

  // Score based on duplicate rate
  const dupRate = duplicates / Math.max(total, 1);
  const score = Math.max(0, Math.round((1 - dupRate * 3) * 100)); // 3x penalty

  return { score: Math.min(100, score), duplicateCount: duplicates };
}

function scoreToGrade(score: number): "A" | "B" | "C" | "D" | "F" {
  if (score >= 90) return "A";
  if (score >= 75) return "B";
  if (score >= 60) return "C";
  if (score >= 45) return "D";
  return "F";
}

// ---------------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------------

function aggregate(
  healthRaw: HealthReport | null,
  validateRaw: ValidateReport | null,
  dedupeRaw: DedupeReport | null
): AggregatedReport {
  const health = {
    raw: healthRaw ?? {},
    ...calcHealthScore(healthRaw),
  };
  const validation = {
    raw: validateRaw ?? {},
    ...calcValidationScore(validateRaw),
  };
  const dedupe = {
    raw: dedupeRaw ?? {},
    ...calcDedupeScore(dedupeRaw),
  };

  // Weighted average: health 40%, validation 40%, dedupe 20%
  const overallScore = Math.round(
    health.score * 0.4 + validation.score * 0.4 + dedupe.score * 0.2
  );

  const grade = scoreToGrade(overallScore);

  const parts: string[] = [];
  if (health.issueCount > 0)
    parts.push(`${health.issueCount} health issue(s)`);
  if (validation.errorCount > 0)
    parts.push(`${validation.errorCount} validation error(s)`);
  if (validation.warningCount > 0)
    parts.push(`${validation.warningCount} validation warning(s)`);
  if (dedupe.duplicateCount > 0)
    parts.push(`${dedupe.duplicateCount} duplicate group(s)`);

  const summary =
    parts.length === 0
      ? "All checks passed. Project data is clean."
      : `Found: ${parts.join(", ")}.`;

  return {
    timestamp: new Date().toISOString(),
    overallScore,
    grade,
    health,
    validation,
    dedupe,
    summary,
  };
}

// ---------------------------------------------------------------------------
// Terminal colors
// ---------------------------------------------------------------------------

function c(code: string): string {
  return NO_COLOR ? "" : code;
}

const RESET = c("\x1b[0m");
const BOLD = c("\x1b[1m");
const DIM = c("\x1b[2m");
const RED = c("\x1b[31m");
const GREEN = c("\x1b[32m");
const YELLOW = c("\x1b[33m");
const CYAN = c("\x1b[36m");
const MAGENTA = c("\x1b[35m");
const BG_GREEN = c("\x1b[42m");
const BG_RED = c("\x1b[41m");
const BG_YELLOW = c("\x1b[43m");

function scoreColor(score: number): string {
  if (score >= 80) return GREEN;
  if (score >= 60) return YELLOW;
  return RED;
}

function gradeBlock(grade: string, score: number): string {
  if (score >= 80) return `${BG_GREEN}${BOLD} ${grade} ${RESET}`;
  if (score >= 60) return `${BG_YELLOW}${BOLD} ${grade} ${RESET}`;
  return `${BG_RED}${BOLD} ${grade} ${RESET}`;
}

function bar(score: number, width = 24): string {
  const filled = Math.round((score / 100) * width);
  const empty = width - filled;
  const col = scoreColor(score);
  return `${col}${"โ–ˆ".repeat(filled)}${RESET}${DIM}${"โ–‘".repeat(empty)}${RESET} ${scoreColor(score)}${score}${RESET}`;
}

// ---------------------------------------------------------------------------
// Terminal report
// ---------------------------------------------------------------------------

function printReport(r: AggregatedReport): void {
  console.log();
  console.log(
    `${BOLD}Code Health Reporter${RESET}  ${DIM}${r.timestamp}${RESET}`
  );
  console.log("โ”€".repeat(60));
  console.log();

  // Overall score block
  console.log(`${BOLD}Overall Health Score${RESET}`);
  console.log(`  ${gradeBlock(r.grade, r.overallScore)}  ${bar(r.overallScore)}`);
  console.log(`  ${DIM}${r.summary}${RESET}`);
  console.log();

  // Section: pm health
  console.log(`${BOLD}pm health${RESET}  ${scoreColor(r.health.score)}${r.health.score}/100${RESET}`);
  if (r.health.raw.checks && r.health.raw.checks.length > 0) {
    for (const chk of r.health.raw.checks) {
      const icon = chk.passed ? `${GREEN}โœ“${RESET}` : `${RED}โœ—${RESET}`;
      console.log(`  ${icon}  ${chk.name}${chk.message ? ` โ€” ${DIM}${chk.message}${RESET}` : ""}`);
    }
  } else if (r.health.raw.issues && r.health.raw.issues.length > 0) {
    for (const issue of r.health.raw.issues.slice(0, 10)) {
      const col =
        issue.level === "error"
          ? RED
          : issue.level === "warning"
          ? YELLOW
          : CYAN;
      const prefix = issue.item_id ? `${DIM}${issue.item_id}${RESET} ` : "";
      console.log(`  ${col}[${issue.level}]${RESET} ${prefix}${issue.message}`);
    }
    if ((r.health.raw.issues?.length ?? 0) > 10) {
      console.log(
        `  ${DIM}โ€ฆ and ${r.health.raw.issues!.length - 10} more issue(s)${RESET}`
      );
    }
  } else if (r.health.raw.summary) {
    console.log(`  ${DIM}${r.health.raw.summary}${RESET}`);
  } else {
    console.log(`  ${GREEN}No issues detected.${RESET}`);
  }
  console.log();

  // Section: pm validate
  console.log(
    `${BOLD}pm validate${RESET}  ${scoreColor(r.validation.score)}${r.validation.score}/100${RESET}`
  );
  const vErrors = r.validation.raw.errors ?? [];
  const vWarnings = r.validation.raw.warnings ?? [];
  if (vErrors.length === 0 && vWarnings.length === 0) {
    const valid = r.validation.raw.valid !== false;
    console.log(
      `  ${valid ? GREEN + "โœ“" + RESET + " Valid" : RED + "โœ—" + RESET + " Invalid"} โ€” no errors or warnings`
    );
  } else {
    for (const err of vErrors.slice(0, 10)) {
      const loc = err.item_id
        ? `${DIM}${err.item_id}${err.field ? `.${err.field}` : ""}${RESET} `
        : "";
      console.log(`  ${RED}[error]${RESET}   ${loc}${err.message}`);
    }
    if (vErrors.length > 10)
      console.log(`  ${DIM}โ€ฆ and ${vErrors.length - 10} more error(s)${RESET}`);
    for (const warn of vWarnings.slice(0, 5)) {
      const loc = warn.item_id
        ? `${DIM}${warn.item_id}${warn.field ? `.${warn.field}` : ""}${RESET} `
        : "";
      console.log(`  ${YELLOW}[warning]${RESET} ${loc}${warn.message}`);
    }
    if (vWarnings.length > 5)
      console.log(`  ${DIM}โ€ฆ and ${vWarnings.length - 5} more warning(s)${RESET}`);
  }
  console.log();

  // Section: optional duplicate scan
  console.log(
    `${BOLD}duplicate scan${RESET}  ${scoreColor(r.dedupe.score)}${r.dedupe.score}/100${RESET}`
  );
  const dupes = r.dedupe.raw.duplicates ?? [];
  if (dupes.length === 0) {
    console.log(`  ${GREEN}โœ“ No duplicate items found.${RESET}`);
  } else {
    console.log(
      `  ${YELLOW}${dupes.length} duplicate group(s) found:${RESET}`
    );
    for (const group of dupes.slice(0, 5)) {
      const titles = group.titles ?? group.ids;
      const sim = group.similarity != null ? ` (${Math.round(group.similarity * 100)}% similar)` : "";
      console.log(
        `  ${MAGENTA}โ€ข${RESET} [${group.ids.join(", ")}]${sim}`
      );
      if (titles.length > 0 && titles !== group.ids) {
        console.log(`    ${DIM}${titles[0]}${RESET}`);
      }
    }
    if (dupes.length > 5)
      console.log(`  ${DIM}โ€ฆ and ${dupes.length - 5} more group(s)${RESET}`);
  }
  console.log();

  // Score breakdown table
  console.log(`${BOLD}Score Breakdown${RESET}`);
  const rows: [string, number, string][] = [
    ["pm health  (40%)", r.health.score, bar(r.health.score, 16)],
    ["pm validate (40%)", r.validation.score, bar(r.validation.score, 16)],
    ["duplicate scan (20%)", r.dedupe.score, bar(r.dedupe.score, 16)],
    ["Overall", r.overallScore, bar(r.overallScore, 16)],
  ];
  for (const [label, , barStr] of rows) {
    const isBold = label === "Overall";
    console.log(
      `  ${isBold ? BOLD : ""}${label.padEnd(20)}${RESET}  ${barStr}`
    );
  }
  console.log();
}

// ---------------------------------------------------------------------------
// Webhook
// ---------------------------------------------------------------------------

interface WebhookPayload {
  timestamp: string;
  overallScore: number;
  grade: string;
  summary: string;
  sections: {
    health: number;
    validation: number;
    dedupe: number;
  };
  issues: {
    healthIssues: number;
    validationErrors: number;
    validationWarnings: number;
    duplicateGroups: number;
  };
}

async function postWebhook(r: AggregatedReport, url: string): Promise<void> {
  const payload: WebhookPayload = {
    timestamp: r.timestamp,
    overallScore: r.overallScore,
    grade: r.grade,
    summary: r.summary,
    sections: {
      health: r.health.score,
      validation: r.validation.score,
      dedupe: r.dedupe.score,
    },
    issues: {
      healthIssues: r.health.issueCount,
      validationErrors: r.validation.errorCount,
      validationWarnings: r.validation.warningCount,
      duplicateGroups: r.dedupe.duplicateCount,
    },
  };

  console.log(`Posting health report to webhookโ€ฆ`);
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Webhook error ${res.status}: ${text}`);
  }

  console.log(`  ${GREEN}โœ“${RESET} Webhook delivered (HTTP ${res.status}).`);
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  console.log("Running pm health checksโ€ฆ");

  process.stdout.write("  pm health       โ€ฆ ");
  const healthRaw = pmJson<HealthReport>(["health", "--json"]);
  console.log(healthRaw ? "done" : `${YELLOW}not available${RESET}`);

  process.stdout.write("  pm validate     โ€ฆ ");
  const validateRaw = pmJson<ValidateReport>(["validate", "--json"]);
  console.log(validateRaw ? "done" : `${YELLOW}not available${RESET}`);

  process.stdout.write("  duplicate scan  โ€ฆ ");
  const dedupeRaw = pmJson<DedupeReport>(["dedupe-audit", "--json"]);
  console.log(dedupeRaw ? "done" : `${YELLOW}not available${RESET}`);

  const report = aggregate(healthRaw, validateRaw, dedupeRaw);
  printReport(report);

  if (WEBHOOK_URL) {
    await postWebhook(report, WEBHOOK_URL);
  }

  // Exit with non-zero if grade is D or F
  if (report.grade === "D" || report.grade === "F") {
    process.exitCode = 1;
  }
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub code-health-reporter
๐Ÿ“ฆ

Gh Issues Importer

Extension example: import GitHub Issues into pm

Complete TypeScript extension example that fetches GitHub Issues and creates pm items. Shows extension manifest, typed command handlers, importer pattern, and incremental sync.

Commands used
pm init defineExtensionregisterCommandregisterImporterTypeScript

Map GitHub Issues into pm item payloads.

Fetches open (or all) issues from a GitHub repository via the GitHub REST API and returns normalized pm item payloads. Workflow wrappers can create or update items from those payloads, keyed on gh-issue-<number>.

The extension is authored in TypeScript. Run npm run build before installing from a local checkout so manifest.json can load dist/index.js.


Setup

You need a GitHub personal access token with repo read scope (or public_repo for public repos only).

export GH_TOKEN=<github-token>
# or
export GITHUB_TOKEN=<github-token>

The extension checks GH_TOKEN first, then GITHUB_TOKEN.


Install

pm install github.com/unbraind/pm-github --project

This installs the extension into .agents/pm/extensions/pm-github/ in your project.

Verify it loaded:

pm extension explore --project

Usage

# Import all open issues from a repo
pm gh-import --owner myorg --repo myrepo

# Import all issues regardless of state
pm gh-import --owner myorg --repo myrepo --state all

# Filter by label
pm gh-import --owner myorg --repo myrepo --label bug
pm gh-import --owner myorg --repo myrepo --label "priority: high"

# Combine filters
pm gh-import --owner myorg --repo myrepo --state open --label enhancement

Each imported issue is mapped to a pm item payload with:

  • title: the issue title
  • body: the issue body (truncated to 500 chars)
  • tags: github, gh-issue, and any GitHub labels
  • status: open (open issues) or closed (closed issues)
  • priority: inferred from labels โ€” priority: high โ†’ 1, priority: low โ†’ 3, else 2
  • id suffix: gh-issue-<number> (used for deduplication)

TypeScript usage

The TypeScript source (index.ts) is a direct port of index.js with full type coverage:

  • GithubIssue, GithubLabel, GithubUser interfaces for the GitHub REST API response
  • FetchIssuesOptions and PmItemShape for internal helpers
  • ExtensionApi and CommandHandlerContext from @unbrained/pm-cli/sdk

To compile and use the TypeScript version:

cd examples/gh-issues-importer
npm install
npm run build

Then install and run as normal:

pm install ./examples/gh-issues-importer --project
pm gh-import --owner myorg --repo myrepo

A tsconfig.json is included in this directory with recommended settings for pm-cli extension authoring ("module": "NodeNext", "strict": true).


Debugging

pm extension doctor --detail deep --trace
Show TypeScript source
gh-issues-importer/index.ts TypeScript
import {
  defineExtension,
  type ExtensionApi,
  type CommandHandlerContext,
  type ImportExportContext,
} from "@unbrained/pm-cli/sdk";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A GitHub label object returned by the REST API. */
interface GithubLabel {
  id: number;
  name: string;
  color: string;
  description: string | null;
}

/** A GitHub user object (author/assignee). */
interface GithubUser {
  login: string;
  id: number;
  avatar_url: string;
  html_url: string;
}

/** A GitHub issue object from the REST API (simplified to fields we use). */
interface GithubIssue {
  number: number;
  title: string;
  body: string | null;
  state: "open" | "closed";
  html_url: string;
  labels: GithubLabel[];
  user: GithubUser | null;
  created_at: string;
  updated_at: string;
  /** Present only on pull requests โ€” used to filter PRs out of the issue list. */
  pull_request?: unknown;
}

/** Options for the fetchIssues helper. */
interface FetchIssuesOptions {
  owner: string;
  repo: string;
  state: string;
  label: string | null;
  token: string;
}

/** Shape of a pm item returned by ghIssueToPmItem. */
interface PmItemShape {
  idSuffix: string;
  title: string;
  body: string;
  status: "open" | "closed";
  priority: "high" | "medium" | "low";
  tags: string[];
  meta: {
    gh_number: number;
    gh_url: string;
    gh_author: string | null;
    gh_created_at: string;
    gh_updated_at: string;
  };
}

/** Config object expected by the importer. */
interface ImporterConfig {
  owner: string;
  repo: string;
  state?: string;
  label?: string | null;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Resolve the GitHub token from the environment. */
function getToken(): string {
  const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error(
      "No GitHub token found. Set GH_TOKEN or GITHUB_TOKEN in your environment.\n" +
        "Get a token at https://github.com/settings/tokens (needs repo or public_repo scope)."
    );
  }
  return token;
}

/**
 * Fetch all issues from the GitHub REST API, handling pagination.
 * Pull requests are filtered out โ€” the issues endpoint returns both.
 */
async function fetchIssues({
  owner,
  repo,
  state,
  label,
  token,
}: FetchIssuesOptions): Promise<GithubIssue[]> {
  const issues: GithubIssue[] = [];
  let page = 1;
  const perPage = 100;

  while (true) {
    const url = new URL(
      `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`
    );
    url.searchParams.set("state", state);
    url.searchParams.set("per_page", String(perPage));
    url.searchParams.set("page", String(page));
    if (label) {
      url.searchParams.set("labels", label);
    }

    const response = await fetch(url.toString(), {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": "pm-github/0.1.0",
      },
    });

    if (!response.ok) {
      const body = await response.text();
      throw new Error(
        `GitHub API error ${response.status} for ${owner}/${repo}: ${body}`
      );
    }

    const data: GithubIssue[] = await response.json();

    // The issues endpoint also returns pull requests; filter them out.
    const realIssues = data.filter((item) => !item.pull_request);
    issues.push(...realIssues);

    if (data.length < perPage) {
      // Last page reached.
      break;
    }
    page++;
  }

  return issues;
}

/** Map a GitHub issue object to a pm item shape. */
function ghIssueToPmItem(ghIssue: GithubIssue): PmItemShape {
  const labelNames = (ghIssue.labels ?? []).map((l) => l.name);

  // Infer priority from labels.
  let priority: "high" | "medium" | "low" = "medium";
  for (const labelName of labelNames) {
    const lower = labelName.toLowerCase();
    if (
      lower.includes("priority: high") ||
      lower === "high" ||
      lower === "urgent"
    ) {
      priority = "high";
      break;
    }
    if (lower.includes("priority: low") || lower === "low") {
      priority = "low";
    }
  }

  // Truncate long issue bodies.
  const MAX_BODY = 500;
  const rawBody = ghIssue.body ?? "";
  const body =
    rawBody.length > MAX_BODY
      ? rawBody.slice(0, MAX_BODY) + "\n\nโ€ฆ(truncated, see full issue)"
      : rawBody;

  // Tags: always include "github" and "gh-issue", plus all label names.
  const tags = ["github", "gh-issue", ...labelNames];

  return {
    // Stable ID suffix for deduplication โ€” pm-cli will upsert on this key.
    idSuffix: `gh-issue-${ghIssue.number}`,
    title: ghIssue.title,
    body,
    status: ghIssue.state === "closed" ? "closed" : "open",
    priority,
    tags,
    meta: {
      gh_number: ghIssue.number,
      gh_url: ghIssue.html_url,
      gh_author: ghIssue.user?.login ?? null,
      gh_created_at: ghIssue.created_at,
      gh_updated_at: ghIssue.updated_at,
    },
  };
}

// ---------------------------------------------------------------------------
// Extension definition
// ---------------------------------------------------------------------------

export default defineExtension({
  name: "pm-github",
  version: "0.1.0",

  activate(api: ExtensionApi) {
    // -----------------------------------------------------------------------
    // commands capability โ€” registers `pm gh-import`
    // -----------------------------------------------------------------------
    api.registerCommand({
      name: "gh-import",
      description: "Import GitHub Issues as pm items",
      intent: "fetch and upsert GitHub issues into the pm item store",
      examples: [
        "pm gh-import --owner myorg --repo myrepo",
        "pm gh-import --owner myorg --repo myrepo --state all --label bug",
      ],
      flags: [
        {
          long: "--owner",
          value_name: "org",
          description: "GitHub org or user name (required)",
          required: true,
        },
        {
          long: "--repo",
          value_name: "name",
          description: "Repository name (required)",
          required: true,
        },
        {
          long: "--state",
          value_name: "state",
          description: "Issue state to import: open | closed | all (default: open)",
        },
        {
          long: "--label",
          value_name: "label",
          description: "Filter by label name (optional)",
        },
      ],

      async run(ctx: CommandHandlerContext) {
        const owner = ctx.options["owner"] as string;
        const repo = ctx.options["repo"] as string;
        const state = (ctx.options["state"] as string | undefined) ?? "open";
        const label = (ctx.options["label"] as string | undefined) ?? null;

        console.error(`Connecting to GitHub API for ${owner}/${repo}...`);

        const token = getToken();

        let ghIssues: GithubIssue[];
        try {
          ghIssues = await fetchIssues({ owner, repo, state, label, token });
        } catch (err) {
          console.error((err as Error).message);
          process.exitCode = 1;
          return;
        }

        if (ghIssues.length === 0) {
          console.error(
            `No issues found for ${owner}/${repo} (state=${state}${label ? `, label=${label}` : ""}).`
          );
          return;
        }

        const items = ghIssues.map(ghIssueToPmItem);
        return { owner, repo, state, label, count: items.length, items };
      },
    });

    // -----------------------------------------------------------------------
    // importers capability โ€” registers this extension as a named importer so
    // other tools and extensions can invoke it programmatically.
    // -----------------------------------------------------------------------
    api.registerImporter("gh-issues", async (ctx: ImportExportContext) => {
      const config = ctx.options;
      const { owner, repo, state = "open", label = null } =
        config as unknown as ImporterConfig;

      if (!owner || !repo) {
        throw new Error(
          "gh-issues importer requires config.owner and config.repo"
        );
      }

      const token = getToken();
      const ghIssues = await fetchIssues({ owner, repo, state, label, token });
      const items = ghIssues.map(ghIssueToPmItem);

      return { count: items.length, items };
    });
  },
});
View source on GitHub gh-issues-importer
๐Ÿ”„

Github Sync Workflow

Keep GitHub Issues and pm items in sync, both directions

Bi-directional sync: pull GitHub Issues into pm as Issue items, then push pm status changes back to GitHub as labels and comments. Designed for use as a CI cron job or manual sync trigger.

Commands used
pm init pm list-all --tagpm create --type Issuepm updateGitHub REST APITypeScript

Keep GitHub Issues and pm items in sync โ€” automatically. Pull open issues into pm, and push status changes back to GitHub as labels and comments.

What this does

Pull direction (GitHub โ†’ pm)

  • Fetches open GitHub Issues and creates pm Issue items
  • Maps GitHub labels to pm priority and tags
  • Deduplicates on gh-issue-<number> ID suffix โ€” re-running updates, not duplicates

Push direction (pm โ†’ GitHub)

  • Reads pm items tagged gh-issue that have changed status
  • Updates the matching GitHub Issue: adds a label (in-progress, blocked) and posts a comment
  • Closes the GitHub Issue when the pm item reaches closed

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+
  • A GitHub personal access token with repo scope
export GH_TOKEN=<github-token>

Setup

cd github-sync-workflow
npm install

# One-time: initialize pm in your project
pm init

Usage

# Pull GitHub Issues into pm
npx tsx main.ts pull --owner myorg --repo myrepo

# Push pm status changes back to GitHub
npx tsx main.ts push --owner myorg --repo myrepo

# Both directions (CI / cron job)
npx tsx main.ts sync --owner myorg --repo myrepo

Label mapping

GitHub label pm priority
priority: critical, urgent, P0 0
priority: high, high, P1 1
priority: low, low, P3 3
(default) 2

Status push mapping

pm status GitHub action
in_progress Add label in-progress, post comment
blocked Add label blocked, post comment with reason
closed Close the GitHub Issue, post resolution comment
canceled Close the GitHub Issue with cancellation note

Running as a cron job

# .github/workflows/pm-sync.yml
name: Sync GitHub Issues โ†” pm
on:
  schedule:
    - cron: "*/15 * * * *"   # every 15 minutes
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
        working-directory: github-sync-workflow
      - run: npx tsx main.ts sync --owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }}
        working-directory: github-sync-workflow
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Show TypeScript source
github-sync-workflow/main.ts TypeScript
/**
 * github-sync-workflow โ€” GitHub Issues โ†” pm-cli bi-directional sync
 *
 * Pull direction: import GitHub Issues as pm items.
 * Push direction: reflect pm status changes back to GitHub as labels/comments.
 *
 * Usage:
 *   npx tsx main.ts pull --owner myorg --repo myrepo
 *   npx tsx main.ts push --owner myorg --repo myrepo
 *   npx tsx main.ts sync --owner myorg --repo myrepo
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------

const {
  positionals: [command = "sync"],
  values: flags,
} = parseArgs({
  allowPositionals: true,
  options: {
    owner: { type: "string" },
    repo: { type: "string" },
    "dry-run": { type: "boolean", default: false },
  },
});

const OWNER = flags.owner ?? "";
const REPO = flags.repo ?? "";
const DRY_RUN = flags["dry-run"] ?? false;

if (!OWNER || !REPO) {
  console.error("Error: --owner and --repo are required.");
  process.exitCode = 1;
  process.exit();
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface GhLabel {
  id: number;
  name: string;
}

interface GhUser {
  login: string;
}

interface GhIssue {
  number: number;
  title: string;
  body: string | null;
  state: "open" | "closed";
  html_url: string;
  labels: GhLabel[];
  user: GhUser | null;
  created_at: string;
  updated_at: string;
  pull_request?: unknown;
}

/** pm item as returned by `pm list --json` */
interface PmItem {
  id: string;
  title: string;
  status: "open" | "in_progress" | "blocked" | "closed" | "canceled" | "draft";
  type: string;
  priority?: number;
  tags?: string[];
  blocked_by?: string;
  blocked_reason?: string;
  close_reason?: string;
  gh_number?: number;
}

// ---------------------------------------------------------------------------
// GitHub REST helpers
// ---------------------------------------------------------------------------

function ghToken(): string {
  const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? "";
  if (!token) {
    throw new Error(
      "No GitHub token found. Set GH_TOKEN or GITHUB_TOKEN in your environment."
    );
  }
  return token;
}

async function ghFetch<T>(path: string, init?: RequestInit): Promise<T> {
  const url = `https://api.github.com${path}`;
  const res = await fetch(url, {
    ...init,
    headers: {
      Authorization: `Bearer ${ghToken()}`,
      Accept: "application/vnd.github+json",
      "X-GitHub-Api-Version": "2022-11-28",
      "User-Agent": "pm-github-sync/0.1.0",
      "Content-Type": "application/json",
      ...(init?.headers ?? {}),
    },
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`GitHub API ${res.status} ${path}: ${text}`);
  }
  return res.json() as Promise<T>;
}

async function fetchAllIssues(state: "open" | "all" = "open"): Promise<GhIssue[]> {
  const issues: GhIssue[] = [];
  let page = 1;
  while (true) {
    const batch = await ghFetch<GhIssue[]>(
      `/repos/${OWNER}/${REPO}/issues?state=${state}&per_page=100&page=${page}`
    );
    const real = batch.filter((i) => !i.pull_request);
    issues.push(...real);
    if (batch.length < 100) break;
    page++;
  }
  return issues;
}

async function addIssueLabel(number: number, label: string): Promise<void> {
  if (DRY_RUN) {
    console.log(`  [dry-run] POST /repos/${OWNER}/${REPO}/issues/${number}/labels  label=${label}`);
    return;
  }
  await ghFetch(`/repos/${OWNER}/${REPO}/issues/${number}/labels`, {
    method: "POST",
    body: JSON.stringify({ labels: [label] }),
  });
}

async function removeIssueLabel(number: number, label: string): Promise<void> {
  if (DRY_RUN) {
    console.log(`  [dry-run] DELETE label=${label} from issue #${number}`);
    return;
  }
  try {
    await ghFetch(`/repos/${OWNER}/${REPO}/issues/${number}/labels/${encodeURIComponent(label)}`, {
      method: "DELETE",
    });
  } catch {
    // Label may not exist โ€” ignore
  }
}

async function postComment(number: number, body: string): Promise<void> {
  if (DRY_RUN) {
    console.log(`  [dry-run] POST comment on issue #${number}: "${body.slice(0, 80)}โ€ฆ"`);
    return;
  }
  await ghFetch(`/repos/${OWNER}/${REPO}/issues/${number}/comments`, {
    method: "POST",
    body: JSON.stringify({ body }),
  });
}

async function closeIssue(number: number): Promise<void> {
  if (DRY_RUN) {
    console.log(`  [dry-run] PATCH issue #${number} state=closed`);
    return;
  }
  await ghFetch(`/repos/${OWNER}/${REPO}/issues/${number}`, {
    method: "PATCH",
    body: JSON.stringify({ state: "closed" }),
  });
}

// ---------------------------------------------------------------------------
// pm-cli helpers
// ---------------------------------------------------------------------------

function pm(cmd: string): string {
  return execSync(`pm ${cmd}`, { encoding: "utf-8" }).trim();
}

function pmJson<T>(cmd: string): T {
  return JSON.parse(pm(`${cmd} --json`));
}

// ---------------------------------------------------------------------------
// Priority mapping: GitHub labels โ†’ pm priority (0=highest)
// ---------------------------------------------------------------------------

function labelsToPriority(labels: GhLabel[]): number {
  const names = labels.map((l) => l.name.toLowerCase());
  for (const name of names) {
    if (
      name.includes("priority: critical") ||
      name === "urgent" ||
      name === "p0"
    ) return 0;
    if (
      name.includes("priority: high") ||
      name === "high" ||
      name === "p1"
    ) return 1;
    if (
      name.includes("priority: low") ||
      name === "low" ||
      name === "p3"
    ) return 3;
  }
  return 2;
}

// ---------------------------------------------------------------------------
// Pull: GitHub Issues โ†’ pm items
// ---------------------------------------------------------------------------

async function pull(): Promise<void> {
  console.log(`Pulling issues from ${OWNER}/${REPO}โ€ฆ`);
  const issues = await fetchAllIssues("open");
  console.log(`  Fetched ${issues.length} open issue(s).`);

  let created = 0;
  let updated = 0;

  for (const issue of issues) {
    const idSuffix = `gh-issue-${issue.number}`;
    const priority = labelsToPriority(issue.labels);
    const tags = ["github", "gh-issue", ...issue.labels.map((l) => l.name)].join(",");

    // Check if a pm item with this gh-issue tag already exists
    const existing = pmJson<PmItem[]>(`list-all --tag ${idSuffix}`);

    if (existing.length > 0) {
      // Update existing item
      const pmId = existing[0].id;
      const cmd = [
        `update ${pmId}`,
        `--title "${issue.title.replace(/"/g, '\\"')}"`,
        `--priority ${priority}`,
        `--tags "${tags},${idSuffix}"`,
      ].join(" ");

      if (DRY_RUN) {
        console.log(`  [dry-run] pm ${cmd}`);
      } else {
        pm(cmd);
      }
      updated++;
    } else {
      // Create new pm item
      const body = (issue.body ?? "")
        .slice(0, 500)
        .replace(/"/g, '\\"');

      const cmd = [
        "create",
        `--type Issue`,
        `--title "${issue.title.replace(/"/g, '\\"')}"`,
        `--description "GitHub Issue #${issue.number} โ€” ${OWNER}/${REPO}"`,
        `--body "${body}"`,
        `--priority ${priority}`,
        `--tags "${tags},${idSuffix}"`,
        "--json",
      ].join(" ");

      if (DRY_RUN) {
        console.log(`  [dry-run] pm ${cmd}`);
      } else {
        pm(cmd);
      }
      created++;
    }
  }

  console.log(`  Done: ${created} created, ${updated} updated.`);
}

// ---------------------------------------------------------------------------
// Push: pm status changes โ†’ GitHub
// ---------------------------------------------------------------------------

async function push(): Promise<void> {
  console.log(`Pushing pm status changes to ${OWNER}/${REPO}โ€ฆ`);

  // Find all pm items tagged with gh-issue
  const pmItems = pmJson<PmItem[]>("list-all --tag gh-issue");
  console.log(`  Found ${pmItems.length} pm item(s) with gh-issue tag.`);

  let pushed = 0;

  for (const item of pmItems) {
    // Extract issue number from tag list (gh-issue-<number>)
    const tag = (item.tags ?? []).find((t) => t.startsWith("gh-issue-"));
    if (!tag) continue;
    const number = parseInt(tag.replace("gh-issue-", ""), 10);
    if (isNaN(number)) continue;

    switch (item.status) {
      case "in_progress":
        console.log(`  #${number} โ†’ in_progress`);
        await removeIssueLabel(number, "blocked");
        await addIssueLabel(number, "in-progress");
        await postComment(number, `**pm-cli update:** This issue is now \`in_progress\` (pm item: ${item.id}).`);
        pushed++;
        break;

      case "blocked":
        console.log(`  #${number} โ†’ blocked`);
        await removeIssueLabel(number, "in-progress");
        await addIssueLabel(number, "blocked");
        const reason = item.blocked_reason ?? item.blocked_by ?? "no reason specified";
        await postComment(number, `**pm-cli update:** This issue is \`blocked\`. Reason: ${reason}`);
        pushed++;
        break;

      case "closed": {
        console.log(`  #${number} โ†’ closed`);
        const closeReason = item.close_reason ?? "resolved";
        await postComment(number, `**pm-cli update:** Marked as closed in pm-cli. Reason: ${closeReason}`);
        await removeIssueLabel(number, "in-progress");
        await removeIssueLabel(number, "blocked");
        await closeIssue(number);
        pushed++;
        break;
      }

      case "canceled": {
        console.log(`  #${number} โ†’ canceled`);
        await postComment(number, `**pm-cli update:** This issue was canceled in pm-cli (not going to fix).`);
        await closeIssue(number);
        pushed++;
        break;
      }

      default:
        // open / draft โ€” no push action needed
        break;
    }
  }

  console.log(`  Pushed ${pushed} status update(s) to GitHub.`);
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  if (command === "pull") {
    await pull();
  } else if (command === "push") {
    await push();
  } else if (command === "sync") {
    await pull();
    await push();
  } else {
    console.error(`Unknown command: ${command}. Use pull, push, or sync.`);
    process.exitCode = 1;
  }
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub github-sync-workflow
๐ŸŒ

Open Source Project

Issue triage with milestone-based releases

Issue triage with severity levels, milestone-based release planning, and good-first-issue tagging for community contributors. Low friction for anonymous contributors.

Commands used
pm init pm searchpm aggregatepm learningspm close-task

Overview

This example shows how an open source maintainer uses pm-cli to manage an active project. It covers issue triage with severity levels, organizing feature requests under milestone-based releases, de-duplicating incoming issues, tagging items for community contributors (good-first-issue, help-wanted), and tracking community contributions through to merge.

When to use this workflow

  • You maintain an open source library, CLI tool, or framework
  • You receive a mix of bug reports and feature requests that need triage
  • You want to plan milestone-based releases (v1.1, v1.2, v2.0) with a clear work breakdown
  • You want to publicly signal which items are approachable for new contributors

Key commands used

Command Purpose
pm init Bootstrap the project
pm create --type Issue File an incoming bug or support request
pm create --type Feature Record a feature request
pm create --type Epic Group features into a release theme
pm create --type Milestone Define a versioned release
pm update <id> --tags Add labels like good-first-issue, help-wanted
pm update <id> --priority Triage: set severity
pm search "query" Find duplicates before creating new items
pm comments <id> "text" Triage notes, community updates, review feedback
pm close <id> "reason" Close a resolved or declined item
pm list-open See all open items for a triage session
pm stats Release completion overview

Step-by-step narrative

1. Initialize the project

cd ~/src/mylib
pm init LIB

2. Create upcoming release Milestones

Create Milestones for the planned releases. These act as the top-level containers for scoped work.

pm create --title "v1.1.0 โ€” Performance and stability" \
          --description "Focus: reduce cold-start time, fix top-5 reported bugs, improve error messages" \
          --type Milestone --priority 1 --deadline "2026-06-01"

pm create --title "v1.2.0 โ€” Plugin API" \
          --description "Public plugin interface for extending the tool without forking" \
          --type Milestone --priority 2 --deadline "2026-08-01"

3. Create release Epics under each Milestone

pm create --title "Performance improvements" \
          --description "Lazy loading, startup profiling, caching hot paths" \
          --type Epic --priority 1 --parent LIB-1

pm create --title "Plugin system" \
          --description "Stable public API for third-party plugins: hooks, extension points, docs" \
          --type Epic --priority 1 --parent LIB-2

4. Triage incoming bug reports as Issues

When a user reports a bug (via GitHub Issues, email, Discord), create it in pm-cli with enough context to reproduce and prioritize it.

pm create \
  --title "Cold start takes 4+ seconds on Linux with large configs" \
  --description "Severity: high. Repro: config file >500 lines, first run after install. Expected: <1s. Actual: 4.2s avg. Reported by: @user123 in #1847" \
  --type Issue \
  --priority 1 \
  --create-mode progressive

pm create \
  --title "Error message for missing required field shows internal stack trace" \
  --description "Severity: medium. Repro: omit required field from config. Expected: friendly error message. Actual: TypeError stack trace dumped to console. Reported by: @newcomer in #1851" \
  --type Issue \
  --priority 2 \
  --create-mode progressive

pm create \
  --title "Windows: path separator causes config load failure" \
  --description "Severity: high. Repro: any Windows path with backslashes. Expected: config loads. Actual: ENOENT error. Reported in #1853 by 3 users." \
  --type Issue \
  --priority 1 \
  --create-mode progressive

5. Check for duplicates before triaging further

pm search "cold start performance slow startup"
pm search "startup time too slow" --mode hybrid

Use search before creating more work. Review related items and close confirmed duplicates, linking them to the canonical item.

# If LIB-8 is a duplicate of LIB-5 (cold start):
pm comments LIB-8 "Duplicate of LIB-5. Closing."
pm close LIB-8 "Duplicate of LIB-5. Combined repro steps added there."

6. Tag items for community contributors

Issues and features marked good-first-issue are approachable for new contributors. help-wanted signals that maintainers welcome external PRs.

# The friendly-error-message issue is a good first issue
pm update LIB-6 --tags good-first-issue,help-wanted
pm comments LIB-6 \
  "Good first issue: the fix is in src/validation/messages.ts. Catch the TypeError and re-throw with a user-friendly message. See CONTRIBUTING.md for PR guidelines."

# The plugin system feature is more involved but help is welcome
pm update LIB-4 --tags help-wanted,RFC
pm comments LIB-4 \
  "RFC open: we are looking for community input on the plugin API surface before implementation starts. See DISCUSSION.md."

7. Link feature requests to release Epics

Feature requests that fit the v1.2 plugin Epic get parented to it.

pm create \
  --title "Plugin hook: pre-process config before validation" \
  --description "Allow plugins to transform config keys before the validation step. Requested in #1840, #1844, #1848." \
  --type Feature \
  --priority 2 \
  --parent LIB-4

pm create \
  --title "Plugin registry: list installed plugins in --version output" \
  --description "Show plugin name + version in the --version flag output for easier debugging." \
  --type Feature \
  --priority 3 \
  --parent LIB-4

8. Start and fix issues

When a maintainer (or community contributor) starts working on an issue, use pm start-task which claims the item and moves it to in_progress in one step:

pm start-task LIB-5   # Cold start โ€” lifecycle alias claims + moves to in_progress

pm comments LIB-5 \
  "Root cause: config parser was re-reading the file on every schema lookup. Fix: cache parsed schema on first read. PR in progress."

pm files LIB-5 --add path=src/config/parser.ts,scope=project
pm files LIB-5 --add path=tests/performance/startup.bench.ts,scope=project

pm close-task LIB-5 \
  "Fixed in PR #61. Schema now cached in memory after first parse. Cold start reduced from 4.2s to 0.4s in benchmarks. Ships in v1.1.0."

9. Track a community contribution

When an external contributor picks up a good-first-issue:

pm comments LIB-6 \
  "Community contribution: @newcontributor opened PR #63. Assigned for review."

pm start-task LIB-6   # maintainer takes review ownership

pm comments LIB-6 \
  "PR #63 reviewed. Left 3 minor comments (no blocking issues). LGTM after changes."

pm close-task LIB-6 \
  "Fixed by @newcontributor in PR #63. TypeError is now caught and re-thrown as 'Missing required field: <fieldName>'. Merged to main. Ships in v1.1.0."

10. Close the v1.1.0 Milestone on release

After all v1.1.0 items are closed and the release is tagged:

pm close LIB-3 "Performance Epic done. Cold start, Windows paths, and error messages all fixed."

pm close LIB-1 \
  "v1.1.0 released on 2026-06-01. Tag: v1.1.0. 3 bugs fixed, 2 community contributions merged. Release notes: CHANGELOG.md#v1.1.0"

pm comments LIB-1 \
  "Post-release: no critical regressions reported in first 48 hours. v1.1.1 patch window open for 2 weeks."

11. Regular maintenance: triage sessions

Run a triage session weekly to keep the backlog from growing unmanageable.

pm list-open
pm search "duplicate stale triage" --mode hybrid
pm health
pm stats

Look for items that are:

  • Stale (open for >30 days with no activity)
  • Missing descriptions or reproduction steps
  • Duplicates surfaced by search review

Add a triage comment and close or update as appropriate.

Show complete workflow script
open-source-project/workflow.sh
#!/usr/bin/env tsx
import { execFileSync } from "node:child_process";

function pm(args: string[]): void {
  execFileSync("pm", args, { stdio: "inherit" });
}

const milestone = process.env.PM_RELEASE ?? "v1.4";

pm(["search", "--mode", "hybrid", "good first issue"]);
pm(["search", "--mode", "hybrid", "duplicate cold start startup"]);
pm([
  "create",
  "--type",
  "Issue",
  "--title",
  "Contributor-friendly setup docs are missing Windows instructions",
  "--description",
  "Add Windows-specific setup notes and troubleshooting for common shell/path issues.",
  "--priority",
  "2",
  "--tags",
  "good-first-issue,docs,contributors",
  "--release",
  milestone,
]);
pm([
  "create",
  "--type",
  "Milestone",
  "--title",
  `${milestone} contributor experience cleanup`,
  "--description",
  "Track docs and onboarding fixes before the next public release.",
  "--priority",
  "2",
  "--release",
  milestone,
]);
pm(["aggregate", "--group-by", "release"]);
pm(["calendar", "--view", "month", "--release", milestone]);
View source on GitHub open-source-project
๐Ÿš€

Release Manager

Calculate release readiness and generate a Go/No-Go decision

TypeScript script that fetches sprint items, calculates release readiness per feature, checks P1 completion, surfaces blockers, and outputs a Go/No-Go decision with a generated release notes draft.

Commands used
pm init pm list-all --sprintpm list-all --type MilestoneGo/No-Go decisionrelease notesTypeScript

Assess sprint release readiness, generate draft release notes, and get a data-driven Go/No-Go decision โ€” all from a single command.

What this does

  • Fetches all Milestone and Feature items for a named sprint
  • Calculates release readiness percentage (closed / total items)
  • Shows per-feature task completion with ASCII progress bars
  • Checks P1 item completion as the primary Go/No-Go gate
  • Surfaces all blocked items as release blockers
  • Generates a draft release notes document from closed Features and Bugs
  • Outputs a color-coded terminal report with a final GO / NO-GO verdict

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+

Setup

cd release-manager
npm install

Usage

# Assess Sprint 12 release readiness
npx tsx main.ts --sprint "Sprint 12"

# Include a version label in the output
npx tsx main.ts --sprint "Sprint 12" --version "v2.3.0"

# Dry-run (report only, no writes)
npx tsx main.ts --sprint "Sprint 12" --dry-run

Environment variables

Variable Default Description
(none) โ€” All configuration is via CLI flags

CLI flags

Flag Required Description
--sprint yes Sprint name to assess (e.g. "Sprint 12")
--version no Release version label (e.g. "v2.3.0")
--dry-run no Print report only, make no changes

Go/No-Go logic

The script outputs NO-GO if any of the following are true:

  1. One or more P1 items are not closed/canceled
  2. Any item in the sprint is in blocked status

Otherwise it outputs GO.

Example output

Release Manager โ€” Sprint 12  /  v2.3.0
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Overall Readiness
  [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘] 80%  (16/20 items closed)

P1 Item Status
  โœ“  APP-03  Implement OAuth callback handler  [closed]
  โœ—  APP-07  Payment gateway rate limiting     [blocked]

Feature Readiness
  ID           Feature                              Tasks      Readiness
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  APP-F1       User Authentication                  5/5        [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] 100%
  APP-F2       Billing Integration                  3/5        [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 60%

Blockers
  โ€ข APP-07 "Payment gateway rate limiting" โ€” waiting on vendor API key

Release Notes Draft
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Release Notes โ€” v2.3.0
Sprint: Sprint 12
Date: 2026-05-10

## What's New

- **User Authentication** (APP-F1)
...

Decision: NO-GO for v2.3.0
  Resolve 1 blocker(s) before releasing.
Show complete workflow script
release-manager/workflow.sh
/**
 * release-manager โ€” Release readiness assessment with pm-cli
 *
 * Lists all Milestone/Feature items for a sprint, calculates release readiness,
 * generates a draft release notes from closed Features, and outputs a Go/No-Go
 * decision based on P1 item completion.
 *
 * Usage:
 *   npx tsx main.ts --sprint "Sprint 12"
 *   npx tsx main.ts --sprint "Sprint 12" --version "v2.3.0"
 *   npx tsx main.ts --sprint "Sprint 12" --dry-run
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    sprint: { type: "string" },
    version: { type: "string" },
    "dry-run": { type: "boolean", default: false },
  },
});

const SPRINT_NAME = args.sprint ?? "";
const RELEASE_VERSION = args.version ?? "next";
const DRY_RUN = args["dry-run"] ?? false;

if (!SPRINT_NAME) {
  console.error("Error: --sprint is required. Example: --sprint \"Sprint 12\"");
  process.exitCode = 1;
  process.exit();
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A pm item as returned by `pm list --json`. */
interface PmItem {
  id: string;
  title: string;
  status: "open" | "in_progress" | "blocked" | "draft" | "closed" | "canceled";
  type: string;
  priority?: number;
  estimated_minutes?: number;
  sprint?: string;
  assignee?: string;
  close_reason?: string;
  blocked_reason?: string;
  blocked_by?: string;
  tags?: string[];
  created_at: string;
  closed_at?: string;
}

interface FeatureGroup {
  feature: PmItem;
  tasks: PmItem[];
  closedTasks: number;
  totalTasks: number;
  readinessPct: number;
}

interface ReleaseReport {
  sprint: string;
  version: string;
  milestones: PmItem[];
  features: FeatureGroup[];
  allItems: PmItem[];
  closedCount: number;
  openCount: number;
  blockedCount: number;
  p1Items: PmItem[];
  p1ClosedCount: number;
  overallReadinessPct: number;
  goNoGo: "GO" | "NO-GO";
  blockers: string[];
}

// ---------------------------------------------------------------------------
// pm-cli helpers
// ---------------------------------------------------------------------------

function pm(cmd: string): string {
  return execSync(`pm ${cmd}`, { encoding: "utf-8" }).trim();
}

function pmJson<T>(cmd: string): T {
  return JSON.parse(pm(`${cmd} --json`));
}

// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------

/** Fetch all items for the given sprint. */
function fetchSprintItems(sprintName: string): PmItem[] {
  try {
    return pmJson<PmItem[]>(`list-all --sprint "${sprintName}"`);
  } catch {
    // Fallback: get all items and filter client-side
    const all = pmJson<PmItem[]>("list-all");
    return all.filter((item) => item.sprint === sprintName);
  }
}

/** Group feature items with their child tasks. */
function groupFeatures(items: PmItem[]): FeatureGroup[] {
  const features = items.filter((i) => i.type === "Feature");
  const tasksByParent = new Map<string, PmItem[]>();

  for (const item of items) {
    if (item.type === "Task" || item.type === "Bug" || item.type === "Issue") {
      const parentId = (item as PmItem & { parent?: string }).parent;
      if (parentId) {
        if (!tasksByParent.has(parentId)) tasksByParent.set(parentId, []);
        tasksByParent.get(parentId)!.push(item);
      }
    }
  }

  return features.map((feature) => {
    const tasks = tasksByParent.get(feature.id) ?? [];
    const closedTasks = tasks.filter(
      (t) => t.status === "closed" || t.status === "canceled"
    ).length;
    const totalTasks = tasks.length;
    const readinessPct =
      totalTasks > 0
        ? Math.round((closedTasks / totalTasks) * 100)
        : feature.status === "closed"
        ? 100
        : 0;

    return { feature, tasks, closedTasks, totalTasks, readinessPct };
  });
}

// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------

function analyzeRelease(sprintName: string, version: string): ReleaseReport {
  const allItems = fetchSprintItems(sprintName);

  const milestones = allItems.filter((i) => i.type === "Milestone");
  const featureGroups = groupFeatures(allItems);

  const closedCount = allItems.filter(
    (i) => i.status === "closed" || i.status === "canceled"
  ).length;
  const blockedCount = allItems.filter((i) => i.status === "blocked").length;
  const openCount = allItems.length - closedCount;

  // P1 items (priority 1 = high)
  const p1Items = allItems.filter(
    (i) => (i.priority ?? 2) <= 1 && i.type !== "Milestone"
  );
  const p1ClosedCount = p1Items.filter(
    (i) => i.status === "closed" || i.status === "canceled"
  ).length;

  const overallReadinessPct =
    allItems.length > 0
      ? Math.round((closedCount / allItems.length) * 100)
      : 0;

  // Collect blockers narrative
  const blockers: string[] = allItems
    .filter((i) => i.status === "blocked")
    .map((i) => {
      const reason = i.blocked_reason ?? i.blocked_by ?? "no reason specified";
      return `${i.id} "${i.title}" โ€” ${reason}`;
    });

  // P1 incomplete items also surface as blockers for Go/No-Go
  const incompleteP1 = p1Items.filter(
    (i) => i.status !== "closed" && i.status !== "canceled"
  );
  for (const item of incompleteP1) {
    if (!blockers.some((b) => b.startsWith(item.id))) {
      blockers.push(`P1 incomplete: ${item.id} "${item.title}" (${item.status})`);
    }
  }

  const goNoGo: "GO" | "NO-GO" =
    p1Items.length > 0 && p1ClosedCount < p1Items.length
      ? "NO-GO"
      : blockedCount > 0
      ? "NO-GO"
      : "GO";

  return {
    sprint: sprintName,
    version,
    milestones,
    features: featureGroups,
    allItems,
    closedCount,
    openCount,
    blockedCount,
    p1Items,
    p1ClosedCount,
    overallReadinessPct,
    goNoGo,
    blockers,
  };
}

// ---------------------------------------------------------------------------
// Release notes generation
// ---------------------------------------------------------------------------

function generateReleaseNotes(report: ReleaseReport): string {
  const lines: string[] = [
    `# Release Notes โ€” ${report.version}`,
    `Sprint: ${report.sprint}`,
    `Date: ${new Date().toISOString().slice(0, 10)}`,
    "",
    "## What's New",
    "",
  ];

  const closedFeatures = report.features.filter(
    (fg) => fg.feature.status === "closed"
  );

  if (closedFeatures.length === 0) {
    lines.push("_No features closed in this sprint._");
  } else {
    for (const { feature } of closedFeatures) {
      lines.push(`- **${feature.title}** (${feature.id})`);
    }
  }

  // Closed bugs / issues
  const closedIssues = report.allItems.filter(
    (i) =>
      (i.type === "Bug" || i.type === "Issue") &&
      (i.status === "closed" || i.status === "canceled")
  );

  if (closedIssues.length > 0) {
    lines.push("");
    lines.push("## Bug Fixes");
    lines.push("");
    for (const issue of closedIssues) {
      lines.push(`- ${issue.title} (${issue.id})`);
    }
  }

  lines.push("");
  lines.push("## Stats");
  lines.push("");
  lines.push(`- Items closed: ${report.closedCount} / ${report.allItems.length}`);
  lines.push(`- Release readiness: ${report.overallReadinessPct}%`);
  lines.push(`- P1 items: ${report.p1ClosedCount} / ${report.p1Items.length} closed`);

  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Terminal output
// ---------------------------------------------------------------------------

const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const CYAN = "\x1b[36m";
const DIM = "\x1b[2m";

function colorStatus(status: PmItem["status"]): string {
  switch (status) {
    case "closed":
    case "canceled":
      return `${GREEN}${status}${RESET}`;
    case "blocked":
      return `${RED}${status}${RESET}`;
    case "in_progress":
      return `${CYAN}in_progress${RESET}`;
    default:
      return `${DIM}${status}${RESET}`;
  }
}

function progressBar(pct: number, width = 20): string {
  const filled = Math.round((pct / 100) * width);
  const empty = width - filled;
  return `[${"โ–ˆ".repeat(filled)}${"โ–‘".repeat(empty)}] ${pct}%`;
}

function printReport(report: ReleaseReport, releaseNotes: string): void {
  console.log();
  console.log(
    `${BOLD}Release Manager โ€” ${report.sprint}  /  ${report.version}${RESET}`
  );
  console.log("โ”€".repeat(60));
  console.log();

  // Overall readiness
  console.log(`${BOLD}Overall Readiness${RESET}`);
  const barColor =
    report.overallReadinessPct >= 80
      ? GREEN
      : report.overallReadinessPct >= 50
      ? YELLOW
      : RED;
  console.log(
    `  ${barColor}${progressBar(report.overallReadinessPct)}${RESET}  (${report.closedCount}/${report.allItems.length} items closed)`
  );
  console.log();

  // P1 summary
  console.log(`${BOLD}P1 Item Status${RESET}`);
  if (report.p1Items.length === 0) {
    console.log(`  ${DIM}No P1 items in this sprint.${RESET}`);
  } else {
    const p1Color =
      report.p1ClosedCount === report.p1Items.length ? GREEN : RED;
    console.log(
      `  ${p1Color}${report.p1ClosedCount} / ${report.p1Items.length} P1 items closed${RESET}`
    );
    for (const item of report.p1Items) {
      const indicator =
        item.status === "closed" || item.status === "canceled" ? "โœ“" : "โœ—";
      const color =
        item.status === "closed" || item.status === "canceled" ? GREEN : RED;
      console.log(
        `  ${color}${indicator}${RESET}  ${item.id}  ${item.title.slice(0, 45)}  [${colorStatus(item.status)}]`
      );
    }
  }
  console.log();

  // Feature breakdown
  if (report.features.length > 0) {
    console.log(`${BOLD}Feature Readiness${RESET}`);
    const col = (s: string, w: number) => s.slice(0, w).padEnd(w);
    console.log(
      `  ${col("ID", 12)} ${col("Feature", 36)} ${col("Tasks", 10)} ${"Readiness"}`
    );
    console.log(
      `  ${"โ”€".repeat(12)} ${"โ”€".repeat(36)} ${"โ”€".repeat(10)} ${"โ”€".repeat(20)}`
    );
    for (const { feature, closedTasks, totalTasks, readinessPct } of report.features) {
      const tasksSummary =
        totalTasks > 0 ? `${closedTasks}/${totalTasks}` : "โ€”";
      const barC =
        readinessPct === 100 ? GREEN : readinessPct >= 50 ? YELLOW : RED;
      console.log(
        `  ${col(feature.id, 12)} ${col(feature.title, 36)} ${col(tasksSummary, 10)} ${barC}${progressBar(readinessPct, 12)}${RESET}`
      );
    }
    console.log();
  }

  // Blockers
  if (report.blockers.length > 0) {
    console.log(`${BOLD}${RED}Blockers${RESET}`);
    for (const b of report.blockers) {
      console.log(`  ${RED}โ€ข${RESET} ${b}`);
    }
    console.log();
  }

  // Release Notes draft
  console.log(`${BOLD}Release Notes Draft${RESET}`);
  console.log("โ”€".repeat(60));
  console.log(releaseNotes);
  console.log("โ”€".repeat(60));
  console.log();

  // Go/No-Go decision
  const goColor = report.goNoGo === "GO" ? GREEN : RED;
  console.log(
    `${BOLD}Decision: ${goColor}${report.goNoGo}${RESET}${BOLD} for ${report.version}${RESET}`
  );
  if (report.goNoGo === "NO-GO") {
    console.log(
      `  ${RED}Resolve ${report.blockers.length} blocker(s) before releasing.${RESET}`
    );
  }
  console.log();

  if (DRY_RUN) {
    console.log(`${DIM}(dry-run: no changes written)${RESET}`);
  }
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  console.log(`Analyzing release readiness for "${SPRINT_NAME}" โ†’ ${RELEASE_VERSION}โ€ฆ`);

  const report = analyzeRelease(SPRINT_NAME, RELEASE_VERSION);
  console.log(`  ${report.allItems.length} items found in sprint.`);

  const releaseNotes = generateReleaseNotes(report);
  printReport(report, releaseNotes);
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub release-manager
๐Ÿง‘โ€๐Ÿ’ป

Solo Dev Workflow

Personal SaaS from planning to daily dev loops

Track a personal SaaS with hierarchical work breakdown (Epics โ†’ Features โ†’ Tasks), context switching with pm context, and a lightweight decision log using comments.

Commands used
pm init pm createpm contextpm start-taskpm notespm learningspm testpm close-task

Overview

This example shows how a solo developer can use pm-cli to manage a personal SaaS project from initial planning through daily development loops. It covers project initialization, hierarchical work breakdown using Epics, Features, and Tasks, maintaining focus during a work session with pm context, and recording decisions so you have a log of why things were built the way they were.

When to use this workflow

  • You are working alone on a product (side project, indie SaaS, personal tool)
  • You want lightweight structure without the overhead of a full team process
  • You need to context-switch frequently and want a quick way to re-orient
  • You want a single source of truth for what is done, what is in flight, and what is next

Key commands used

Command Purpose
pm init Bootstrap the project's .agents/pm/ directory
pm create Create an Epic, Feature, Task, or Issue
pm context Print the current project context (open/in-progress items)
pm list-open List all open items
pm start-task <id> Lifecycle alias: claim + move to in_progress
pm update <id> Edit fields on an existing item
pm comments <id> "text" Leave a progress note or decision record
pm close-task <id> "reason" Lifecycle alias: close with reason + release assignment
pm close <id> "reason" Mark an item done with a closing summary
pm files <id> --add Attach source files to a task
pm docs <id> --add Attach documentation references
pm history <id> View the full history of changes to an item
pm stats Show project-level health metrics
pm health Run validation checks

Step-by-step narrative

1. Bootstrap the project

Every pm-cli project starts with pm init. The optional prefix keeps IDs recognizable.

cd ~/projects/mysaas
pm init SAAS

This creates .agents/pm/ with settings, item folders, history, locks, and search caches. All subsequent commands read configuration from this directory.

2. Plan the first quarter with Epics

Epics represent large, multi-week goals. Create one Epic per major product area so you can track progress at a glance without drowning in task detail.

pm create --title "User Authentication" \
          --description "Full auth system: signup, login, password reset, OAuth" \
          --type Epic --priority 1

pm create --title "Billing and Subscriptions" \
          --description "Stripe integration, plan management, invoices" \
          --type Epic --priority 2

pm create --title "Core Dashboard" \
          --description "Main product UI: overview, settings, onboarding" \
          --type Epic --priority 1

3. Break an Epic into Features

Features are user-facing capabilities that fit inside a sprint or two. Link them back to their parent Epic so rollups work correctly.

# Get the ID that was printed when you created the Auth epic (e.g. SAAS-1)
pm create --title "Email/password signup and login" \
          --description "Registration form, hashed passwords, JWT session tokens" \
          --type Feature --priority 1 --parent SAAS-1

pm create --title "Password reset via email" \
          --description "Forgot-password flow with time-limited tokens" \
          --type Feature --priority 2 --parent SAAS-1

pm create --title "Google OAuth" \
          --description "Sign in with Google, link to existing accounts" \
          --type Feature --priority 2 --parent SAAS-1

4. Decompose a Feature into Tasks

Tasks are the atomic units you actually implement. Keep them small enough to close in a single session.

pm create --title "Create users table and migration" \
          --description "Schema: id, email, password_hash, created_at, verified_at" \
          --type Task --priority 1 --parent SAAS-4

pm create --title "POST /auth/register endpoint" \
          --description "Validate input, hash password, insert user, return JWT" \
          --type Task --priority 1 --parent SAAS-4

pm create --title "POST /auth/login endpoint" \
          --description "Verify credentials, return JWT, set secure cookie" \
          --type Task --priority 1 --parent SAAS-4

pm create --title "Auth middleware for protected routes" \
          --description "Decode JWT, attach user to request context, 401 on failure" \
          --type Task --priority 1 --parent SAAS-4

5. Start a work session with context

At the beginning of each day or after switching context, run pm context to see exactly where things stand. It prints in-progress items first, then open items ranked by priority.

pm context

This answers "what was I doing?" and "what should I do next?" in one command.

6. Start a task

pm start-task is the lifecycle alias that claims the item and moves it to in_progress in a single step. This prevents you from accidentally starting two things at once.

pm start-task SAAS-7   # "Create users table and migration"

7. Record decisions as you go

Use pm comments to leave a note any time you make a non-obvious decision. This turns your task list into a lightweight decision log.

pm comments SAAS-7 "Using pgcrypto gen_random_uuid() for IDs instead of serial ints โ€” easier to shard later"
pm comments SAAS-7 "Added soft-delete column (deleted_at) after reading about GDPR requirements"

8. Attach the files you touch

Linking source files to the task creates a permanent trail of what changed where.

pm files SAAS-7 --add path=db/migrations/001_create_users.sql,scope=project
pm files SAAS-7 --add path=src/models/user.ts,scope=project

9. Close the task with a summary

When the work is done and tested, close the task using pm close-task, the lifecycle alias that records the reason and releases assignment metadata. The closing reason becomes part of the permanent record.

pm close-task SAAS-7 "Migration applied to dev and staging. uuid-ossp extension enabled. All existing tests pass."

10. Review item history

Use pm history to see the full audit trail of everything that happened to an item: creation, comments, status changes, file links, and closure.

pm history SAAS-7

This shows all mutations with timestamps and RFC6902 patches โ€” useful for reconstructing why something was done months later.

11. Repeat for the remaining tasks

Start-task, comment, attach files, close-task. After finishing all tasks under SAAS-4:

pm close SAAS-4 "Email/password auth fully working. Login returns HttpOnly JWT cookie. Integration tests green."

12. Record a milestone or architectural decision

Use a Feature or a high-priority Task as a milestone marker. Leave a detailed comment when you hit a significant checkpoint.

pm create --title "Milestone: auth system production-ready" \
          --description "All auth features shipped and smoke-tested in staging" \
          --type Task --priority 1 --parent SAAS-1

pm comments SAAS-15 "Decided to use short-lived access tokens (15 min) + refresh tokens stored in Redis. Revisit if Redis becomes a cost concern."

pm close-task SAAS-15 "Auth system live on staging. Penetration test scheduled for next week."

13. Check project health at end of week

pm stats
pm health
pm validate

pm stats shows counts by status and type. pm health warns about stale in-progress items or items missing descriptions. pm validate catches structural problems like orphaned children.

Show complete workflow script
solo-dev-workflow/workflow.sh
#!/usr/bin/env tsx
import { execFileSync } from "node:child_process";

function pm(args: string[]): void {
  execFileSync("pm", args, { stdio: "inherit" });
}

const prefix = process.env.PM_ITEM_PREFIX ?? "APP";
const taskId = process.env.PM_TASK_ID ?? `${prefix}-3`;

pm([
  "create",
  "--type",
  "Epic",
  "--title",
  "Ship billing dashboard",
  "--description",
  "End-to-end solo developer workflow for planning, implementation, verification, and learnings.",
  "--priority",
  "1",
  "--tags",
  "billing,solo",
]);
pm([
  "create",
  "--type",
  "Task",
  "--title",
  "Add invoice status filters",
  "--description",
  "Filter paid, open, overdue, and failed invoices on the dashboard.",
  "--priority",
  "1",
  "--tags",
  "billing,frontend",
]);
pm(["context", "--depth", "full"]);
pm(["start-task", taskId]);
pm(["notes", taskId, "--add", "Implementation note: keep filters URL-addressable for support links."]);
pm(["test", taskId, "--add", "--command", "npm test -- invoice-filters", "--description", "Invoice filter regression tests"]);
pm(["learnings", taskId, "--add", "URL-backed filters reduce support handoff friction."]);
pm(["close-task", taskId, "Invoice filters implemented, tested, and documented."]);
View source on GitHub solo-dev-workflow
๐Ÿ“ˆ

Sprint Velocity Tracker

Measure velocity across sprints and project next-sprint capacity

Reads closed items from the last N sprints, calculates velocity in hours or story points, renders an ASCII bar chart, and projects next sprint capacity as an average ยฑ one standard deviation.

Commands used
pm init pm list-all --type Milestonepm list-all --sprintvelocity chartcapacity planningTypeScript

Measure your team's sprint velocity over time, visualize trends with an ASCII chart, and project realistic capacity for the next sprint.

What this does

  • Reads Sprint Milestones from pm and fetches their closed items
  • Calculates velocity per sprint in estimated hours or story points completed
  • Computes average velocity, standard deviation, and trend direction
  • Renders an ASCII bar chart so you can spot acceleration or slowdown at a glance
  • Projects next sprint capacity as a range (average ยฑ 1 std dev)

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+
  • Sprint items should have estimated_minutes set for hour-based velocity, or story_points for point-based velocity

Setup

cd sprint-velocity-tracker
npm install

Usage

# Default: last 3 sprints, velocity in hours
npx tsx main.ts

# Last 5 sprints
SPRINTS_BACK=5 npx tsx main.ts

# Use story points instead of hours
npx tsx main.ts --unit points

# Custom sprint count
npx tsx main.ts --sprints 6 --unit points

Environment variables

Variable Default Description
SPRINTS_BACK 3 Number of past sprints to include

CLI flags

Flag Default Description
--sprints 3 (or $SPRINTS_BACK) Number of past sprints to analyze
--unit hours Velocity unit: hours or points

Example output

Sprint Velocity Tracker  (last 3 sprint(s), unit: h)
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

  Sprint               Items   Closed  Est h    Done h   Velocity
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  Sprint 10            18      14      36h      28.5h    28.5h
  Sprint 11            22      19      44h      37.0h    37h
  Sprint 12            20      16      40h      32.0h    32h

Summary
  Average velocity : 32.5h
  Std deviation    : ยฑ3.6h
  Trend            : โ†’ stable

Velocity Chart

   37 โ”‚       โ–ˆโ–ˆโ–ˆโ–ˆ
   30 โ”‚  โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ
   22 โ”‚  โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ
   15 โ”‚  โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ
    7 โ”‚  โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ
    0 โ”‚  โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
        Sprint Sprint Sprint
          10    11    12

  Unit: h completed per sprint

Next Sprint Projection
  Based on 3 sprint(s), plan for:
  32.5h of work  (range: 28.9โ€“36.1h)
Show complete workflow script
sprint-velocity-tracker/workflow.sh
/**
 * sprint-velocity-tracker โ€” Track and project sprint velocity with pm-cli
 *
 * Reads closed items from the last N sprints, calculates velocity per sprint
 * (story points / estimated hours completed), draws an ASCII velocity chart,
 * and projects capacity for the next sprint.
 *
 * Usage:
 *   npx tsx main.ts
 *   SPRINTS_BACK=5 npx tsx main.ts
 *   npx tsx main.ts --sprints 5 --unit hours
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args / env
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    sprints: { type: "string" },
    unit: { type: "string", default: "hours" }, // "hours" | "points"
    chart: { type: "boolean", default: true },
  },
});

const SPRINTS_BACK = parseInt(
  args.sprints ?? process.env.SPRINTS_BACK ?? "3",
  10
);
const UNIT: "hours" | "points" = args.unit === "points" ? "points" : "hours";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A pm item as returned by `pm list --json`. */
interface PmItem {
  id: string;
  title: string;
  status: "open" | "in_progress" | "blocked" | "draft" | "closed" | "canceled";
  type: string;
  priority?: number;
  estimated_minutes?: number;
  story_points?: number;
  sprint?: string;
  assignee?: string;
  created_at: string;
  closed_at?: string;
}

/** A pm Milestone representing a sprint. */
interface SprintMilestone {
  id: string;
  title: string;
  status: PmItem["status"];
  created_at: string;
  closed_at?: string;
}

/** Velocity data for one sprint. */
interface SprintVelocity {
  name: string;
  totalItems: number;
  closedItems: number;
  estimatedHours: number;
  completedHours: number;
  storyPoints: number;
  completedPoints: number;
  velocity: number; // depends on UNIT
  velocityUnit: string;
}

// ---------------------------------------------------------------------------
// pm-cli helpers
// ---------------------------------------------------------------------------

function pm(cmd: string): string {
  return execSync(`pm ${cmd}`, { encoding: "utf-8" }).trim();
}

function pmJson<T>(cmd: string): T {
  return JSON.parse(pm(`${cmd} --json`));
}

// ---------------------------------------------------------------------------
// Sprint discovery
// ---------------------------------------------------------------------------

/** Fetch sprint Milestones and return the most recent N closed ones. */
function fetchRecentSprints(n: number): SprintMilestone[] {
  let milestones: PmItem[];
  try {
    milestones = pmJson<PmItem[]>("list-all --type Milestone");
  } catch {
    milestones = pmJson<PmItem[]>("list-all").filter(
      (i) => i.type === "Milestone"
    );
  }

  // Sort by created_at descending, take the most recent N
  return milestones
    .sort(
      (a, b) =>
        new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
    )
    .slice(0, n) as SprintMilestone[];
}

// ---------------------------------------------------------------------------
// Velocity calculation
// ---------------------------------------------------------------------------

/** Fetch all closed items for a sprint by sprint name tag. */
function fetchClosedItems(sprintName: string): PmItem[] {
  let items: PmItem[];
  try {
    items = pmJson<PmItem[]>(`list-all --sprint "${sprintName}"`);
  } catch {
    items = pmJson<PmItem[]>("list-all").filter(
      (i) => i.sprint === sprintName
    );
  }
  return items.filter(
    (i) =>
      (i.status === "closed" || i.status === "canceled") &&
      !["Milestone", "Epic"].includes(i.type)
  );
}

function calcVelocity(sprint: SprintMilestone): SprintVelocity {
  const allSprintItems: PmItem[] = (() => {
    try {
      return pmJson<PmItem[]>(`list-all --sprint "${sprint.title}"`);
    } catch {
      return pmJson<PmItem[]>("list-all").filter(
        (i) => i.sprint === sprint.title
      );
    }
  })().filter((i) => !["Milestone", "Epic"].includes(i.type));

  const closed = allSprintItems.filter(
    (i) => i.status === "closed" || i.status === "canceled"
  );

  const estimatedHours = allSprintItems.reduce(
    (sum, i) => sum + (i.estimated_minutes ?? 0) / 60,
    0
  );
  const completedHours = closed.reduce(
    (sum, i) => sum + (i.estimated_minutes ?? 0) / 60,
    0
  );

  const storyPoints = allSprintItems.reduce(
    (sum, i) => sum + (i.story_points ?? 0),
    0
  );
  const completedPoints = closed.reduce(
    (sum, i) => sum + (i.story_points ?? 0),
    0
  );

  const velocity =
    UNIT === "points"
      ? completedPoints
      : Math.round(completedHours * 10) / 10;

  return {
    name: sprint.title,
    totalItems: allSprintItems.length,
    closedItems: closed.length,
    estimatedHours: Math.round(estimatedHours * 10) / 10,
    completedHours: Math.round(completedHours * 10) / 10,
    storyPoints,
    completedPoints,
    velocity,
    velocityUnit: UNIT === "points" ? "pts" : "h",
  };
}

// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------

function average(nums: number[]): number {
  if (nums.length === 0) return 0;
  return nums.reduce((s, n) => s + n, 0) / nums.length;
}

function stddev(nums: number[]): number {
  if (nums.length < 2) return 0;
  const avg = average(nums);
  const variance = nums.reduce((s, n) => s + Math.pow(n - avg, 2), 0) / nums.length;
  return Math.sqrt(variance);
}

function trend(nums: number[]): "up" | "down" | "stable" {
  if (nums.length < 2) return "stable";
  const first = average(nums.slice(0, Math.ceil(nums.length / 2)));
  const last = average(nums.slice(Math.floor(nums.length / 2)));
  const delta = last - first;
  const threshold = average(nums) * 0.05; // 5% threshold
  if (delta > threshold) return "up";
  if (delta < -threshold) return "down";
  return "stable";
}

// ---------------------------------------------------------------------------
// ASCII chart
// ---------------------------------------------------------------------------

const CHART_HEIGHT = 8;
const CHART_WIDTH = 6; // chars per sprint bar

function drawVelocityChart(sprints: SprintVelocity[]): string {
  if (sprints.length === 0) return "(no data)";

  const velocities = sprints.map((s) => s.velocity);
  const maxV = Math.max(...velocities, 1);

  const lines: string[] = [];
  const unit = sprints[0].velocityUnit;

  // Y-axis + bars
  for (let row = CHART_HEIGHT; row >= 0; row--) {
    const threshold = (row / CHART_HEIGHT) * maxV;
    let line = "";

    // Y-axis label (every other row)
    if (row % 2 === 0) {
      const label = String(Math.round(threshold)).padStart(4);
      line += `${label} โ”‚`;
    } else {
      line += `     โ”‚`;
    }

    for (const s of sprints) {
      const barHeight = (s.velocity / maxV) * CHART_HEIGHT;
      if (barHeight >= row) {
        line += " โ–ˆโ–ˆโ–ˆโ–ˆ ";
      } else if (row === 0) {
        line += " โ”€โ”€โ”€โ”€โ”€";
      } else {
        line += "      ";
      }
    }
    lines.push(line);
  }

  // X-axis baseline
  lines.push(`     โ””${"โ”€โ”€โ”€โ”€โ”€โ”€".repeat(sprints.length)}`);

  // Sprint labels
  const labelLine =
    "       " +
    sprints.map((s) => s.name.slice(-6).padEnd(6)).join("");
  lines.push(labelLine);

  lines.push(`\n  Unit: ${unit} completed per sprint`);
  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Terminal output
// ---------------------------------------------------------------------------

const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const CYAN = "\x1b[36m";
const DIM = "\x1b[2m";
const RED = "\x1b[31m";

function trendArrow(t: "up" | "down" | "stable"): string {
  if (t === "up") return `${GREEN}โ†‘ improving${RESET}`;
  if (t === "down") return `${RED}โ†“ declining${RESET}`;
  return `${YELLOW}โ†’ stable${RESET}`;
}

function printVelocityReport(sprints: SprintVelocity[]): void {
  const velocities = sprints.map((s) => s.velocity);
  const avg = average(velocities);
  const sd = stddev(velocities);
  const t = trend(velocities);
  const projected = Math.round(avg * 10) / 10;
  const unit = sprints[0]?.velocityUnit ?? "h";

  console.log();
  console.log(`${BOLD}Sprint Velocity Tracker${RESET}  (last ${sprints.length} sprint(s), unit: ${unit})`);
  console.log("โ”€".repeat(60));
  console.log();

  // Sprint table
  const col = (s: string, w: number) => s.slice(0, w).padEnd(w);
  console.log(
    `  ${col("Sprint", 20)} ${col("Items", 7)} ${col("Closed", 7)} ${col(`${UNIT === "points" ? "Pts" : "Est h"}`, 8)} ${col(`Done ${unit}`, 8)} ${BOLD}Velocity${RESET}`
  );
  console.log(
    `  ${"โ”€".repeat(20)} ${"โ”€".repeat(7)} ${"โ”€".repeat(7)} ${"โ”€".repeat(8)} ${"โ”€".repeat(8)} ${"โ”€".repeat(10)}`
  );

  for (const s of sprints) {
    const completionPct =
      s.totalItems > 0 ? Math.round((s.closedItems / s.totalItems) * 100) : 0;
    const pctColor =
      completionPct >= 80 ? GREEN : completionPct >= 50 ? YELLOW : RED;
    const estDisplay =
      UNIT === "points"
        ? String(s.storyPoints)
        : `${s.estimatedHours}h`;
    const doneDisplay =
      UNIT === "points"
        ? String(s.completedPoints)
        : `${s.completedHours}h`;
    console.log(
      `  ${col(s.name, 20)} ${col(String(s.totalItems), 7)} ${col(`${pctColor}${s.closedItems}${RESET}`, 7 + 9)} ${col(estDisplay, 8)} ${col(doneDisplay, 8)} ${CYAN}${s.velocity}${unit}${RESET}`
    );
  }
  console.log();

  // Stats summary
  console.log(`${BOLD}Summary${RESET}`);
  console.log(`  Average velocity : ${CYAN}${Math.round(avg * 10) / 10}${unit}${RESET}`);
  console.log(`  Std deviation    : ${DIM}ยฑ${Math.round(sd * 10) / 10}${unit}${RESET}`);
  console.log(`  Trend            : ${trendArrow(t)}`);
  console.log();

  // ASCII chart
  if (args.chart !== false && sprints.length > 0) {
    console.log(`${BOLD}Velocity Chart${RESET}`);
    console.log();
    console.log(drawVelocityChart(sprints));
    console.log();
  }

  // Next sprint projection
  console.log(`${BOLD}Next Sprint Projection${RESET}`);
  const lo = Math.max(0, Math.round((avg - sd) * 10) / 10);
  const hi = Math.round((avg + sd) * 10) / 10;
  console.log(`  Based on ${sprints.length} sprint(s), plan for:`);
  console.log(`  ${GREEN}${projected}${unit}${RESET} of work  (range: ${lo}โ€“${hi}${unit})`);
  console.log();

  if (sprints.length < 3) {
    console.log(
      `  ${YELLOW}Note: projections improve with more sprint history.${RESET}`
    );
    console.log(
      `  Run with SPRINTS_BACK=5 for a more reliable average.`
    );
    console.log();
  }
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  console.log(
    `Fetching velocity data for last ${SPRINTS_BACK} sprint(s) (unit: ${UNIT})โ€ฆ`
  );

  const recentSprints = fetchRecentSprints(SPRINTS_BACK);

  if (recentSprints.length === 0) {
    console.log(
      "No sprint Milestones found. Create at least one Milestone with type=Milestone."
    );
    process.exitCode = 1;
    return;
  }

  console.log(`  Found ${recentSprints.length} sprint Milestone(s).`);

  const velocities = recentSprints.map((s) => {
    process.stdout.write(`  Calculating velocity for "${s.title}"โ€ฆ`);
    const v = calcVelocity(s);
    process.stdout.write(` ${v.velocity}${v.velocityUnit}\n`);
    return v;
  });

  // Reverse so oldest sprint is leftmost in chart
  velocities.reverse();

  printVelocityReport(velocities);
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub sprint-velocity-tracker
๐Ÿƒ

Team Sprint

3-person team running two-week sprints

Sprint setup, assignment across team members, blocked item handling, and retrospective records. Shows how multiple people can share a single .agents/pm/ directory via git.

Commands used
pm init pm start-taskpm updatepm aggregatepm notespm learningspm validate

Overview

This example shows a three-person development team running two-week sprints with pm-cli. It covers sprint setup using milestones, breaking Epics down to Features and Tasks, assigning work across team members, handling blocked items, and closing a sprint with a brief retrospective.

The team uses pm aggregate to roll up child-item progress to parent items and pm list-all to visualize milestone due dates.

When to use this workflow

  • A small team (2โ€“6 people) running time-boxed sprints
  • You want everyone to have the same picture of sprint state without a heavyweight tool
  • You need to track blocked items and clearly record what is blocking them
  • You want a lightweight retrospective record stored alongside the work

Key commands used

Command Purpose
pm init Bootstrap the project
pm create --type Milestone Define a sprint milestone with a due date
pm create --type Epic/Feature/Task Build the work hierarchy
pm start-task <id> Lifecycle alias: claim + move to in_progress
pm update <id> --status blocked Mark an item blocked
pm update <id> --blocked-by <id> Record the blocking dependency
pm update <id> --status in_progress Unblock and resume an item
pm comments <id> "text" Sprint notes, stand-up updates, blockers
pm close <id> "reason" Complete an item
pm aggregate Roll up child status to parent items
pm list-all View milestone due dates on a timeline
pm stats Sprint velocity and completion metrics
pm list-open See remaining open items

Team members in this example

  • alice โ€” backend engineer
  • bob โ€” frontend engineer
  • carol โ€” full-stack / team lead

Step-by-step narrative

1. Project initialization (carol โ€” team lead)

cd ~/projects/teamapp
pm init APP

2. Create the Sprint 1 milestone

A Milestone item represents the sprint boundary. Set the due date so pm list-all can show it on the timeline.

pm create \
  --title "Sprint 1 โ€” User onboarding" \
  --description "Goal: new users can sign up, verify email, complete profile, and reach the dashboard" \
  --type Milestone \
  --priority 1 \
  --deadline "2026-05-15"

3. Create the sprint Epic and Features

The Epic scopes the sprint's theme. Features are the shippable chunks.

# Epic for the sprint theme
pm create \
  --title "User Onboarding Flow" \
  --description "End-to-end: registration โ†’ email verification โ†’ profile setup โ†’ first dashboard view" \
  --type Epic \
  --priority 1

# Features under the Epic
pm create --title "Registration and email verification" \
          --description "Form, backend, transactional email, token verification link" \
          --type Feature --priority 1 --parent APP-2

pm create --title "User profile setup wizard" \
          --description "3-step wizard: avatar upload, display name, preferences" \
          --type Feature --priority 1 --parent APP-2

pm create --title "Empty-state dashboard with onboarding checklist" \
          --description "Dashboard shows a checklist guiding users through their first actions" \
          --type Feature --priority 2 --parent APP-2

4. Decompose Features into Tasks and assign them

# Registration feature (APP-3) tasks โ€” alice owns backend, bob owns frontend
pm create --title "POST /users/register API" \
          --description "Validate, hash, insert, send verification email" \
          --type Task --priority 1 --parent APP-3 --assignee alice

pm create --title "Email verification token service" \
          --description "Generate signed token, store with expiry, verify and mark user confirmed" \
          --type Task --priority 1 --parent APP-3 --assignee alice

pm create --title "Registration UI form" \
          --description "React form, client-side validation, error handling, success state" \
          --type Task --priority 1 --parent APP-3 --assignee bob

pm create --title "Email verification landing page" \
          --description "Page that consumes token from URL, shows success/error state" \
          --type Task --priority 2 --parent APP-3 --assignee bob

5. Sprint kickoff โ€” team starts their tasks

Each person starts their assigned tasks. pm start-task claims the item and moves it to in_progress in one step.

# alice runs:
PM_AUTHOR=alice pm start-task APP-6
PM_AUTHOR=alice pm start-task APP-7

# bob runs:
PM_AUTHOR=bob pm start-task APP-8
PM_AUTHOR=bob pm start-task APP-9

6. Daily stand-up via comments

Instead of a separate stand-up doc, team members post a brief update comment each morning.

# alice's day 3 update
PM_AUTHOR=alice pm comments APP-6 \
  "Stand-up day 3: register endpoint done and tested. Sending to code review. Starting APP-7 (token service) today."

# bob's day 3 update
PM_AUTHOR=bob pm comments APP-8 \
  "Stand-up day 3: form validation wired up. Blocked on design handoff for error state โ€” Carol has the Figma file."

7. Handle a blocked item

Bob's form is blocked waiting on a design file. Record the blocker formally so it shows up in pm health warnings.

# carol creates a Task for the design handoff
pm create --title "Hand off error-state designs to bob" \
          --description "Export Figma frames for registration form error states" \
          --type Task --priority 1 --assignee carol

# bob marks his task blocked and links the blocker
PM_AUTHOR=bob pm update APP-8 --status blocked
PM_AUTHOR=bob pm update APP-8 --blocked-by APP-12

PM_AUTHOR=bob pm comments APP-8 "Blocked on design handoff (APP-12). Moving to onboarding checklist front-end while waiting."

8. Unblock the item once the blocker is resolved

# carol delivers the designs and closes APP-12
PM_AUTHOR=carol pm close APP-12 "Figma frames exported and shared in Slack #design channel."

# bob resumes APP-8
PM_AUTHOR=bob pm update APP-8 --status in_progress
PM_AUTHOR=bob pm comments APP-8 "Unblocked. Got designs from carol. Implementing error states now."

9. Use pm aggregate to check Feature completion

After several tasks close, roll up progress to the parent Feature.

pm aggregate --parent APP-3

This shows grouped counts of child items under the Feature, broken down by status.

10. Check the sprint calendar

pm list-all

Shows the Sprint 1 milestone due date and which items are scheduled before it.

11. Sprint close โ€” finish the remaining items

As the sprint ends, team members close their completed tasks and leave closing summaries.

PM_AUTHOR=alice pm close APP-6 "Register endpoint deployed to staging. All 14 unit tests pass. PR merged."
PM_AUTHOR=alice pm close APP-7 "Token service live. Tokens expire after 24 hours. Resend endpoint added."
PM_AUTHOR=bob pm close APP-8 "Registration form live on staging. Validated against Chrome, Firefox, Safari."
PM_AUTHOR=bob pm close APP-9 "Verification landing page done. Handles expired token gracefully with resend link."

Close the Features, then the Epic, then the Milestone.

pm close APP-3 "Registration + email verification feature shipped to staging. QA sign-off pending."
pm close APP-2 "Onboarding Epic: registration, profile wizard, and dashboard checklist all shipped. Ready for QA."
pm close APP-1 "Sprint 1 complete. Goal achieved: users can sign up, verify, and reach dashboard. See retrospective note."

12. Retrospective note on the Milestone

pm comments APP-1 \
  "RETRO Sprint 1 โ€” What went well: clear task breakdown made parallel work easy. Blocker on design was caught early.
What to improve: design handoffs should happen before sprint starts, not mid-sprint.
Action item: add 'design review' checklist to sprint planning template (APP-13)."

13. Sprint velocity review

pm stats
pm health
Show complete workflow script
team-sprint/workflow.sh
#!/usr/bin/env tsx
import { execFileSync } from "node:child_process";

function pm(args: string[]): void {
  execFileSync("pm", args, { stdio: "inherit" });
}

const sprint = process.env.PM_SPRINT ?? "2026-W20";

pm([
  "create",
  "--type",
  "Milestone",
  "--title",
  `${sprint} sprint goal`,
  "--description",
  "Team sprint focused on closing high-priority product quality items.",
  "--priority",
  "1",
  "--sprint",
  sprint,
]);
pm([
  "create",
  "--type",
  "Feature",
  "--title",
  "Improve project sharing review flow",
  "--description",
  "Make shared project permissions clear and enforce view-only access.",
  "--priority",
  "1",
  "--assignee",
  "alex",
  "--sprint",
  sprint,
]);
pm(["list-open", "--sprint", sprint]);
pm(["aggregate", "--group-by", "assignee"]);
pm(["calendar", "--view", "week", "--sprint", sprint]);
pm(["validate", "--check-lifecycle", "--check-stale-blockers"]);
View source on GitHub team-sprint
๐Ÿ“‹

Team Standup Reporter

Generate Yesterday / Today / Blockers reports from pm activity

Reads pm activity for the past 24 hours, groups updates by team member, and formats a Yesterday / Today / Blockers standup report. Optionally posts to Slack via an Incoming Webhook.

Commands used
pm init pm activity --frompm list-openpm list-in-progresspm list-blockedSlack webhookTypeScript

Generate a daily standup report from pm-cli activity โ€” no more "what did I do yesterday?" moments.

What this does

  • Queries pm activity for the last 24 hours (or since last business day on Mondays)
  • Groups activity by team member and item
  • Formats a concise standup report: Yesterday / Today / Blockers
  • Optionally posts the report to Slack via a webhook

Requirements

  • pm v2026.1.0 or later
  • Node.js 20+
  • Optional: Slack Incoming Webhook URL (set SLACK_WEBHOOK_URL)

Setup

cd team-standup-reporter
npm install

# Run for the whole team
npx tsx main.ts

# Run for a specific team member
npx tsx main.ts --assignee alice

# Post to Slack
SLACK_WEBHOOK_URL=https://hooks.slack.com/... npx tsx main.ts --slack

Example output

Daily Standup โ€” Monday 2026-05-11
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

alice
  Yesterday:
    โ€ข Closed APP-14 "Implement OAuth callback handler" (4h)
    โ€ข Commented on APP-17 "Add refresh-token rotation"
  Today:  APP-17 (in progress), APP-22 (open)
  Blockers: none

bob
  Yesterday:
    โ€ข Started APP-22 "User profile settings page"
    โ€ข Added notes to APP-22
  Today:  APP-22 (in progress)
  Blockers: APP-31 is blocked โ€” waiting on design review

carol
  Yesterday:
    โ€ข Created APP-31 "Sprint 12 milestone"
    โ€ข Closed APP-19 "Update deployment docs" (2h)
  Today:  APP-25 (open)
  Blockers: none

Automating with cron

Add to your crontab to post every weekday at 9 AM:

0 9 * * 1-5 cd /path/to/project && npx tsx team-standup-reporter/main.ts --slack

Or use a GitHub Actions scheduled workflow โ€” see the CI integration pattern in the agent-ci-workflow example.

Show TypeScript source
team-standup-reporter/main.ts TypeScript
/**
 * team-standup-reporter โ€” Generate daily standup reports from pm-cli activity
 *
 * Reads pm activity for the last 24h (or since last Friday for Mondays),
 * groups by team member, and formats Yesterday / Today / Blockers.
 *
 * Optionally posts to Slack via an Incoming Webhook.
 *
 * Usage:
 *   npx tsx main.ts
 *   npx tsx main.ts --assignee alice
 *   npx tsx main.ts --slack
 *   SLACK_WEBHOOK_URL=https://hooks.slack.com/... npx tsx main.ts --slack
 */

import { execSync } from "node:child_process";
import { parseArgs } from "node:util";

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------

const { values: args } = parseArgs({
  options: {
    assignee: { type: "string" },
    slack: { type: "boolean", default: false },
    "webhook-url": { type: "string" },
  },
});

const FILTER_ASSIGNEE = args.assignee ?? null;
const POST_TO_SLACK = args.slack ?? false;
const SLACK_WEBHOOK =
  args["webhook-url"] ??
  process.env.SLACK_WEBHOOK_URL ??
  "";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** A single activity entry returned by `pm activity --json`. */
interface ActivityEntry {
  id: string;
  item_id: string;
  item_title?: string;
  op: string;        // "create" | "update" | "close" | "comment" | "note" | "file" | "claim" | ...
  author?: string;
  ts: string;        // ISO timestamp
  message?: string;
}

/** A pm item (minimal fields we need for "today" list). */
interface PmItem {
  id: string;
  title: string;
  status: "open" | "in_progress" | "blocked" | "closed" | "canceled" | "draft";
  assignee?: string;
  blocked_reason?: string;
  blocked_by?: string;
  estimated_minutes?: number;
}

/** Grouped standup data for one team member. */
interface MemberStandup {
  author: string;
  yesterday: YesterdayEntry[];
  today: PmItem[];
  blockers: PmItem[];
}

interface YesterdayEntry {
  itemId: string;
  itemTitle: string;
  ops: string[];
  estimatedHours?: number;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function pm(cmd: string): string {
  return execSync(`pm ${cmd}`, { encoding: "utf-8" }).trim();
}

function pmJson<T>(cmd: string): T {
  return JSON.parse(pm(`${cmd} --json`));
}

/** Return the "from" timestamp for activity queries.
 *  On Mondays we go back to last Friday (72h). Otherwise 24h. */
function activityFrom(): string {
  const now = new Date();
  const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
  const hoursBack = dayOfWeek === 1 ? 72 : 24;
  const from = new Date(now.getTime() - hoursBack * 60 * 60 * 1000);
  return from.toISOString();
}

/** Format a date as "Monday 2026-05-11" */
function formatDate(d: Date): string {
  const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
  const ymd = d.toISOString().slice(0, 10);
  return `${days[d.getDay()]} ${ymd}`;
}

// ---------------------------------------------------------------------------
// Build standup data
// ---------------------------------------------------------------------------

function fetchActivity(from: string): ActivityEntry[] {
  const cmd = FILTER_ASSIGNEE
    ? `activity --from "${from}" --author "${FILTER_ASSIGNEE}"`
    : `activity --from "${from}"`;
  return pmJson<ActivityEntry[]>(cmd);
}

function fetchOpenItems(): PmItem[] {
  const open = pmJson<PmItem[]>("list-open");
  const inProgress = pmJson<PmItem[]>("list-in-progress");
  const blocked = pmJson<PmItem[]>("list-blocked");
  return [...open, ...inProgress, ...blocked];
}

/** Group activity entries by author, then by item. */
function groupActivity(entries: ActivityEntry[]): Map<string, Map<string, YesterdayEntry>> {
  const byAuthor = new Map<string, Map<string, YesterdayEntry>>();

  for (const entry of entries) {
    const author = entry.author ?? "(unknown)";
    if (!byAuthor.has(author)) {
      byAuthor.set(author, new Map());
    }
    const byItem = byAuthor.get(author)!;

    if (!byItem.has(entry.item_id)) {
      byItem.set(entry.item_id, {
        itemId: entry.item_id,
        itemTitle: entry.item_title ?? entry.item_id,
        ops: [],
      });
    }

    const existing = byItem.get(entry.item_id)!;
    if (!existing.ops.includes(entry.op)) {
      existing.ops.push(entry.op);
    }
  }

  return byAuthor;
}

/** Describe a list of ops concisely: "Started, commented, closed" โ†’ readable verb */
function describeOps(ops: string[]): string {
  const verbMap: Record<string, string> = {
    create: "Created",
    update: "Updated",
    close: "Closed",
    claim: "Started",
    release: "Paused",
    comment: "Commented on",
    note: "Added notes to",
    learning: "Added learnings to",
    file: "Attached files to",
    "start-task": "Started",
    "close-task": "Closed",
    "pause-task": "Paused",
  };

  const verbs = ops.map((op) => verbMap[op] ?? op);
  if (verbs.length === 1) return verbs[0];
  const last = verbs.pop();
  return `${verbs.join(", ")} and ${last}`;
}

function buildStandups(
  activityByAuthor: Map<string, Map<string, YesterdayEntry>>,
  allItems: PmItem[]
): MemberStandup[] {
  const authors = [...activityByAuthor.keys()].sort();
  const itemsByAssignee = new Map<string, PmItem[]>();

  for (const item of allItems) {
    if (item.assignee) {
      if (!itemsByAssignee.has(item.assignee)) {
        itemsByAssignee.set(item.assignee, []);
      }
      itemsByAssignee.get(item.assignee)!.push(item);
    }
  }

  return authors.map((author) => {
    const byItem = activityByAuthor.get(author)!;
    const yesterday = [...byItem.values()];
    const myItems = itemsByAssignee.get(author) ?? [];
    const today = myItems.filter((i) => i.status === "open" || i.status === "in_progress");
    const blockers = myItems.filter((i) => i.status === "blocked");

    return { author, yesterday, today, blockers };
  });
}

// ---------------------------------------------------------------------------
// Format report
// ---------------------------------------------------------------------------

function formatTextReport(standups: MemberStandup[], date: string): string {
  const lines: string[] = [
    `Daily Standup โ€” ${date}`,
    "โ”€".repeat(48),
    "",
  ];

  for (const member of standups) {
    lines.push(member.author);

    lines.push("  Yesterday:");
    if (member.yesterday.length === 0) {
      lines.push("    (no recorded activity)");
    } else {
      for (const entry of member.yesterday) {
        const verb = describeOps(entry.ops);
        lines.push(`    โ€ข ${verb} ${entry.itemId} "${entry.itemTitle}"`);
      }
    }

    lines.push("  Today:");
    if (member.today.length === 0) {
      lines.push("    (nothing assigned)");
    } else {
      const items = member.today
        .map((i) => `${i.id} (${i.status.replace("_", " ")})`)
        .join(", ");
      lines.push(`    ${items}`);
    }

    lines.push("  Blockers:");
    if (member.blockers.length === 0) {
      lines.push("    none");
    } else {
      for (const b of member.blockers) {
        const reason = b.blocked_reason ?? b.blocked_by ?? "reason not specified";
        lines.push(`    ${b.id} is blocked โ€” ${reason}`);
      }
    }

    lines.push("");
  }

  return lines.join("\n");
}

/** Format for Slack โ€” uses mrkdwn. */
function formatSlackPayload(standups: MemberStandup[], date: string): string {
  const blocks: unknown[] = [
    {
      type: "header",
      text: { type: "plain_text", text: `Daily Standup โ€” ${date}`, emoji: false },
    },
    { type: "divider" },
  ];

  for (const member of standups) {
    const yesterdayLines =
      member.yesterday.length === 0
        ? "_no recorded activity_"
        : member.yesterday
            .map((e) => `โ€ข ${describeOps(e.ops)} *${e.itemId}* "${e.itemTitle}"`)
            .join("\n");

    const todayText =
      member.today.length === 0
        ? "_nothing assigned_"
        : member.today.map((i) => `${i.id} (${i.status.replace("_", " ")})`).join(", ");

    const blockersText =
      member.blockers.length === 0
        ? "none"
        : member.blockers
            .map((b) => `${b.id} โ€” ${b.blocked_reason ?? b.blocked_by ?? "reason not specified"}`)
            .join("; ");

    blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: `*${member.author}*\n*Yesterday:*\n${yesterdayLines}\n*Today:* ${todayText}\n*Blockers:* ${blockersText}`,
      },
    });
  }

  return JSON.stringify({ blocks });
}

// ---------------------------------------------------------------------------
// Slack posting
// ---------------------------------------------------------------------------

async function postToSlack(payload: string): Promise<void> {
  if (!SLACK_WEBHOOK) {
    console.warn("SLACK_WEBHOOK_URL not set โ€” skipping Slack post.");
    return;
  }

  const res = await fetch(SLACK_WEBHOOK, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: payload,
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Slack webhook error ${res.status}: ${text}`);
  }

  console.log("Posted to Slack.");
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  const from = activityFrom();
  const date = formatDate(new Date());

  console.log(`Fetching pm activity since ${from}โ€ฆ`);

  const activity = fetchActivity(from);
  console.log(`  ${activity.length} activity entries found.`);

  const allItems = fetchOpenItems();
  const activityByAuthor = groupActivity(activity);
  const standups = buildStandups(activityByAuthor, allItems);

  if (standups.length === 0) {
    console.log("No activity found for the standup window. Nothing to report.");
    return;
  }

  const textReport = formatTextReport(standups, date);
  console.log("\n" + textReport);

  if (POST_TO_SLACK) {
    const slackPayload = formatSlackPayload(standups, date);
    await postToSlack(slackPayload);
  }
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : String(err));
  process.exitCode = 1;
});
View source on GitHub team-standup-reporter
๐Ÿ”ท

Ts Sdk Starter

TypeScript extension covering 8 SDK capabilities

Full-featured TypeScript reference extension covering 8 SDK capability types: custom commands, item type schemas, create flags, importers, hooks, renderers, search providers, preflight gates, and service overrides.

Commands used
pm init defineExtensionregisterItemTypesregisterFlagsregisterImporterhooksrendererssearchpreflightservices

A fully-typed TypeScript reference extension for pm-cli. Copy this as your starting point when building a new extension. 8 of the 9 pm-cli capability types are demonstrated with inline comments explaining the hook point.


What each capability does

Capability What this extension registers Purpose
commands pm incident List open incidents sorted by severity
schema Incident item type + fields Custom type stored in incidents/ folder
schema --severity / --service flags on create Extra flags on built-in commands
importers incident-fixtures importer Bulk-create incidents from a JSON file
hooks beforeCommand logger Log command name + timestamp before every run
renderers toon format One-liner output for piping into other tools
search incident-search provider Title-match search scoped to Incident items
preflight Severity gate Skip format sync on read-only commands
services help_format override stub Shows registration pattern for service overrides

Note: The parser capability (registerParser) is not demonstrated here. See pm-starter for a parser example.


Project layout

ts-sdk-starter/
  index.ts       # TypeScript source (8 SDK capabilities; parser intentionally omitted)
  manifest.json  # Extension manifest
  tsconfig.json  # TypeScript compiler config
  README.md      # This file

Build

The extension entry point in manifest.json is index.js, which is the compiled output. Compile the TypeScript source before installing:

# Install the TypeScript compiler if you don't have it
npm install -g typescript

# Compile index.ts -> dist/index.js
cd examples/ts-sdk-starter
tsc

# The compiled output is in dist/. Copy it up if needed, or adjust tsconfig.json
# to output directly to the extension root.

Alternatively, if your project uses a bundler (esbuild, tsup, vite), point it at index.ts and output index.js in the same directory.


Install

After compiling:

# Install into the current project
pm install ./examples/ts-sdk-starter --project

# Verify it loaded
pm extension explore --project

Usage

Custom command

# List all open incidents
pm incident

# Filter by severity
pm incident --severity critical

# Filter by affected service
pm incident --service api

Create an incident using the custom type + flags

pm create --type incident \
  --title "API gateway 502s" \
  --body "Elevated error rate on /v2/users" \
  --severity critical \
  --service api

Import incidents from a JSON fixture file

pm extension import incident-fixtures --file ./fixtures/incidents.json

The fixture file must be a JSON array of objects with this shape:

[
  {
    "id": "inc-001",
    "title": "DB connection pool exhausted",
    "description": "All connections in use, queue backing up",
    "severity": "critical",
    "service": "api"
  },
  {
    "title": "Slow search queries",
    "severity": "minor",
    "service": "web"
  }
]

Toon renderer

# Incidents are rendered in toon format with structured YAML-like output
pm incident

# Pipe into other tools
pm incident --format toon | grep severity

Search

pm search "gateway"

The incident-search provider will match on Incident titles containing the query string.

Preflight gate

The preflight blocks closing a critical Incident that has no body:

# The preflight gate skips format sync on read-only commands (list, get, search, etc.)
pm list    # preflight skips format sync
pm create --type incident --title "test" --severity critical  # preflight runs normally

Capability deep-dives

commands

Registered with api.registerCommand(). Every command needs:

  • name โ€” the subcommand (pm <name>)
  • description + intent โ€” shown in help and used by AI agents
  • examples โ€” shown in pm help <name>
  • flags โ€” array of { long, value_name, description, required? }
  • run(ctx) โ€” async handler; ctx.args, ctx.pm, ctx.log are your tools

schema (item types)

api.registerItemTypes() creates a custom item type with its own folder, aliases, and required fields. api.registerItemFields() registers the backing field types. Both require the schema capability in manifest.json.

schema (command flags)

api.registerFlags(command, flags) adds flags to any existing command. Use this to extend create, update, or any other built-in. Requires both commands and schema in manifest.json.

importers

api.registerImporter(name, handler) registers a named importer callable via extension import. The handler receives an ImportExportContext with options (user-supplied arguments), pm_root (store location), and other fields. Return a summary object like { count, message }.

hooks

api.hooks.beforeCommand(fn) runs before every command. Available hooks: beforeCommand, afterCommand, onWrite, onRead, onIndex. Keep them fast โ€” they block the command pipeline.

renderers

api.registerRenderer(format, fn) overrides pm's output for --format <format>. The handler receives a RendererOverrideContext with result (command result) and format. Return the formatted string, or "" to fall through to the default renderer.

search

api.registerSearchProvider({ name, query(context) }) plugs into pm search. context.documents is the full indexed document set; filter and score it, then return [{ id, score, matched_fields }].

preflight

api.registerPreflight(fn) runs before mutations. Return { allow: true } to permit or { allow: false, reason: "..." } to block. The operation object has action, item, and fields.

services

api.registerService(name, fn) replaces a named runtime service. Return context.payload to pass through โ€” override the value to redirect behaviour. Valid service names: output_format, error_format, help_format, lock_acquire, lock_release, history_append, item_store_write, item_store_delete. Only enable the services capability for reviewed extensions; it can change storage paths, lock behaviour, and history writes.


Debugging

pm extension doctor --detail deep --trace
pm extension explore --project
pm --no-extensions incident   # verify the command only exists with the extension
Show TypeScript source
ts-sdk-starter/index.ts TypeScript
/**
 * ts-sdk-starter โ€” pm-cli TypeScript extension reference
 *
 * Demonstrates 8 of the 9 SDK capability types with correct types and API usage:
 *   1. commands   โ€” custom `pm incident` command
 *   2. schema     โ€” custom "Incident" item type + fields
 *   3. schema     โ€” custom flag on `create` (--severity)
 *   4. importers  โ€” JSON fixture importer
 *   5. hooks      โ€” beforeCommand / afterCommand lifecycle hooks
 *   6. renderers  โ€” toon renderer override for incident output
 *   7. search     โ€” custom search provider (title-match stub)
 *   8. preflight  โ€” preflight decision override
 *   9. services   โ€” help_format service override stub
 *
 * Note: `parser` (registerParser) is not demonstrated here. See pm-starter
 * for a parser example.
 *
 * Each section is labelled with its capability name so you can jump directly
 * to the capability you care about when building your own extension.
 */

import {
  defineExtension,
  type ExtensionApi,
  type CommandHandlerContext,
  type ImportExportContext,
  type BeforeCommandHookContext,
  type AfterCommandHookContext,
  type RendererOverrideContext,
  type SearchProviderQueryContext,
  type SearchProviderHit,
  type PreflightOverrideContext,
  type ServiceOverrideContext,
} from "@unbrained/pm-cli/sdk";
import * as fs from "node:fs/promises";
import * as path from "node:path";

// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------

/** Shape of a JSON fixture file entry for the incident importer. */
interface IncidentFixture {
  id?: string;
  title: string;
  description?: string;
  severity: "critical" | "major" | "minor";
  service?: "api" | "web" | "worker";
}

// ---------------------------------------------------------------------------
// Extension definition
// ---------------------------------------------------------------------------

export default defineExtension({
  activate(api: ExtensionApi) {
    // =======================================================================
    // Capability 2 & 3: schema โ€” custom item type, fields, and create flag
    // =======================================================================
    //
    // registerItemTypes adds a new type so `pm create --type incident`
    // stores items in the incidents/ folder with option enforcement.
    // registerItemFields declares backing field types.
    // registerFlags adds extra flags to existing built-in commands.

    api.registerItemTypes([
      {
        name: "Incident",
        folder: "incidents",
        aliases: ["incident", "inc"],
        required_create_fields: ["title", "severity"],
        options: [
          {
            key: "severity",
            values: ["critical", "major", "minor"],
            required: true,
          },
          {
            key: "service",
            values: ["api", "web", "worker"],
          },
        ],
      },
    ]);

    api.registerItemFields([
      { name: "severity", type: "string" },
      { name: "service", type: "string", optional: true },
    ]);

    // Add --severity and --service flags to `pm create`:
    //   pm create --type incident --title "DB down" --severity critical
    api.registerFlags("create", [
      {
        long: "--severity",
        value_name: "level",
        description: "Incident severity: critical | major | minor",
      },
      {
        long: "--service",
        value_name: "svc",
        description: "Affected service: api | web | worker",
      },
    ]);

    // =======================================================================
    // Capability 1: commands โ€” `pm incident` custom command
    // =======================================================================
    //
    // CommandHandlerContext provides: command, args (string[]), options
    // (Record<string, unknown>), global, pm_root.
    // Use `options` for flag values. Read files via pm_root if needed.

    api.registerCommand({
      name: "incident",
      description: "Show a summary of active incidents",
      intent: "surface open incidents for standup or triage",
      examples: [
        "pm incident",
        "pm incident --severity critical",
        "pm incident --service api",
      ],
      flags: [
        {
          long: "--severity",
          value_name: "level",
          description: "Filter by severity: critical | major | minor",
        },
        {
          long: "--service",
          value_name: "svc",
          description: "Filter by service: api | web | worker",
        },
      ],

      async run(ctx: CommandHandlerContext) {
        const severityFilter = ctx.options["severity"] as string | undefined;
        const serviceFilter = ctx.options["service"] as string | undefined;

        // Read incidents from pm_root โ€” .agents/pm/incidents/
        const incidentsDir = path.join(ctx.pm_root, "incidents");
        let files: string[] = [];
        try {
          files = await fs.readdir(incidentsDir);
        } catch {
          return { incidents: [], message: "No incidents directory found. Run `pm init` first." };
        }

        const incidents: Array<{ id: string; title: string; severity?: string; service?: string }> = [];

        for (const file of files.filter((f) => f.endsWith(".toon"))) {
          try {
            const content = await fs.readFile(path.join(incidentsDir, file), "utf-8");
            // Parse YAML front-matter (simple extraction โ€” real code would use gray-matter or similar)
            const titleMatch = content.match(/^title:\s*(.+)$/m);
            const severityMatch = content.match(/^severity:\s*(.+)$/m);
            const serviceMatch = content.match(/^service:\s*(.+)$/m);
            const idMatch = content.match(/^id:\s*(.+)$/m);
            const statusMatch = content.match(/^status:\s*(.+)$/m);

            if (statusMatch?.[1]?.trim() === "closed" || statusMatch?.[1]?.trim() === "canceled") continue;

            const item = {
              id: idMatch?.[1]?.trim() ?? file.replace(".toon", ""),
              title: titleMatch?.[1]?.trim() ?? "(untitled)",
              severity: severityMatch?.[1]?.trim(),
              service: serviceMatch?.[1]?.trim(),
            };

            if (severityFilter && item.severity !== severityFilter) continue;
            if (serviceFilter && item.service !== serviceFilter) continue;

            incidents.push(item);
          } catch {
            // skip unreadable files
          }
        }

        // Sort: critical first, then major, then minor
        const order: Record<string, number> = { critical: 0, major: 1, minor: 2 };
        incidents.sort((a, b) => (order[a.severity ?? ""] ?? 9) - (order[b.severity ?? ""] ?? 9));

        return { incidents, count: incidents.length };
      },
    });

    // =======================================================================
    // Capability 4: importers โ€” JSON fixture importer
    // =======================================================================
    //
    // ImportExportContext provides: registration, action, command, args,
    // options, global, pm_root.
    // Use options for user-supplied arguments, pm_root to locate the store.

    api.registerImporter(
      "incident-fixtures",
      async (ctx: ImportExportContext) => {
        const filePath = ctx.options["file"] as string | undefined;
        if (!filePath) {
          throw new Error(
            "incident-fixtures requires --file <path> โ€” path to a JSON fixtures file."
          );
        }

        const resolved = path.resolve(filePath);
        let raw: string;
        try {
          raw = await fs.readFile(resolved, "utf-8");
        } catch {
          throw new Error(`Could not read fixtures file: ${resolved}`);
        }

        const fixtures: IncidentFixture[] = JSON.parse(raw);
        const incidentsDir = path.join(ctx.pm_root, "incidents");
        await fs.mkdir(incidentsDir, { recursive: true });

        let count = 0;
        for (const fixture of fixtures) {
          const id = fixture.id ?? `inc-${fixture.title.toLowerCase().replace(/\s+/g, "-").slice(0, 20)}`;
          const toon = [
            "---",
            `id: "${id}"`,
            `title: "${fixture.title}"`,
            `status: open`,
            `type: Incident`,
            `severity: ${fixture.severity}`,
            fixture.service ? `service: ${fixture.service}` : null,
            fixture.description ? `description: "${fixture.description}"` : null,
            `created_at: "${new Date().toISOString()}"`,
            "---",
            fixture.description ?? "",
          ]
            .filter((l): l is string => l !== null)
            .join("\n");

          await fs.writeFile(path.join(incidentsDir, `${id}.toon`), toon, "utf-8");
          count++;
        }

        return { count, message: `Imported ${count} incident(s) from ${resolved}` };
      }
    );

    // =======================================================================
    // Capability 5: hooks โ€” beforeCommand / afterCommand lifecycle
    // =======================================================================
    //
    // BeforeCommandHookContext: command, args, options, global, pm_root
    // AfterCommandHookContext: ...BeforeCommandHookContext, ok, error?, result?
    // Hooks should be fast and not throw โ€” errors are swallowed by the runtime.

    api.hooks.beforeCommand(async (ctx: BeforeCommandHookContext) => {
      // Example: log every pm command to a local audit file (fire-and-forget)
      const auditPath = path.join(ctx.pm_root, "incident-audit.log");
      const line = `${new Date().toISOString()} BEFORE ${ctx.command}\n`;
      fs.appendFile(auditPath, line).catch(() => {});
    });

    api.hooks.afterCommand(async (ctx: AfterCommandHookContext) => {
      const auditPath = path.join(ctx.pm_root, "incident-audit.log");
      const line = `${new Date().toISOString()} AFTER  ${ctx.command} ok=${ctx.ok}\n`;
      fs.appendFile(auditPath, line).catch(() => {});
    });

    // =======================================================================
    // Capability 6: renderers โ€” toon renderer override
    // =======================================================================
    //
    // RendererOverride signature: (context: RendererOverrideContext) => string
    // context.result is the command result. context.format is "toon" or "json".
    // Return the formatted string. Return "" to pass through to the default.

    api.registerRenderer("toon", (ctx: RendererOverrideContext): string => {
      const result = ctx.result as Record<string, unknown> | null;
      if (
        result &&
        typeof result === "object" &&
        "incidents" in result &&
        Array.isArray(result.incidents)
      ) {
        const incidents = result.incidents as Array<{
          id: string;
          title: string;
          severity?: string;
          service?: string;
        }>;

        if (incidents.length === 0) {
          return "incidents:\n  count: 0\n  message: no open incidents";
        }

        const lines = [
          `incidents:`,
          `  count: ${incidents.length}`,
          `  items:`,
        ];
        for (const inc of incidents) {
          lines.push(`    - id: "${inc.id}"`);
          lines.push(`      title: "${inc.title}"`);
          if (inc.severity) lines.push(`      severity: ${inc.severity}`);
          if (inc.service) lines.push(`      service: ${inc.service}`);
        }
        return lines.join("\n");
      }

      // Return empty string to fall through to the default renderer.
      return "";
    });

    // =======================================================================
    // Capability 7: search โ€” title-match search provider
    // =======================================================================
    //
    // SearchProviderQueryContext provides: query, mode, tokens, options,
    // settings, documents (ItemDocument[]).
    // Return SearchProviderHit[] with { id, score, matched_fields }.

    api.registerSearchProvider({
      name: "incident-search",
      async query(ctx: SearchProviderQueryContext): Promise<SearchProviderHit[]> {
        const q = ctx.query.toLowerCase();
        return ctx.documents
          .filter((doc) => {
            const type = (doc.metadata.type as string | undefined)?.toLowerCase() ?? "";
            const title = (doc.metadata.title as string | undefined)?.toLowerCase() ?? "";
            return type === "incident" && title.includes(q);
          })
          .map((doc) => ({
            id: doc.metadata.id as string,
            score: 0.75,
            matched_fields: ["title"],
          }));
      },
    });

    // =======================================================================
    // Capability 8: preflight โ€” override preflight decision flags
    // =======================================================================
    //
    // PreflightOverrideContext: command, args, options, global, pm_root,
    //   decision: { enforce_item_format_gate, run_preflight_item_format_sync,
    //               run_extension_migrations, enforce_mandatory_migration_gate }
    // Return PreflightOverrideDelta to selectively override decision flags.
    // Return {} to leave all flags unchanged.

    api.registerPreflight(async (ctx: PreflightOverrideContext) => {
      // Example: skip format sync on read-only commands (list, get, search)
      const readOnlyCommands = new Set(["list", "get", "search", "context", "aggregate", "stats"]);
      if (readOnlyCommands.has(ctx.command)) {
        return { run_preflight_item_format_sync: false };
      }

      // Leave all other preflight flags at their defaults.
      return {};
    });

    // =======================================================================
    // Capability 9: services โ€” help_format service override stub
    // =======================================================================
    //
    // ServiceOverrideContext: service (ExtensionServiceName), command?, args?,
    //   options?, global?, pm_root?, payload (the default service payload).
    // Return the new payload, or context.payload to pass through unchanged.
    //
    // Valid service names: output_format, error_format, help_format,
    //   lock_acquire, lock_release, history_append, item_store_write,
    //   item_store_delete.

    api.registerService("help_format", (ctx: ServiceOverrideContext) => {
      // Pass through to the default help formatter unchanged.
      // Replace this with custom logic to inject banners, extra sections, etc.
      return ctx.payload;
    });
  },
});
View source on GitHub ts-sdk-starter