@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 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.16+ebd6045
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.16",
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 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).
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 _batch_path() -> Path:
33
- return cache_dir() / "auto_triage.json"
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 _answers_path() -> Path:
37
- return cache_dir() / "auto_triage.answers.json"
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
- 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.
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
- Return JSON — an array of assignment objects:
45
- [
46
- {"track": "<exact-track-slug>", "issues": [<issue-numbers>]},
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
- - An issue can appear in AT MOST ONE track assignment.
53
- - Omit issues that genuinely don't fit any existing track (they stay untracked).
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
- return _apply(cfg)
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
- print(f"Fetching open issues from {repo}...")
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
- batch_path = _batch_path()
127
- batch_path.write_text(json.dumps({
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
- }, indent=2))
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
- print(f" {slug} [{priority}, {milestone}]")
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
- print(f"After the agent returns assignment JSON, save it to:")
166
- print(f" {_answers_path()}")
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 _apply(cfg: dict) -> int:
173
- answers_path = _answers_path()
174
- batch_path = _batch_path()
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
- answers = json.loads(answers_path.read_text())
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.frontmatter import write_file
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
- proc = subprocess.run(
118
- ["gh", "issue", "view", str(issue_num),
119
- "--repo", target.repo, "--json", "milestone"],
120
- capture_output=True, text=True,
121
- )
122
- if proc.returncode == 0:
123
- info = json.loads(proc.stdout)
124
- m = info.get("milestone", {})
125
- if (
126
- m and m.get("title")
127
- and m["title"] != target.meta.get("milestone_alignment")
128
- ):
129
- print(
130
- f"⚠ #{issue_num} is on milestone '{m['title']}', "
131
- f"track '{target.name}' aligned to"
132
- f" '{target.meta.get('milestone_alignment')}'."
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
- src_issues = [
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
- target.meta.setdefault("github", {})["issues"] = sorted(issues)
177
- write_file(target.path, target.meta, target.body)
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.frontmatter import write_file
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
- sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
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
- issues.append(issue_num)
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
- src_issues = [n for n in (src.meta.get("github", {}).get("issues") or [])
144
- if n != issue_num]
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