@stylusnexus/work-plan 2026.6.9-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem work-plan CLI launcher (Windows). Resolves work_plan.py relative to this
|
|
3
|
+
rem shim's parent (the wrapper lives at <root>\bin\work-plan.cmd).
|
|
4
|
+
set "WP=%~dp0..\skills\work-plan\work_plan.py"
|
|
5
|
+
if exist "%WP%" ( python "%WP%" %* & goto :eof )
|
|
6
|
+
if defined CLAUDE_PLUGIN_ROOT if exist "%CLAUDE_PLUGIN_ROOT%\skills\work-plan\work_plan.py" ( python "%CLAUDE_PLUGIN_ROOT%\skills\work-plan\work_plan.py" %* & goto :eof )
|
|
7
|
+
if exist "%USERPROFILE%\.claude\skills\work-plan\work_plan.py" ( python "%USERPROFILE%\.claude\skills\work-plan\work_plan.py" %* & goto :eof )
|
|
8
|
+
echo work-plan: CLI not found. 1>&2
|
|
9
|
+
exit /b 1
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stylusnexus/work-plan",
|
|
3
|
+
"version": "2026.6.9-1",
|
|
4
|
+
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"work-plan": "bin/work-plan"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"skills/work-plan/",
|
|
11
|
+
"scripts/npm-check-deps.js",
|
|
12
|
+
"VERSION",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"postinstall": "node scripts/npm-check-deps.js"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"os": [
|
|
23
|
+
"darwin",
|
|
24
|
+
"linux"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"work-plan",
|
|
28
|
+
"github issues",
|
|
29
|
+
"cli",
|
|
30
|
+
"project planning",
|
|
31
|
+
"tracks"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/stylusnexus/work-plan-toolkit.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/stylusnexus/work-plan-toolkit#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/stylusnexus/work-plan-toolkit/issues"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"_note": "This package.json exists ONLY to distribute the Python CLI via npm (npm install -g @stylusnexus/work-plan). It adds no Node/Python dependencies and no build step — the CLI stays pure Python 3.9 stdlib. The vscode/ extension is a separate Node project with its own package.json."
|
|
43
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Postinstall check for the @stylusnexus/work-plan npm package.
|
|
3
|
+
//
|
|
4
|
+
// The work-plan CLI is pure Python and shells out to three external tools at
|
|
5
|
+
// runtime. npm can't install them (they're not npm packages), so we just check
|
|
6
|
+
// they're on PATH and print a friendly heads-up if any are missing. This NEVER
|
|
7
|
+
// fails the install — a missing tool is the user's to fix, and the CLI prints
|
|
8
|
+
// its own clear error if one is absent when actually run.
|
|
9
|
+
|
|
10
|
+
const { execSync } = require("node:child_process");
|
|
11
|
+
|
|
12
|
+
const TOOLS = [
|
|
13
|
+
{ cmd: "python3", why: "runs the CLI", hint: "https://www.python.org/downloads/ (or `brew install python`)" },
|
|
14
|
+
{ cmd: "yq", why: "parses config + frontmatter (mikefarah/yq, the Go one — NOT the python yq)", hint: "brew install yq · https://github.com/mikefarah/yq" },
|
|
15
|
+
{ cmd: "gh", why: "reads GitHub issue state", hint: "brew install gh · https://cli.github.com" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function have(cmd) {
|
|
19
|
+
try {
|
|
20
|
+
const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
|
|
21
|
+
execSync(probe, { stdio: "ignore", shell: true });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const missing = TOOLS.filter((t) => !have(t.cmd));
|
|
30
|
+
if (missing.length) {
|
|
31
|
+
const lines = [
|
|
32
|
+
"",
|
|
33
|
+
" work-plan installed. It needs a few tools on your PATH that npm can't install:",
|
|
34
|
+
...missing.map((t) => ` • ${t.cmd} — ${t.why}\n ${t.hint}`),
|
|
35
|
+
"",
|
|
36
|
+
" (The CLI will tell you specifically if one is missing when you run it.)",
|
|
37
|
+
"",
|
|
38
|
+
];
|
|
39
|
+
console.warn(lines.join("\n"));
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Never let the check itself break the install.
|
|
43
|
+
}
|
|
44
|
+
process.exit(0);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: work-plan
|
|
3
|
+
description: Use when starting or ending a work session across many GitHub issues, switching between parallel agent sessions on different workstreams, re-orienting on what to do next, sweeping for stale tracking state, or bootstrapping a new repo into a daily-planning system.
|
|
4
|
+
argument-hint: "[brief|handoff|orient|reconcile|hygiene|--help]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Work Plan
|
|
8
|
+
|
|
9
|
+
Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file that references GitHub issues by ID; the CLI derives state live from `gh`/`git`. Composes with `/repo-activity-summary` for the global multi-repo view.
|
|
10
|
+
|
|
11
|
+
## Subcommand reference
|
|
12
|
+
|
|
13
|
+
| Subcommand | When |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `/work-plan brief` | Starting work or after a gap. Multi-track snapshot. |
|
|
16
|
+
| `/work-plan handoff [track] [--auto-next \| --set-next 1,2,3]` | Wrapping up a work block. Captures touched + next + blockers; writes session log. Add `--auto-next` to suggest a priority-sorted next_up list from open issues (interactive: apply / edit / skip). Tracks with `next_up_auto: true` in frontmatter get the auto-derived list surfaced in `brief` automatically. |
|
|
17
|
+
| `/work-plan orient [track]` (alias `where-was-i`) | Re-orienting. With a track: ~15-line track paste-block. Without: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
|
|
18
|
+
| `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Three steps in sequence: ① `refresh-md --all` — pull live GitHub state into every active track's status table (same as "Refresh Track Body" but for all tracks); ② `reconcile --all` — sync track frontmatter membership against GitHub labels; ③ `duplicates` — flag likely-duplicate issues for consolidation. Run once a week to keep status icons, labels, and dedup state honest. `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode (it needs a single explicit repo to be unambiguous). |
|
|
19
|
+
| `/work-plan slot <issue-num> [track]` | A new GitHub issue should belong to a track. If the issue is already listed in another active track's frontmatter, you'll be prompted to move it (remove from source) instead of duplicating. |
|
|
20
|
+
| `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
|
|
21
|
+
| `/work-plan refresh-md <track> \| --all \| --repo=<key>` | **Pull live GitHub state into a track's status table.** Run this after closing or merging issues — it re-fetches each issue's open/closed state from GitHub and rewrites the status cells in the track body, which refreshes the dependency graph and `next_up` display. `--all` sweeps every active track; `--repo=<key>` scopes to one repo. In VS Code: right-click a track → **Refresh Track Body**. |
|
|
22
|
+
| `/work-plan list [--all]` | List active tracks (or all including parked/archived). |
|
|
23
|
+
| `/work-plan init <path>` | Add frontmatter to a new track .md file. |
|
|
24
|
+
| `/work-plan init-repo <key> [--github=<slug>] [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. |
|
|
25
|
+
| `/work-plan suggest-priorities --repo=<key>` | Two-step AI label backfill (one-time migration). |
|
|
26
|
+
| `/work-plan group [--milestone=X] [--label=Y] [--repo=Z]` | Two-step AI clustering: turn a flat list of issues into thematic track files. Powerful for a new milestone or repo re-org — fetches issues, prints a clustering prompt, you save the JSON answer, then `--apply` creates the track files. |
|
|
27
|
+
| `/work-plan auto-triage [--repo=<key>]` | Two-step AI assignment: assign untracked open issues to *existing* tracks. Use after `coverage` shows a gap. Prints a prompt listing untracked issues + active tracks; save AI's JSON answer; re-run with `--apply`. |
|
|
28
|
+
| `/work-plan coverage [--repo=<key>] [--list]` | Report how many open issues are not in any track (per repo). `--list` shows titles. Read-only. Run before `auto-triage` or `group` to measure the gap. |
|
|
29
|
+
| `/work-plan reconcile <track> \| --all [--draft]` | Sync track frontmatter with GitHub labels (read-only on GitHub). Default label is `track/<slug>`; override per-track via `github.labels` in frontmatter. Add `--draft` to preview proposed ADDs/FLAGs without prompting or writing. |
|
|
30
|
+
| `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
|
|
31
|
+
| `/work-plan plan-status [--repo=<key>] [--stamp [--draft]] [--type=plan\|spec]` | **Doc/plan liveness.** "Which of my plan/spec docs actually shipped, half-shipped, or died?" Correlates each plan's declared file-manifest (Create/Modify/Test paths) against git + filesystem — not the unreliable checkboxes. Reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. Read-only by default; `--stamp` writes an idempotent status header into each doc (`--draft` previews, writes nothing). Natural-language triggers: "what's done vs unfinished in `<repo>`", "stamp the plan statuses", "which plans are stale/dead". |
|
|
32
|
+
|
|
33
|
+
## How to invoke
|
|
34
|
+
|
|
35
|
+
All subcommands route through the Python CLI. Prefer the `work-plan` launcher (on
|
|
36
|
+
PATH as a plugin, and installed by `install.sh`): `work-plan <subcommand>`. It
|
|
37
|
+
resolves `work_plan.py` relative to itself, then via `${CLAUDE_PLUGIN_ROOT}` /
|
|
38
|
+
`${PLUGIN_ROOT}` / `~/.claude` / `~/.agents`. If the launcher isn't on PATH, call
|
|
39
|
+
the CLI directly, first match wins:
|
|
40
|
+
|
|
41
|
+
1. `${CLAUDE_PLUGIN_ROOT}/skills/work-plan/work_plan.py` (Claude plugin; Codex sets this too)
|
|
42
|
+
2. `${PLUGIN_ROOT}/skills/work-plan/work_plan.py` (Codex plugin)
|
|
43
|
+
3. `~/.claude/skills/work-plan/work_plan.py` (install.sh → Claude Code)
|
|
44
|
+
4. `~/.agents/skills/work-plan/work_plan.py` (install.sh → Codex)
|
|
45
|
+
|
|
46
|
+
Run via Bash. Don't reimplement the logic in chat.
|
|
47
|
+
|
|
48
|
+
## Verbatim relay (orientation subcommands)
|
|
49
|
+
|
|
50
|
+
For `brief`, `handoff`, `orient` (`where-was-i`), and `hygiene`, the Python output IS the deliverable. After running the Bash command, **reproduce the full Python output verbatim in a fenced code block in your chat reply.** Don't summarize, paraphrase, or truncate — users copy-paste from chat into other terminals/sessions, so any rewording loses information.
|
|
51
|
+
|
|
52
|
+
`plan-status` is also verbatim-relay, with one exception: its report can run to hundreds of docs. If the output is large, relay the headline line (counts + lie-gap) and the actionable **🟡 partial** bucket verbatim, then offer the full report rather than flooding the chat. The `--stamp`/`--draft` summary line (`stamped N doc(s)` / `would stamp N doc(s)`) is always relayed verbatim.
|
|
53
|
+
|
|
54
|
+
## Handoff: Claude-driven `next_up`
|
|
55
|
+
|
|
56
|
+
After running `handoff`:
|
|
57
|
+
|
|
58
|
+
1. Read the output (open issues, last session log, priority, milestone).
|
|
59
|
+
2. Survey the user's project memory (e.g., a `MEMORY.md` index in their working directory or `~/.claude/projects/.../memory/`) for related signals — deploy gates, blocked items, in-flight clusters.
|
|
60
|
+
3. Pick a "next" — single ticket OR tight cluster (2-4 issues) — based on track priority, milestone, what's gating other work, what cluster naturally goes together.
|
|
61
|
+
4. Justify the pick in chat (1-2 sentences).
|
|
62
|
+
5. Persist via `python3 <skill-path>/work_plan.py handoff <track> --set-next <comma-list>` (e.g. `--set-next 4167,4148,4149`).
|
|
63
|
+
6. Show the user what was set so they can override.
|
|
64
|
+
|
|
65
|
+
## Two-step AI subcommands (`suggest-priorities`, `group`, `auto-triage`)
|
|
66
|
+
|
|
67
|
+
All three follow the same pattern:
|
|
68
|
+
|
|
69
|
+
1. CLI fetches issues + writes prompt to terminal (saved to `~/.claude/work-plan/cache/`).
|
|
70
|
+
2. **You** read the issues, output the requested JSON, save via Write tool to the path the CLI printed.
|
|
71
|
+
3. Re-run with `--apply` to commit changes.
|
|
72
|
+
|
|
73
|
+
Show the proposed labels/clusters/assignments BEFORE applying. The user may want to override.
|
|
74
|
+
|
|
75
|
+
**Which one to use:**
|
|
76
|
+
- `group` — issues need to be *clustered into new track files* (run once per milestone or after a re-org)
|
|
77
|
+
- `auto-triage` — untracked issues need to be *assigned to existing tracks* (run after `coverage` shows a gap)
|
|
78
|
+
- `suggest-priorities` — issues need `priority/PN` labels backfilled (one-time migration)
|
|
79
|
+
|
|
80
|
+
## Two-tier track storage (shared vs private)
|
|
81
|
+
|
|
82
|
+
Track files live in one of two places:
|
|
83
|
+
|
|
84
|
+
| Tier | Path | Who sees it |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| **Shared** | `<local-clone>/.work-plan/<slug>.md` | Everyone with repo access (committed + pushed) |
|
|
87
|
+
| **Private** | `<notes_root>/<folder>/<slug>.md` | Local only (never committed) |
|
|
88
|
+
|
|
89
|
+
**Routing logic (automatic):** if a repo has a registered `local:` path that is a valid git repo, new tracks go into `.work-plan/` by default. Pass `--private` to any write command to route to `notes_root` instead.
|
|
90
|
+
|
|
91
|
+
**Setup shared tracks for a repo:**
|
|
92
|
+
```
|
|
93
|
+
/work-plan init-repo myproject --github=org/myproject --local=/path/to/clone
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Syncing shared tracks:** `git pull` pulls teammates' track changes; `git add .work-plan/ && git commit && git push` shares your own. The CLI never auto-pushes.
|
|
97
|
+
|
|
98
|
+
**Disambiguation when the same track slug exists in two repos:**
|
|
99
|
+
```
|
|
100
|
+
/work-plan slot 4234 auth-flow@critforge # @repo qualifier
|
|
101
|
+
/work-plan close auth-flow --repo=critforge # --repo=<key> flag
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Both forms work on: `slot`, `close`, `handoff`, `canonicalize`, `refresh-md`, `reconcile`, `set`.
|
|
105
|
+
|
|
106
|
+
## Track ↔ GitHub label mapping
|
|
107
|
+
|
|
108
|
+
By default, `reconcile` and the `brief` new-issue suggester look for the label `track/<slug>` on GitHub issues. If your repo uses a different scheme (flat labels like `storytelling`, namespaced labels like `area/maps`, or no `track/*` namespace at all), declare the labels per-track in the markdown frontmatter:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
---
|
|
112
|
+
track: storytelling-enhancements
|
|
113
|
+
status: active
|
|
114
|
+
github:
|
|
115
|
+
repo: your-org/your-repo
|
|
116
|
+
labels: [storytelling, campaigns] # OR semantics — issue matches if ANY label is present
|
|
117
|
+
issues: [4296, 4290, ...]
|
|
118
|
+
---
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This is read-only on GitHub: the skill never adds, removes, or rewrites labels on the remote — it only reads them to know which issues belong to a track. The only writes are to your local markdown frontmatter, gated behind interactive confirmation. If `github.labels` is omitted, the default `track/<slug>` pattern is used (existing setups keep working unchanged).
|
|
122
|
+
|
|
123
|
+
## Track ↔ commit attribution
|
|
124
|
+
|
|
125
|
+
`handoff` shows commits attributed to a track since the last handoff. Attribution rules (in order):
|
|
126
|
+
|
|
127
|
+
1. **Explicit branches** — if frontmatter has `github.branches: [feature/x, ...]`, only commits on those branches count. Path globs do not apply.
|
|
128
|
+
2. **Issue mention OR path glob** — otherwise, scan all branches and keep commits whose message (subject OR body) mentions an issue in `github.issues`, OR whose changed paths match any glob in `github.paths` (fnmatch syntax — `*`, `?`, `**`, `[seq]`). Scanning the body matters for squash-merged PRs whose subjects follow Conventional Commits (e.g. `feat(scope): description`) and carry the issue ref in the body (`Closes #1234`).
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
github:
|
|
132
|
+
repo: your-org/your-repo
|
|
133
|
+
issues: [4148, 4149, ...]
|
|
134
|
+
paths:
|
|
135
|
+
- "apps/web/src/components/ux/**"
|
|
136
|
+
- "**/useToast*"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
When zero commits attribute to the track but the repo has activity in the same window, the handoff renders a soft signal (`0 attributed / N repo-wide since last handoff`) so the silence isn't mistaken for "nothing happened."
|
|
140
|
+
|
|
141
|
+
## Setup
|
|
142
|
+
|
|
143
|
+
Run `./install.sh` (macOS / Linux / WSL) or `.\install.ps1` (Windows) from the toolkit root. Then `/work-plan init-repo <key> --github=<org/repo>` to bootstrap your first repo. See the toolkit README for full setup, requirements, and platform-specific install commands.
|
|
144
|
+
|
|
145
|
+
## Common mistakes
|
|
146
|
+
|
|
147
|
+
| Mistake | Fix |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Calling `gh` directly to check issue state | `brief` / `orient` already do it, with track context. |
|
|
150
|
+
| Editing track frontmatter manually | Prefer `handoff` or `slot` — they update timestamps and dedupe. |
|
|
151
|
+
| Forgetting to label issues with `priority/PN` | `brief` sorts by priority; without labels everything looks the same. |
|
|
152
|
+
| Setting `local:` in config to a path that doesn't exist | In-progress detection silently no-ops. Verify path. |
|
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""auto-triage subcommand: AI-assign untracked issues to existing tracks.
|
|
2
|
+
|
|
3
|
+
Two-step (same pattern as `group`):
|
|
4
|
+
1. Run without --apply: fetches untracked open issues, writes a batch file,
|
|
5
|
+
prints a prompt for the AI to assign each issue to an existing track.
|
|
6
|
+
2. Run with --apply: reads the AI's JSON answers and slots each assignment
|
|
7
|
+
into the relevant track's frontmatter.
|
|
8
|
+
|
|
9
|
+
Use --repo=<key> to scope to one configured repo. When the config has a
|
|
10
|
+
single repo, --repo is inferred automatically.
|
|
11
|
+
|
|
12
|
+
Answers JSON format (written to cache/auto_triage.answers.json):
|
|
13
|
+
[
|
|
14
|
+
{"track": "auth-flow", "issues": [4501, 4502]},
|
|
15
|
+
{"track": "tabletop-sessions", "issues": [4503]}
|
|
16
|
+
]
|
|
17
|
+
Issues omitted from every list are left untracked (no error).
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from lib.config import load_config, ConfigError
|
|
26
|
+
from lib.frontmatter import parse_file, write_file
|
|
27
|
+
from lib.scratch import cache_dir
|
|
28
|
+
from lib.tracks import discover_tracks
|
|
29
|
+
from lib.github_state import fetch_open_issues
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _batch_path() -> Path:
|
|
33
|
+
return cache_dir() / "auto_triage.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _answers_path() -> Path:
|
|
37
|
+
return cache_dir() / "auto_triage.answers.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PROMPT_TEMPLATE = """\
|
|
41
|
+
You have a list of EXISTING tracks and a list of UNTRACKED open issues.
|
|
42
|
+
Assign each issue to the most appropriate existing track.
|
|
43
|
+
|
|
44
|
+
Return JSON — an array of assignment objects:
|
|
45
|
+
[
|
|
46
|
+
{"track": "<exact-track-slug>", "issues": [<issue-numbers>]},
|
|
47
|
+
...
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
- Use ONLY the track slugs listed under "Existing tracks" below.
|
|
52
|
+
- An issue can appear in AT MOST ONE track assignment.
|
|
53
|
+
- Omit issues that genuinely don't fit any existing track (they stay untracked).
|
|
54
|
+
- Do NOT invent new tracks — that's /work-plan group's job.
|
|
55
|
+
- Do NOT include empty assignments (issues: []).
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(args: list[str]) -> int:
|
|
61
|
+
apply_mode = "--apply" in args
|
|
62
|
+
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
cfg = load_config()
|
|
66
|
+
except ConfigError as e:
|
|
67
|
+
print(f"ERROR: {e}")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
if apply_mode:
|
|
71
|
+
return _apply(cfg)
|
|
72
|
+
|
|
73
|
+
# -----------------------------------------------------------------------
|
|
74
|
+
# Step 1: fetch untracked issues + print AI prompt
|
|
75
|
+
# -----------------------------------------------------------------------
|
|
76
|
+
repos_cfg = cfg.get("repos", {})
|
|
77
|
+
if repo_arg:
|
|
78
|
+
folder = repo_arg.split("=", 1)[1]
|
|
79
|
+
if folder not in repos_cfg:
|
|
80
|
+
print(f"ERROR: repo folder '{folder}' not in config.yml.")
|
|
81
|
+
return 1
|
|
82
|
+
elif len(repos_cfg) == 1:
|
|
83
|
+
folder = next(iter(repos_cfg))
|
|
84
|
+
else:
|
|
85
|
+
print("Multiple repos in config. Specify with --repo=<folder-name>.")
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
repo = repos_cfg[folder].get("github")
|
|
89
|
+
if not repo:
|
|
90
|
+
print(f"ERROR: repo entry '{folder}' has no 'github' key.")
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
tracks = discover_tracks(cfg)
|
|
94
|
+
active_tracks = [
|
|
95
|
+
t for t in tracks
|
|
96
|
+
if t.has_frontmatter and t.repo == repo
|
|
97
|
+
and t.meta.get("status") in ("active", "in-progress", "blocked")
|
|
98
|
+
]
|
|
99
|
+
if not active_tracks:
|
|
100
|
+
print(f"No active tracks found for {repo}. Run /work-plan group first.")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
# Build per-repo set of already-tracked issue numbers
|
|
104
|
+
tracked_nums: set = set()
|
|
105
|
+
for t in tracks:
|
|
106
|
+
if t.repo == repo and t.has_frontmatter:
|
|
107
|
+
tracked_nums.update(t.meta.get("github", {}).get("issues") or [])
|
|
108
|
+
|
|
109
|
+
print(f"Fetching open issues from {repo}...")
|
|
110
|
+
open_issues = fetch_open_issues(repo, limit=500)
|
|
111
|
+
untracked = [i for i in open_issues if i.get("number") not in tracked_nums]
|
|
112
|
+
|
|
113
|
+
if not untracked:
|
|
114
|
+
print(f"No untracked issues found for {repo} — full coverage!")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
batch_path = _batch_path()
|
|
118
|
+
batch_path.write_text(json.dumps({
|
|
119
|
+
"repo": repo,
|
|
120
|
+
"folder": folder,
|
|
121
|
+
"untracked": untracked,
|
|
122
|
+
"tracks": [{"slug": t.meta.get("track", t.name), "name": t.name,
|
|
123
|
+
"milestone": t.meta.get("milestone_alignment"),
|
|
124
|
+
"priority": t.meta.get("launch_priority")}
|
|
125
|
+
for t in active_tracks],
|
|
126
|
+
}, indent=2))
|
|
127
|
+
|
|
128
|
+
print(f"Found {len(untracked)} untracked issues ({len(active_tracks)} active tracks).")
|
|
129
|
+
print()
|
|
130
|
+
print("=" * 60)
|
|
131
|
+
print(PROMPT_TEMPLATE)
|
|
132
|
+
|
|
133
|
+
print("Existing tracks:")
|
|
134
|
+
for t in active_tracks:
|
|
135
|
+
slug = t.meta.get("track", t.name)
|
|
136
|
+
milestone = t.meta.get("milestone_alignment", "—")
|
|
137
|
+
priority = t.meta.get("launch_priority", "—")
|
|
138
|
+
print(f" {slug} [{priority}, {milestone}]")
|
|
139
|
+
|
|
140
|
+
print()
|
|
141
|
+
print("Untracked issues to assign:")
|
|
142
|
+
for i in untracked:
|
|
143
|
+
num = i.get("number", "?")
|
|
144
|
+
title = i.get("title", "")
|
|
145
|
+
milestone = i.get("milestone") or {}
|
|
146
|
+
m_title = milestone.get("title", "—") if isinstance(milestone, dict) else "—"
|
|
147
|
+
labels = [lb["name"] for lb in (i.get("labels") or [])]
|
|
148
|
+
print(f" #{num} [{m_title}] [{','.join(labels) or 'no-labels'}] {title}")
|
|
149
|
+
|
|
150
|
+
print("=" * 60)
|
|
151
|
+
print()
|
|
152
|
+
print(f"After the agent returns assignment JSON, save it to:")
|
|
153
|
+
print(f" {_answers_path()}")
|
|
154
|
+
print("Then run:")
|
|
155
|
+
print(" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _apply(cfg: dict) -> int:
|
|
160
|
+
answers_path = _answers_path()
|
|
161
|
+
batch_path = _batch_path()
|
|
162
|
+
if not answers_path.exists():
|
|
163
|
+
print(f"ERROR: {answers_path} not found. Run without --apply first.")
|
|
164
|
+
return 1
|
|
165
|
+
if not batch_path.exists():
|
|
166
|
+
print(f"ERROR: {batch_path} not found.")
|
|
167
|
+
return 1
|
|
168
|
+
|
|
169
|
+
batch = json.loads(batch_path.read_text())
|
|
170
|
+
repo = batch["repo"]
|
|
171
|
+
folder = batch["folder"]
|
|
172
|
+
if folder not in cfg.get("repos", {}):
|
|
173
|
+
print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
answers = json.loads(answers_path.read_text())
|
|
177
|
+
|
|
178
|
+
tracks = discover_tracks(cfg)
|
|
179
|
+
tracks_by_slug = {}
|
|
180
|
+
for t in tracks:
|
|
181
|
+
if t.repo == repo and t.has_frontmatter:
|
|
182
|
+
slug = t.meta.get("track", t.name)
|
|
183
|
+
tracks_by_slug[slug] = t
|
|
184
|
+
tracks_by_slug[t.name] = t # also index by name for resilience
|
|
185
|
+
|
|
186
|
+
untracked_nums = {i["number"] for i in batch.get("untracked", [])}
|
|
187
|
+
|
|
188
|
+
slotted = 0
|
|
189
|
+
skipped = 0
|
|
190
|
+
for assignment in answers:
|
|
191
|
+
slug = assignment.get("track", "").strip()
|
|
192
|
+
issue_nums = assignment.get("issues") or []
|
|
193
|
+
if not slug or not issue_nums:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
track = tracks_by_slug.get(slug)
|
|
197
|
+
if not track:
|
|
198
|
+
print(f" WARN: track '{slug}' not found — skipping {len(issue_nums)} issue(s).")
|
|
199
|
+
skipped += len(issue_nums)
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
existing_meta, existing_body = parse_file(track.path)
|
|
203
|
+
if not existing_meta:
|
|
204
|
+
print(f" SKIP {slug}: file exists but has no frontmatter.")
|
|
205
|
+
skipped += len(issue_nums)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
|
|
209
|
+
existing_set = set(existing_issues)
|
|
210
|
+
new_nums = [n for n in issue_nums if n in untracked_nums and n not in existing_set]
|
|
211
|
+
already_there = [n for n in issue_nums if n in existing_set]
|
|
212
|
+
|
|
213
|
+
if already_there:
|
|
214
|
+
print(f" ℹ {slug}: #{','.join(str(n) for n in already_there)} already present.")
|
|
215
|
+
if not new_nums:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
merged = sorted(existing_set | set(new_nums))
|
|
219
|
+
existing_meta.setdefault("github", {})["issues"] = merged
|
|
220
|
+
existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
221
|
+
write_file(track.path, existing_meta, existing_body)
|
|
222
|
+
print(f" ✓ {slug}: added #{','.join(str(n) for n in new_nums)} "
|
|
223
|
+
f"({len(merged)} issues total)")
|
|
224
|
+
slotted += len(new_nums)
|
|
225
|
+
|
|
226
|
+
print()
|
|
227
|
+
print(f"Done: {slotted} issue(s) assigned, {skipped} skipped.")
|
|
228
|
+
if slotted:
|
|
229
|
+
print("Next: run /work-plan brief to see the updated tracks.")
|
|
230
|
+
return 0
|