@stylusnexus/work-plan 2026.6.16 → 2026.6.18
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/README.md +2 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +2 -2
package/README.md
CHANGED
|
@@ -514,7 +514,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
514
514
|
| `which-repo [--json]` | Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only; it's the shared resolver behind `brief`'s cwd auto-scope and the VS Code viewer's repo auto-focus. |
|
|
515
515
|
| `handoff <track> [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — <ts>` entry. `--auto-next` suggests a priority-sorted top-3 from open issues (interactive: apply / edit / skip). `--set-next 1,2,3` is the explicit form — note it writes the session entry too; for a field-only `next_up` change with no session log, use `set next_up=…`. Without either flag, just captures the session summary and reads any pre-existing `next_up`. |
|
|
516
516
|
| `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
|
|
517
|
-
| `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below). |
|
|
517
|
+
| `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below), `--expect=<fp>` (compare-and-swap: the fingerprint of the track's issue list as the caller last saw it — if the on-disk list changed since, the write aborts with a `{stale}` JSON signal instead of clobbering; used by the viewer's assisted-slot flow, #241). The write always re-reads the file and merges onto fresh disk; for a shared-tier track on a `plan_branch` it fetch+rebases the worktree first and aborts with `{needs_rebase}` on an un-rebasable divergence. |
|
|
518
518
|
| `close <track> [--state=shipped\|parked\|abandoned] [--note=<text>]` | Mark track shipped, parked, or abandoned. Moves to `archive/<state>/` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. |
|
|
519
519
|
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. If the live fetch comes back incomplete (GitHub timeout/permission error, or a frontmatter issue that no longer resolves), that track is **skipped and left untouched** rather than rewriting valid rows as `(not fetched)`, and the command exits nonzero so sweeps can flag the degraded run. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
520
520
|
| `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `dedupe-tiers` (report-only) + `duplicates`. With `--repo=<key>`, steps 1–3 scope to that repo and the global `duplicates` step is skipped. |
|
|
@@ -532,7 +532,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
532
532
|
| `plan-branch <init\|status\|push> <repo> [--branch=<name>] [--confirm=<token>] [--dry-run] [--json]` | Set up and share a repo's canonical **shared-tier** plan branch. The `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init` creates that branch + a `.work-plan/` skeleton (default an **orphan** `work-plan/plan` — zero shared history with code, like `gh-pages`; override with `--branch`) and records `plan_branch` in config — or **connects** to a teammate's already-published branch if one exists. `init` is **local only** (no push). `status` reports whether the branch exists, is published to origin, and how many commits are unpushed (`--json` for the machine shape). `push` shares it: on a **public** repo it prints a confirm heads-up + token and exits (re-run with `--confirm=<token>`); `--dry-run` previews the commits that would push. Requires a repo registered via `init-repo` with a local clone path. |
|
|
533
533
|
| `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
|
|
534
534
|
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
|
|
535
|
-
| `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
535
|
+
| `auto-triage [--repo=<key>] [--apply] [--json] [--heuristic] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`); the scan stamps a `batch_id` and writes a per-repo cache file. `--json` emits the batch (+ prompt + answers path) as one JSON object on stdout for the VS Code viewer's Suggested bucket (#241) instead of the human prompt. `--heuristic` (#373) skips the LLM: it scores each issue against the candidate tracks with local signals (milestone match, track-label overlap, title/scope keyword overlap) and writes the v2 answers file itself (`source: "heuristic"`, abstain-first) — so suggestions work with no Claude session (lower-trust, offline). `--apply` accepts the **v2** abstain-first answers shape (`{version,batch_id,suggestions:[{issue,verdict,track,confidence,margin,rationale}]}` — only clear-margin `suggest` verdicts are slotted; abstains/narrow stay untracked) as well as the legacy v1 `[{track,issues}]`. Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
536
536
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
537
537
|
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews the label drift (ADDs/MOVEs/FLAGs) without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
538
538
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.18+64ae461
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.18",
|
|
4
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
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -75,6 +75,8 @@ All three follow the same pattern:
|
|
|
75
75
|
|
|
76
76
|
Show the proposed labels/clusters/assignments BEFORE applying. The user may want to override.
|
|
77
77
|
|
|
78
|
+
**`auto-triage` answers — write atomically and copy the `batch_id`.** The VS Code viewer watches the answers file and reads suggestions live (#241), so a half-written file would be read mid-write. Write to a `.tmp` sibling and then rename it onto the final `auto_triage.<repo>.answers.json` path the CLI printed (the Write tool's create-then-replace is fine; never append). Use the **v2** answers shape — `{"version": 2, "batch_id": "<copy from the scan output>", "suggestions": [...]}` — and copy the `batch_id` the scan printed so the viewer can tell fresh suggestions from a stale older scan. Prefer `"verdict": "abstain"` for issues with no clear home: most untracked issues genuinely have none, and a wrong suggestion a human rubber-stamps is worse than silence.
|
|
79
|
+
|
|
78
80
|
**Which one to use:**
|
|
79
81
|
- `group` — issues need to be *clustered into new track files* (run once per milestone or after a re-org)
|
|
80
82
|
- `auto-triage` — untracked issues need to be *assigned to existing tracks* (run after `coverage` shows a gap)
|
|
@@ -9,14 +9,30 @@ Two-step (same pattern as `group`):
|
|
|
9
9
|
Use --repo=<key> to scope to one configured repo. When the config has a
|
|
10
10
|
single repo, --repo is inferred automatically.
|
|
11
11
|
|
|
12
|
-
Answers JSON
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
Answers JSON — two accepted shapes (the reader sniffs which one):
|
|
13
|
+
|
|
14
|
+
v1 (legacy, still accepted):
|
|
15
|
+
[
|
|
16
|
+
{"track": "auth-flow", "issues": [4501, 4502]},
|
|
17
|
+
{"track": "tabletop-sessions", "issues": [4503]}
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
v2 (preferred — abstain-first, per-issue, carries confidence/rationale the
|
|
21
|
+
VS Code viewer renders; #241):
|
|
22
|
+
{"version": 2, "batch_id": "<from the batch file>", "suggestions": [
|
|
23
|
+
{"issue": 4501, "verdict": "suggest", "track": "auth-flow",
|
|
24
|
+
"runner_up": "tabletop-sessions", "confidence": 0.82, "margin": "clear",
|
|
25
|
+
"rationale": "shares milestone v0.4.0 and label area/auth"},
|
|
26
|
+
{"issue": 4507, "verdict": "abstain", "rationale": "no track covers billing"}
|
|
27
|
+
]}
|
|
28
|
+
|
|
29
|
+
In v2 `--apply` slots only verdict=="suggest" assignments whose margin is "clear"
|
|
30
|
+
(narrow-margin / abstained issues stay untracked — the safe default). Issues
|
|
31
|
+
omitted entirely are left untracked (no error).
|
|
18
32
|
"""
|
|
33
|
+
import hashlib
|
|
19
34
|
import json
|
|
35
|
+
import os
|
|
20
36
|
import subprocess
|
|
21
37
|
import sys
|
|
22
38
|
from datetime import datetime
|
|
@@ -29,36 +45,61 @@ from lib.tracks import discover_tracks
|
|
|
29
45
|
from lib.github_state import fetch_open_issues
|
|
30
46
|
|
|
31
47
|
|
|
32
|
-
def
|
|
33
|
-
|
|
48
|
+
def _repo_slug(repo) -> str:
|
|
49
|
+
"""Filesystem-safe slug for a repo's per-repo cache files (#241): two repos
|
|
50
|
+
never collide on the single fixed cache path (a multi-repo clobber race)."""
|
|
51
|
+
return (repo or "").replace("/", "_")
|
|
34
52
|
|
|
35
53
|
|
|
36
|
-
def
|
|
37
|
-
|
|
54
|
+
def _batch_path(repo=None) -> Path:
|
|
55
|
+
name = f"auto_triage.{_repo_slug(repo)}.json" if repo else "auto_triage.json"
|
|
56
|
+
return cache_dir() / name
|
|
38
57
|
|
|
39
58
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
def _answers_path(repo=None) -> Path:
|
|
60
|
+
name = f"auto_triage.{_repo_slug(repo)}.answers.json" if repo else "auto_triage.answers.json"
|
|
61
|
+
return cache_dir() / name
|
|
43
62
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
|
|
64
|
+
def _make_batch_id(repo: str) -> str:
|
|
65
|
+
"""A short id correlating an answers file to the batch that produced it. The
|
|
66
|
+
viewer checks it so a stale answers file from an older scan isn't rendered as
|
|
67
|
+
current (mtime alone can't tell — answers are always newer than the batch)."""
|
|
68
|
+
stamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
|
69
|
+
return hashlib.sha256(f"{repo}:{stamp}".encode("utf-8")).hexdigest()[:12]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
PROMPT_TEMPLATE = """\
|
|
73
|
+
For EACH untracked issue below, decide whether one of the EXISTING tracks is a
|
|
74
|
+
clearly correct home — and if not, ABSTAIN. Most untracked issues will NOT have
|
|
75
|
+
a clear home; that is normal and correct. Only suggest a track when the issue is
|
|
76
|
+
unmistakably about that track's scope.
|
|
77
|
+
|
|
78
|
+
Return JSON in this exact shape:
|
|
79
|
+
{"version": 2, "batch_id": "<copy the batch_id printed below>", "suggestions": [
|
|
80
|
+
{"issue": <num>, "verdict": "suggest", "track": "<exact-track-slug>",
|
|
81
|
+
"runner_up": "<second-best slug or null>", "confidence": <0.0-1.0>,
|
|
82
|
+
"margin": "clear" | "narrow",
|
|
83
|
+
"rationale": "<the concrete shared signal: a label, milestone, or scope keyword>"},
|
|
84
|
+
{"issue": <num>, "verdict": "abstain", "rationale": "<why no track fits>"}
|
|
85
|
+
]}
|
|
49
86
|
|
|
50
87
|
Rules:
|
|
51
88
|
- Use ONLY the track slugs listed under "Existing tracks" below.
|
|
52
|
-
-
|
|
53
|
-
|
|
89
|
+
- Name your top choice AND your runner-up. If you cannot clearly distinguish
|
|
90
|
+
them, set "margin": "narrow" — that means neither is clearly right.
|
|
91
|
+
- "rationale" must cite a CONCRETE shared signal (a label, a milestone, a scope
|
|
92
|
+
keyword). "Generally related" is not a valid reason — abstain instead.
|
|
93
|
+
- When in doubt, ABSTAIN (verdict "abstain", no track). A wrong suggestion a
|
|
94
|
+
human rubber-stamps is worse than no suggestion.
|
|
54
95
|
- Do NOT invent new tracks — that's /work-plan group's job.
|
|
55
|
-
- Do NOT include empty assignments (issues: []).
|
|
56
96
|
|
|
57
97
|
"""
|
|
58
98
|
|
|
59
99
|
|
|
60
100
|
def run(args: list[str]) -> int:
|
|
61
101
|
apply_mode = "--apply" in args
|
|
102
|
+
heuristic = "--heuristic" in args
|
|
62
103
|
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
63
104
|
|
|
64
105
|
limit = 100
|
|
@@ -77,7 +118,13 @@ def run(args: list[str]) -> int:
|
|
|
77
118
|
return 1
|
|
78
119
|
|
|
79
120
|
if apply_mode:
|
|
80
|
-
|
|
121
|
+
# Resolve repo for per-repo cache files; no --repo falls back to the
|
|
122
|
+
# legacy fixed filenames (back-compat with the pre-#241 terminal flow).
|
|
123
|
+
apply_repo = None
|
|
124
|
+
if repo_arg:
|
|
125
|
+
folder = repo_arg.split("=", 1)[1]
|
|
126
|
+
apply_repo = cfg.get("repos", {}).get(folder, {}).get("github")
|
|
127
|
+
return _apply(cfg, apply_repo)
|
|
81
128
|
|
|
82
129
|
# -----------------------------------------------------------------------
|
|
83
130
|
# Step 1: fetch untracked issues + print AI prompt
|
|
@@ -115,7 +162,8 @@ def run(args: list[str]) -> int:
|
|
|
115
162
|
if t.repo == repo and t.has_frontmatter:
|
|
116
163
|
tracked_nums.update(t.meta.get("github", {}).get("issues") or [])
|
|
117
164
|
|
|
118
|
-
|
|
165
|
+
# Progress goes to stderr so --json keeps stdout a single clean JSON object.
|
|
166
|
+
print(f"Fetching open issues from {repo}...", file=sys.stderr)
|
|
119
167
|
open_issues = fetch_open_issues(repo, limit=500)
|
|
120
168
|
untracked = [i for i in open_issues if i.get("number") not in tracked_nums]
|
|
121
169
|
|
|
@@ -123,28 +171,69 @@ def run(args: list[str]) -> int:
|
|
|
123
171
|
print(f"No untracked issues found for {repo} — full coverage!")
|
|
124
172
|
return 0
|
|
125
173
|
|
|
126
|
-
|
|
127
|
-
|
|
174
|
+
batch_id = _make_batch_id(repo)
|
|
175
|
+
batch_obj = {
|
|
176
|
+
"batch_id": batch_id,
|
|
128
177
|
"repo": repo,
|
|
129
178
|
"folder": folder,
|
|
130
179
|
"untracked": untracked,
|
|
131
180
|
"tracks": [{"slug": t.meta.get("track", t.name), "name": t.name,
|
|
132
181
|
"milestone": t.meta.get("milestone_alignment"),
|
|
133
|
-
"priority": t.meta.get("launch_priority")
|
|
182
|
+
"priority": t.meta.get("launch_priority"),
|
|
183
|
+
# Scope/description grounds the match on what the track is
|
|
184
|
+
# FOR, not just its slug string (#241, ai-engineer review).
|
|
185
|
+
"scope": t.meta.get("scope") or t.meta.get("description") or ""}
|
|
134
186
|
for t in active_tracks],
|
|
135
|
-
}
|
|
187
|
+
}
|
|
188
|
+
batch_path = _batch_path(repo)
|
|
189
|
+
batch_path.write_text(json.dumps(batch_obj, indent=2))
|
|
190
|
+
|
|
191
|
+
# --heuristic (#373): compute suggestions deterministically (no LLM) and
|
|
192
|
+
# write the v2 answers file ourselves, so the Suggested bucket works with no
|
|
193
|
+
# Claude session. Same schema the LLM path produces, stamped
|
|
194
|
+
# source:"heuristic" so the viewer can flag it lower-trust. Atomic
|
|
195
|
+
# (.tmp + os.replace) since the viewer watches this path live.
|
|
196
|
+
if heuristic:
|
|
197
|
+
from lib.heuristic_triage import score_suggestions
|
|
198
|
+
suggestions = score_suggestions(
|
|
199
|
+
untracked,
|
|
200
|
+
[{"slug": t.meta.get("track", t.name), "name": t.name,
|
|
201
|
+
"milestone": t.meta.get("milestone_alignment"),
|
|
202
|
+
"scope": t.meta.get("scope") or t.meta.get("description") or "",
|
|
203
|
+
"labels": (t.meta.get("github", {}) or {}).get("labels") or []}
|
|
204
|
+
for t in active_tracks],
|
|
205
|
+
)
|
|
206
|
+
answers_path = _answers_path(repo)
|
|
207
|
+
tmp = answers_path.with_suffix(answers_path.suffix + ".tmp")
|
|
208
|
+
tmp.write_text(json.dumps(
|
|
209
|
+
{"version": 2, "source": "heuristic", "batch_id": batch_id,
|
|
210
|
+
"suggestions": suggestions}, indent=2))
|
|
211
|
+
os.replace(tmp, answers_path)
|
|
212
|
+
|
|
213
|
+
# --json: emit the batch (+ prompt + answers path) as one machine-readable
|
|
214
|
+
# object for the VS Code viewer, which captures batch_id to correlate the
|
|
215
|
+
# answers a Claude session writes back (#241). No human prose on stdout.
|
|
216
|
+
if "--json" in args:
|
|
217
|
+
print(json.dumps({**batch_obj,
|
|
218
|
+
"prompt": PROMPT_TEMPLATE,
|
|
219
|
+
"answers_path": str(_answers_path(repo))}))
|
|
220
|
+
return 0
|
|
136
221
|
|
|
137
222
|
print(f"Found {len(untracked)} untracked issues ({len(active_tracks)} active tracks).")
|
|
138
223
|
print()
|
|
139
224
|
print("=" * 60)
|
|
140
225
|
print(PROMPT_TEMPLATE)
|
|
141
226
|
|
|
227
|
+
print(f"batch_id: {batch_id} (copy into the answers JSON)")
|
|
228
|
+
print()
|
|
142
229
|
print("Existing tracks:")
|
|
143
230
|
for t in active_tracks:
|
|
144
231
|
slug = t.meta.get("track", t.name)
|
|
145
232
|
milestone = t.meta.get("milestone_alignment", "—")
|
|
146
233
|
priority = t.meta.get("launch_priority", "—")
|
|
147
|
-
|
|
234
|
+
scope = t.meta.get("scope") or t.meta.get("description") or ""
|
|
235
|
+
scope_txt = f" — {scope}" if scope else ""
|
|
236
|
+
print(f" {slug} [{priority}, {milestone}]{scope_txt}")
|
|
148
237
|
|
|
149
238
|
print()
|
|
150
239
|
print("Untracked issues to assign:")
|
|
@@ -162,16 +251,75 @@ def run(args: list[str]) -> int:
|
|
|
162
251
|
|
|
163
252
|
print("=" * 60)
|
|
164
253
|
print()
|
|
165
|
-
|
|
166
|
-
|
|
254
|
+
if heuristic:
|
|
255
|
+
print(f"Heuristic suggestions written to:")
|
|
256
|
+
print(f" {_answers_path(repo)}")
|
|
257
|
+
print("They appear in the VS Code Suggested bucket, or apply from the terminal:")
|
|
258
|
+
print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}")
|
|
259
|
+
return 0
|
|
260
|
+
print(f"After the agent returns assignment JSON, save it (atomically — write")
|
|
261
|
+
print(f"a .tmp then rename) to:")
|
|
262
|
+
print(f" {_answers_path(repo)}")
|
|
167
263
|
print("Then run:")
|
|
168
|
-
print(" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply")
|
|
264
|
+
print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}")
|
|
169
265
|
return 0
|
|
170
266
|
|
|
171
267
|
|
|
172
|
-
def
|
|
173
|
-
|
|
174
|
-
|
|
268
|
+
def _normalize_answers(answers, batch_id=None):
|
|
269
|
+
"""Collapse either answers shape into v1 assignment objects [{track, issues}].
|
|
270
|
+
|
|
271
|
+
- v2 (dict with "suggestions"): keep only verdict=="suggest" whose margin is
|
|
272
|
+
not "narrow" (abstains and narrow-margin items stay untracked — the safe
|
|
273
|
+
default), group by track. confidence/rationale are for the viewer; the
|
|
274
|
+
write ignores them.
|
|
275
|
+
- v1 (list): passed through.
|
|
276
|
+
|
|
277
|
+
The file is model-authored/untrusted, so every field is hardened: issue
|
|
278
|
+
numbers int-coerced, malformed entries skipped, unknown shapes ignored.
|
|
279
|
+
Returns (assignments, batch_mismatch: bool).
|
|
280
|
+
"""
|
|
281
|
+
mismatch = False
|
|
282
|
+
if isinstance(answers, dict) and "suggestions" in answers:
|
|
283
|
+
if batch_id and answers.get("batch_id") and answers["batch_id"] != batch_id:
|
|
284
|
+
mismatch = True
|
|
285
|
+
by_track: dict = {}
|
|
286
|
+
for s in answers.get("suggestions") or []:
|
|
287
|
+
if not isinstance(s, dict):
|
|
288
|
+
continue
|
|
289
|
+
if s.get("verdict") != "suggest":
|
|
290
|
+
continue
|
|
291
|
+
if s.get("margin") == "narrow":
|
|
292
|
+
continue
|
|
293
|
+
slug = (s.get("track") or "").strip()
|
|
294
|
+
if not slug:
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
num = int(s.get("issue"))
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
continue
|
|
300
|
+
by_track.setdefault(slug, []).append(num)
|
|
301
|
+
return ([{"track": k, "issues": v} for k, v in by_track.items()], mismatch)
|
|
302
|
+
|
|
303
|
+
# v1 legacy list.
|
|
304
|
+
out = []
|
|
305
|
+
for a in answers if isinstance(answers, list) else []:
|
|
306
|
+
if not isinstance(a, dict):
|
|
307
|
+
continue
|
|
308
|
+
slug = (a.get("track") or "").strip()
|
|
309
|
+
nums = []
|
|
310
|
+
for n in a.get("issues") or []:
|
|
311
|
+
try:
|
|
312
|
+
nums.append(int(n))
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
continue
|
|
315
|
+
if slug and nums:
|
|
316
|
+
out.append({"track": slug, "issues": nums})
|
|
317
|
+
return (out, mismatch)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _apply(cfg: dict, repo: str = None) -> int:
|
|
321
|
+
answers_path = _answers_path(repo)
|
|
322
|
+
batch_path = _batch_path(repo)
|
|
175
323
|
if not answers_path.exists():
|
|
176
324
|
print(f"ERROR: {answers_path} not found. Run without --apply first.")
|
|
177
325
|
return 1
|
|
@@ -186,7 +334,11 @@ def _apply(cfg: dict) -> int:
|
|
|
186
334
|
print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
|
|
187
335
|
return 1
|
|
188
336
|
|
|
189
|
-
|
|
337
|
+
raw_answers = json.loads(answers_path.read_text())
|
|
338
|
+
answers, batch_mismatch = _normalize_answers(raw_answers, batch.get("batch_id"))
|
|
339
|
+
if batch_mismatch:
|
|
340
|
+
print("⚠ answers batch_id does not match the current batch — these "
|
|
341
|
+
"suggestions may be from an older scan. Re-run without --apply to refresh.")
|
|
190
342
|
|
|
191
343
|
tracks = discover_tracks(cfg)
|
|
192
344
|
tracks_by_slug = {}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""batch-slot subcommand — slot multiple issues into a track at once."""
|
|
2
2
|
import json
|
|
3
3
|
import subprocess
|
|
4
|
+
import sys
|
|
4
5
|
|
|
5
6
|
from lib.config import load_config, ConfigError
|
|
6
7
|
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
7
|
-
from lib.
|
|
8
|
+
from lib.membership_guard import guarded_membership_write, shared_rebase_guard
|
|
8
9
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
9
10
|
from lib.prompts import parse_flags
|
|
10
11
|
|
|
@@ -25,7 +26,7 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
|
|
|
25
26
|
|
|
26
27
|
def run(args: list[str]) -> int:
|
|
27
28
|
flags, positional = parse_flags(
|
|
28
|
-
args, {"--confirm", "--move", "--no-move", "--repo"}
|
|
29
|
+
args, {"--confirm", "--move", "--no-move", "--repo", "--expect"}
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
if len(positional) < 2:
|
|
@@ -99,8 +100,22 @@ def run(args: list[str]) -> int:
|
|
|
99
100
|
)
|
|
100
101
|
return 0
|
|
101
102
|
|
|
103
|
+
# Shared-tier rebase (#241): pull + rebase the plan_branch worktree onto
|
|
104
|
+
# origin before writing; an un-rebasable divergence aborts cleanly.
|
|
105
|
+
ok, reason = shared_rebase_guard(target, cfg)
|
|
106
|
+
if not ok:
|
|
107
|
+
print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name}))
|
|
108
|
+
return 0
|
|
109
|
+
|
|
102
110
|
do_move = "--move" in flags
|
|
103
111
|
|
|
112
|
+
# --expect=<fp> opts into the compare-and-swap staleness guard (#241). When
|
|
113
|
+
# present (the assisted/viewer path) advisory notes go to stderr so stdout
|
|
114
|
+
# stays pure for the {stale} abort signal the caller parses.
|
|
115
|
+
expect = flags.get("--expect")
|
|
116
|
+
expect = expect if isinstance(expect, str) else None
|
|
117
|
+
notes = sys.stderr if expect is not None else sys.stdout
|
|
118
|
+
|
|
104
119
|
# Collect source tracks that need issue removal (consolidated per source).
|
|
105
120
|
source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num])
|
|
106
121
|
|
|
@@ -113,24 +128,29 @@ def run(args: list[str]) -> int:
|
|
|
113
128
|
skipped.append(issue_num)
|
|
114
129
|
continue
|
|
115
130
|
|
|
116
|
-
# Milestone mismatch check (non-blocking warning).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
m
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
# Milestone mismatch check (non-blocking warning). Never let gh being
|
|
132
|
+
# absent/odd crash the command — it's advisory and sits before the write.
|
|
133
|
+
try:
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
["gh", "issue", "view", str(issue_num),
|
|
136
|
+
"--repo", target.repo, "--json", "milestone"],
|
|
137
|
+
capture_output=True, text=True,
|
|
138
|
+
)
|
|
139
|
+
if proc.returncode == 0:
|
|
140
|
+
info = json.loads(proc.stdout)
|
|
141
|
+
m = info.get("milestone", {})
|
|
142
|
+
if (
|
|
143
|
+
m and m.get("title")
|
|
144
|
+
and m["title"] != target.meta.get("milestone_alignment")
|
|
145
|
+
):
|
|
146
|
+
print(
|
|
147
|
+
f"⚠ #{issue_num} is on milestone '{m['title']}', "
|
|
148
|
+
f"track '{target.name}' aligned to"
|
|
149
|
+
f" '{target.meta.get('milestone_alignment')}'.",
|
|
150
|
+
file=notes,
|
|
151
|
+
)
|
|
152
|
+
except (OSError, json.JSONDecodeError):
|
|
153
|
+
pass
|
|
134
154
|
|
|
135
155
|
# Prior-owner detection.
|
|
136
156
|
sources = _find_prior_owners(
|
|
@@ -149,7 +169,8 @@ def run(args: list[str]) -> int:
|
|
|
149
169
|
names = ", ".join(f"'{t.name}'" for t in sources)
|
|
150
170
|
print(
|
|
151
171
|
f"ℹ #{issue_num} still listed in {names}"
|
|
152
|
-
f" — re-run with --move to relocate."
|
|
172
|
+
f" — re-run with --move to relocate.",
|
|
173
|
+
file=notes,
|
|
153
174
|
)
|
|
154
175
|
|
|
155
176
|
if not slotted:
|
|
@@ -160,21 +181,25 @@ def run(args: list[str]) -> int:
|
|
|
160
181
|
)
|
|
161
182
|
return 0
|
|
162
183
|
|
|
163
|
-
# Write source tracks (consolidated removals)
|
|
184
|
+
# Write source tracks (consolidated removals), each re-read + merged onto
|
|
185
|
+
# fresh disk so a concurrent edit to a source track isn't clobbered.
|
|
164
186
|
if do_move:
|
|
165
187
|
for src_name, (src, removals) in source_removals.items():
|
|
166
|
-
|
|
167
|
-
n for n in (src.meta.get("github", {}).get("issues") or [])
|
|
168
|
-
if n not in removals
|
|
169
|
-
]
|
|
170
|
-
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
171
|
-
write_file(src.path, src.meta, src.body)
|
|
188
|
+
guarded_membership_write(src.path, remove_nums=removals)
|
|
172
189
|
removed_str = ", ".join(f"#{n}" for n in sorted(removals))
|
|
173
|
-
print(f" ✓ Removed {removed_str} from '{src_name}'.")
|
|
174
|
-
|
|
175
|
-
# Write target track once.
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
print(f" ✓ Removed {removed_str} from '{src_name}'.", file=notes)
|
|
191
|
+
|
|
192
|
+
# Write target track once. Carries `expect`: on a detected concurrent change
|
|
193
|
+
# to the membership list it aborts with {stale} instead of clobbering.
|
|
194
|
+
result = guarded_membership_write(target.path, add_nums=slotted, expect=expect)
|
|
195
|
+
if result.get("stale"):
|
|
196
|
+
print(json.dumps({
|
|
197
|
+
"stale": True,
|
|
198
|
+
"reason": result["reason"],
|
|
199
|
+
"current": result["current"],
|
|
200
|
+
"track": target.name,
|
|
201
|
+
}))
|
|
202
|
+
return 0
|
|
178
203
|
|
|
179
204
|
slotted_str = ", ".join(f"#{n}" for n in slotted)
|
|
180
205
|
print(f"✓ Slotted {slotted_str} into '{target.name}'.")
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""slot subcommand."""
|
|
2
2
|
import json
|
|
3
3
|
import subprocess
|
|
4
|
+
import sys
|
|
4
5
|
|
|
5
6
|
from lib.config import load_config, ConfigError
|
|
6
7
|
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
7
|
-
from lib.
|
|
8
|
+
from lib.membership_guard import guarded_membership_write, shared_rebase_guard
|
|
8
9
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
9
10
|
from lib.prompts import parse_flags, prompt_input
|
|
10
11
|
|
|
@@ -28,7 +29,7 @@ def run(args: list[str]) -> int:
|
|
|
28
29
|
# --confirm uses equals form: --confirm=<token>
|
|
29
30
|
# --move / --no-move are bare flags
|
|
30
31
|
# --repo uses equals form: --repo=<key>
|
|
31
|
-
flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
|
|
32
|
+
flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo", "--expect"})
|
|
32
33
|
if not positional:
|
|
33
34
|
print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
|
|
34
35
|
return 2
|
|
@@ -116,39 +117,67 @@ def run(args: list[str]) -> int:
|
|
|
116
117
|
}))
|
|
117
118
|
return 0
|
|
118
119
|
|
|
120
|
+
# Shared-tier rebase (#241): for a track pinned to a plan_branch, pull +
|
|
121
|
+
# rebase the worktree onto origin before writing so a teammate's pushed plan
|
|
122
|
+
# change isn't clobbered. A divergence we can't auto-rebase aborts cleanly.
|
|
123
|
+
ok, reason = shared_rebase_guard(target, cfg)
|
|
124
|
+
if not ok:
|
|
125
|
+
print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name}))
|
|
126
|
+
return 0
|
|
127
|
+
|
|
119
128
|
# Determine move behavior from flags.
|
|
120
129
|
# --move: remove issue from prior owners.
|
|
121
130
|
# Default / --no-move: add-only; print a note naming prior owners.
|
|
122
131
|
do_move = "--move" in flags
|
|
123
132
|
|
|
124
|
-
|
|
133
|
+
# --expect=<fp> opts into the compare-and-swap staleness guard (#241). When
|
|
134
|
+
# present (the assisted/viewer path), advisory notes go to stderr so stdout
|
|
135
|
+
# stays pure for the {stale} abort signal the caller parses.
|
|
136
|
+
expect = flags.get("--expect")
|
|
137
|
+
expect = expect if isinstance(expect, str) else None
|
|
138
|
+
notes = sys.stderr if expect is not None else sys.stdout
|
|
125
139
|
|
|
126
|
-
|
|
127
|
-
target.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
128
|
-
|
|
129
|
-
proc = subprocess.run(
|
|
130
|
-
["gh", "issue", "view", str(issue_num),
|
|
131
|
-
"--repo", target.repo, "--json", "milestone"],
|
|
132
|
-
capture_output=True, text=True,
|
|
133
|
-
)
|
|
134
|
-
if proc.returncode == 0:
|
|
135
|
-
info = json.loads(proc.stdout)
|
|
136
|
-
m = info.get("milestone", {})
|
|
137
|
-
if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
|
|
138
|
-
print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
|
|
139
|
-
f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.")
|
|
140
|
+
sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
|
|
140
141
|
|
|
142
|
+
# Milestone mismatch is advisory only — never let gh being absent/odd crash
|
|
143
|
+
# the command (it sits between the rebase guard and the write).
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.run(
|
|
146
|
+
["gh", "issue", "view", str(issue_num),
|
|
147
|
+
"--repo", target.repo, "--json", "milestone"],
|
|
148
|
+
capture_output=True, text=True,
|
|
149
|
+
)
|
|
150
|
+
if proc.returncode == 0:
|
|
151
|
+
info = json.loads(proc.stdout)
|
|
152
|
+
m = info.get("milestone", {})
|
|
153
|
+
if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
|
|
154
|
+
print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
|
|
155
|
+
f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.",
|
|
156
|
+
file=notes)
|
|
157
|
+
except (OSError, json.JSONDecodeError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Move sources first (each re-read + merged onto fresh disk), then the
|
|
161
|
+
# target. The target write carries `expect`: on a detected concurrent change
|
|
162
|
+
# to the membership list it aborts with {stale} instead of clobbering.
|
|
141
163
|
if sources and do_move:
|
|
142
164
|
for src in sources:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
146
|
-
write_file(src.path, src.meta, src.body)
|
|
147
|
-
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
165
|
+
guarded_membership_write(src.path, remove_nums=[issue_num])
|
|
166
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.", file=notes)
|
|
148
167
|
elif sources and not do_move:
|
|
149
168
|
names = ", ".join(f"'{t.name}'" for t in sources)
|
|
150
|
-
print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate."
|
|
169
|
+
print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.",
|
|
170
|
+
file=notes)
|
|
171
|
+
|
|
172
|
+
result = guarded_membership_write(target.path, add_nums=[issue_num], expect=expect)
|
|
173
|
+
if result.get("stale"):
|
|
174
|
+
print(json.dumps({
|
|
175
|
+
"stale": True,
|
|
176
|
+
"reason": result["reason"],
|
|
177
|
+
"current": result["current"],
|
|
178
|
+
"track": target.name,
|
|
179
|
+
}))
|
|
180
|
+
return 0
|
|
151
181
|
|
|
152
|
-
write_file(target.path, target.meta, target.body)
|
|
153
182
|
print(f"✓ Slotted #{issue_num} into '{target.name}'.")
|
|
154
183
|
return 0
|