@stylusnexus/work-plan 2026.6.13 → 2026.6.14-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.
Files changed (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +71 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +73 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. package/skills/work-plan/work_plan.py +36 -1
@@ -0,0 +1,85 @@
1
+ """plan-baseline — stamp the CURRENT computed verdict into a plan/spec doc's YAML
2
+ frontmatter as a drift baseline (#286 slice 2).
3
+
4
+ Distinct from `plan-confirm` (a human *pin* that overrides the verdict) and from
5
+ the body status banner (`plan-status --stamp`). This records `verdict_baseline:
6
+ <computed>` so `plan-status` can later flag **drift** — when the live computed
7
+ verdict diverges from the stamped baseline (e.g. a once-shipped plan whose
8
+ declared files were deleted, silently regressing to partial). It is the third
9
+ "started, then drifted off" signal the Plans view otherwise can't see.
10
+
11
+ Frontmatter-only (never the body/manifest/checkboxes/banner). The baseline value
12
+ is computed authoritatively here (the same evaluator plan-status uses), not taken
13
+ from the caller.
14
+
15
+ Usage:
16
+ work_plan.py plan-baseline --repo=<key> [--confirm=<token>] -- <rel>
17
+ work_plan.py plan-baseline --repo=<key> --clear [--confirm=<token>] -- <rel>
18
+ """
19
+ import sys
20
+ from datetime import date
21
+
22
+ from lib import config as config_mod
23
+ from lib import doc_discovery
24
+ from lib import plan_fm
25
+ from lib import verdict as verdict_mod
26
+ from lib.prompts import parse_flags
27
+ from commands.plan_status import evaluate_doc
28
+
29
+ KNOWN = {"--repo", "--clear", "--confirm"}
30
+
31
+
32
+ def run(args: list) -> int:
33
+ flags, positional = parse_flags(args, KNOWN)
34
+
35
+ repo = flags.get("--repo")
36
+ if not repo or repo is True:
37
+ print("ERROR: --repo=<key> is required.", file=sys.stderr)
38
+ return 2
39
+ if not positional:
40
+ print("usage: work_plan.py plan-baseline --repo=<key> [--clear] -- <rel>",
41
+ file=sys.stderr)
42
+ return 2
43
+ rel = positional[0]
44
+ clear = bool(flags.get("--clear"))
45
+
46
+ try:
47
+ cfg = config_mod.load_config()
48
+ except config_mod.ConfigError as e:
49
+ print(f"ERROR: {e}", file=sys.stderr)
50
+ return 1
51
+
52
+ local = config_mod.resolve_local_path_for_folder(repo, cfg)
53
+ if not local or not local.exists():
54
+ print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
55
+ return 2
56
+
57
+ doc_path = plan_fm.resolve_doc_path(local, rel)
58
+ if doc_path is None:
59
+ print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
60
+ return 1
61
+
62
+ slug = config_mod.resolve_github_for_folder(repo, cfg)
63
+ action = "clearing the verdict baseline on" if clear else f"stamping a verdict baseline on '{rel}' via"
64
+ if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
65
+ return 0
66
+
67
+ if clear:
68
+ if not plan_fm.set_key(doc_path, "verdict_baseline", None):
69
+ print(f"✓ {rel} had no verdict baseline (nothing to clear).")
70
+ return 0
71
+ print(f"✓ cleared verdict baseline on {rel} (frontmatter only).")
72
+ return 0
73
+
74
+ # Compute the verdict authoritatively (same evaluator plan-status uses).
75
+ doc = doc_discovery.Doc(path=doc_path, rel=rel,
76
+ kind=doc_discovery.classify_kind(rel))
77
+ cfg_stall = cfg.get("stall_days")
78
+ stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
79
+ row = evaluate_doc(doc, local, date.today(), verdict_mod.DEAD_DAYS, stall_days)
80
+ verdict = row["verdict"]
81
+
82
+ plan_fm.set_key(doc_path, "verdict_baseline", verdict)
83
+ print(f"✓ {rel} baseline stamped at '{verdict}' — wrote verdict_baseline to "
84
+ f"frontmatter only. plan-status will flag drift if the live verdict changes.")
85
+ return 0
@@ -0,0 +1,83 @@
1
+ """plan-confirm — write a human `verdict_override` into a plan/spec doc's YAML
2
+ frontmatter (#286).
3
+
4
+ This is the one viewer-driven write to a plan doc, and it is **frontmatter-only**
5
+ by hard constraint: it touches the doc's YAML frontmatter and nothing else — never
6
+ the prose body, the declared-file manifest, the checkboxes, or the status banner.
7
+ A reviewer uses it to affirm a verdict the mechanical heuristic got wrong (e.g. a
8
+ genuinely-shipped plan whose phase checkboxes were never ticked), which silences
9
+ the "shipped but boxes unchecked" lie-gap on the next `plan-status` read.
10
+
11
+ Usage:
12
+ work_plan.py plan-confirm --repo=<key> --verdict=shipped|partial|dead [--confirm=<token>] -- <rel>
13
+ work_plan.py plan-confirm --repo=<key> --clear [--confirm=<token>] -- <rel>
14
+
15
+ `<rel>` is the repo-relative POSIX path of the plan doc (as emitted by
16
+ `plan-status --json`). It is validated to resolve to a real file inside the repo,
17
+ so a caller can't redirect the write outside the checkout.
18
+ """
19
+ import sys
20
+
21
+ from lib import config as config_mod
22
+ from lib import plan_fm
23
+ from lib.prompts import parse_flags
24
+
25
+ VALID_VERDICTS = {"shipped", "partial", "dead"}
26
+ KNOWN = {"--repo", "--verdict", "--clear", "--confirm"}
27
+
28
+
29
+ def run(args: list) -> int:
30
+ flags, positional = parse_flags(args, KNOWN)
31
+
32
+ repo = flags.get("--repo")
33
+ if not repo or repo is True:
34
+ print("ERROR: --repo=<key> is required.", file=sys.stderr)
35
+ return 2
36
+ if not positional:
37
+ print("usage: work_plan.py plan-confirm --repo=<key> "
38
+ "--verdict=shipped|partial|dead [--clear] -- <rel>", file=sys.stderr)
39
+ return 2
40
+ rel = positional[0]
41
+
42
+ clear = bool(flags.get("--clear"))
43
+ verdict = flags.get("--verdict")
44
+ if not clear:
45
+ if not verdict or verdict is True or verdict not in VALID_VERDICTS:
46
+ print("ERROR: --verdict must be one of shipped|partial|dead "
47
+ "(or pass --clear to remove the override).", file=sys.stderr)
48
+ return 2
49
+
50
+ try:
51
+ cfg = config_mod.load_config()
52
+ except config_mod.ConfigError as e:
53
+ print(f"ERROR: {e}", file=sys.stderr)
54
+ return 1
55
+
56
+ local = config_mod.resolve_local_path_for_folder(repo, cfg)
57
+ if not local or not local.exists():
58
+ print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
59
+ return 2
60
+
61
+ doc_path = plan_fm.resolve_doc_path(local, rel)
62
+ if doc_path is None:
63
+ print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
64
+ return 1
65
+
66
+ # Public-repo confirm gate (the extension surfaces this as a modal). The token
67
+ # is keyed on (slug, rel) — the same shape close/set use, so the viewer's
68
+ # existing executeWrite token flow drives it unchanged.
69
+ slug = config_mod.resolve_github_for_folder(repo, cfg)
70
+ action = "clearing the verdict override on" if clear else f"marking '{rel}' as {verdict} via"
71
+ if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
72
+ return 0
73
+
74
+ if clear:
75
+ if not plan_fm.set_key(doc_path, "verdict_override", None):
76
+ print(f"✓ no verdict override on {rel} (nothing to clear).")
77
+ return 0
78
+ print(f"✓ cleared verdict override on {rel} (frontmatter only).")
79
+ return 0
80
+
81
+ plan_fm.set_key(doc_path, "verdict_override", verdict)
82
+ print(f"✓ {rel} confirmed {verdict} — wrote verdict_override to frontmatter only.")
83
+ return 0
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
 
12
12
  from lib import config as config_mod
13
13
  from lib import doc_discovery, manifest, git_state, github_state
14
+ from lib import frontmatter
14
15
  from lib import verdict as verdict_mod
15
16
  from lib import status_header
16
17
  from lib import llm_evidence
@@ -22,6 +23,42 @@ KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
22
23
  "--llm", "--apply", "--archive", "--issues", "--stall-days"}
23
24
  _ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
24
25
 
26
+ # Human verdict-override (#286): a reviewer affirms the verdict in the doc's
27
+ # YAML frontmatter, so the mechanical heuristic (file score + checkbox %) stops
28
+ # second-guessing it — and the "shipped but boxes unchecked" lie-gap goes quiet.
29
+ _OVERRIDE_VERDICTS = {"shipped", "partial", "dead"}
30
+ _OVERRIDE_GLYPH = {"shipped": "✅", "partial": "🟡", "dead": "💀"}
31
+
32
+
33
+ def _read_fm_signals(path) -> tuple:
34
+ """Read the frontmatter signals plan-status honors (#286), in ONE parse:
35
+
36
+ (override, acknowledged, baseline)
37
+
38
+ `override` is the `verdict_override` value (case-insensitive shipped|partial|
39
+ dead, else None — a typo can't pin a bogus verdict). `acknowledged` is True
40
+ when the doc carries a truthy `acknowledged` (the durable ack plan-ack writes).
41
+ `baseline` is the `verdict_baseline` value (a verdict string, else None) that
42
+ plan-baseline stamps for drift detection. All frontmatter-only — never the
43
+ body/checkboxes. A doc with no frontmatter (most plans) parses to empty meta,
44
+ a clean (None, False, None) no-op."""
45
+ try:
46
+ meta, _ = frontmatter.parse_file(Path(path))
47
+ except Exception:
48
+ return (None, False, None)
49
+ if not isinstance(meta, dict):
50
+ return (None, False, None)
51
+ val = meta.get("verdict_override")
52
+ override = (val.strip().lower()
53
+ if isinstance(val, str) and val.strip().lower() in _OVERRIDE_VERDICTS
54
+ else None)
55
+ acknowledged = bool(meta.get("acknowledged"))
56
+ bval = meta.get("verdict_baseline")
57
+ baseline = (bval.strip().lower()
58
+ if isinstance(bval, str) and bval.strip().lower() in _OVERRIDE_VERDICTS
59
+ else None)
60
+ return (override, acknowledged, baseline)
61
+
25
62
 
26
63
  def _resolve_repo_root(flags) -> Path:
27
64
  repo = flags.get("--repo")
@@ -95,6 +132,14 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
95
132
  else:
96
133
  v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
97
134
 
135
+ # Frontmatter signals (#286): a `verdict_override` pins the verdict over the
136
+ # mechanical one (applied BEFORE the staleness clock + lie-gap so both key off
137
+ # the confirmed verdict), and `acknowledged` is the durable, shared ack.
138
+ override, acknowledged, baseline = _read_fm_signals(doc.path)
139
+ if override:
140
+ v = verdict_mod.Verdict(
141
+ override, _OVERRIDE_GLYPH[override], f"human-confirmed · {v.rationale}")
142
+
98
143
  # Staleness clock (#164): a partial plan whose declared manifest files have
99
144
  # gone cold = "started executing, then drifted off." Key off the manifest
100
145
  # files' commit date, NOT the plan doc's git date — plan docs are gitignored,
@@ -111,8 +156,14 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
111
156
  stalled = (today - manifest_dt.date()).days >= stall_days
112
157
  # else: no declared files on disk yet -> brand-new, not stalled.
113
158
 
114
- lie_gap = (v.label == "shipped" and total_chk > 0
159
+ # A human-confirmed verdict silences the lie-gap: the reviewer has affirmed
160
+ # the "shipped" claim despite unchecked boxes, so it's no longer a lie.
161
+ lie_gap = (not override and v.label == "shipped" and total_chk > 0
115
162
  and done / total_chk < 0.25)
163
+ # Drift (#286): a stamped `verdict_baseline` that no longer matches the live
164
+ # verdict — e.g. a once-shipped plan whose declared files were deleted. A
165
+ # human override owns the verdict, so it suppresses drift (same as lie-gap).
166
+ verdict_drift = bool(baseline) and not override and baseline != v.label
116
167
  return {
117
168
  "rel": doc.rel, "kind": doc.kind,
118
169
  "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
@@ -122,11 +173,24 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
122
173
  "manifest_last_touched": manifest_dt.date().isoformat() if manifest_dt else None,
123
174
  "stalled": stalled,
124
175
  "lie_gap": lie_gap,
176
+ "override": override,
177
+ "acknowledged": acknowledged,
178
+ "verdict_baseline": baseline,
179
+ "verdict_drift": verdict_drift,
180
+ "offtree_paths": manifest.offtree_declared_paths(decls, repo_root),
125
181
  "unchecked_items": manifest.unchecked_checkbox_labels(text),
126
182
  "stall_days": stall_days,
127
183
  }
128
184
 
129
185
 
186
+ # Public alias so other commands (e.g. `export`, which resolves a track's linked
187
+ # plan badge for #285) reuse the SAME verdict/lie-gap/override evaluation instead
188
+ # of reimplementing it and drifting. Kept at this module path so the existing
189
+ # tests that patch `commands.plan_status.git_state.*` and call `_evaluate`
190
+ # directly keep working unchanged.
191
+ evaluate_doc = _evaluate
192
+
193
+
130
194
  def _render(rows, repo_root) -> None:
131
195
  print(f"# plan-status — {repo_root}\n")
132
196
  by = {}
@@ -0,0 +1,156 @@
1
+ """push-track — promote a PRIVATE track to a repo's SHARED tier and publish it
2
+ (#306).
3
+
4
+ Tracks default to the private tier (`notes_root`, local-only, never pushed). The
5
+ shared tier lives in a repo's `.work-plan/` on its canonical `plan_branch` (via a
6
+ git worktree) and is how a track becomes visible to teammates. This verb moves a
7
+ private track's `.md` into the shared `.work-plan/`, removes the private copy
8
+ (so the track isn't duplicated), commits it to the plan branch, and pushes —
9
+ unless `--no-push`.
10
+
11
+ The tier is derived from WHERE the file lives, so promotion is a file move; no
12
+ frontmatter edit. Pushing to a PUBLIC repo's plan branch makes the plan
13
+ world-visible — the exposed state the viewer's visibility×tier badge warns about
14
+ — so the push is confirm-token gated, like `plan-branch push`.
15
+
16
+ Usage:
17
+ work_plan.py push-track <track | track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]
18
+ """
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from lib.config import load_config, ConfigError
24
+ from lib.tracks import (
25
+ discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError,
26
+ )
27
+ from lib.frontmatter import write_file
28
+ from lib import plan_worktree as pw
29
+ from lib.write_guard import needs_confirm, make_token, valid_token
30
+ from lib.prompts import parse_flags
31
+
32
+ KNOWN = {"--repo", "--no-push", "--confirm"}
33
+
34
+
35
+ def run(args: list) -> int:
36
+ flags, positional = parse_flags(args, KNOWN)
37
+ if not positional:
38
+ print("usage: work_plan.py push-track <track> [--repo=<key>] [--no-push] "
39
+ "[--confirm=<token>]", file=sys.stderr)
40
+ return 2
41
+ name_from_arg, repo_from_arg = parse_track_repo_arg(positional[0])
42
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
43
+ repo_qualifier = repo_from_arg or repo_flag
44
+ no_push = bool(flags.get("--no-push"))
45
+
46
+ try:
47
+ cfg = load_config()
48
+ except ConfigError as e:
49
+ print(f"ERROR: {e}", file=sys.stderr)
50
+ return 1
51
+
52
+ try:
53
+ track = find_track_by_name(name_from_arg, discover_tracks(cfg), repo=repo_qualifier)
54
+ except AmbiguousTrackError as e:
55
+ print(str(e), file=sys.stderr)
56
+ return 1
57
+ if not track:
58
+ print(f"No track matching '{name_from_arg}'.", file=sys.stderr)
59
+ return 1
60
+ if track.tier == "shared":
61
+ print(f"'{track.name}' is already in the shared tier — nothing to promote.",
62
+ file=sys.stderr)
63
+ return 1
64
+ if not track.folder:
65
+ print(f"'{track.name}' has no configured repo (folder) — can't resolve a "
66
+ "shared tier to promote into.", file=sys.stderr)
67
+ return 1
68
+
69
+ entry = (cfg.get("repos") or {}).get(track.folder) or {}
70
+ github = entry.get("github")
71
+ branch = entry.get("plan_branch")
72
+ local = entry.get("local")
73
+ if not local:
74
+ print(f"repo '{track.folder}' has no local clone path in config.", file=sys.stderr)
75
+ return 2
76
+ if not branch:
77
+ print(f"repo '{track.folder}' has no shared plan branch. Set one up first:\n"
78
+ f" /work-plan plan-branch init {track.folder}", file=sys.stderr)
79
+ return 1
80
+
81
+ # Exposure gate (the push is what publishes). Fire BEFORE any mutation so the
82
+ # viewer's modal lands first; --no-push keeps it local, so no gate. Fails
83
+ # CLOSED on unknown visibility (same as plan-branch push).
84
+ if not no_push and needs_confirm(github, cfg):
85
+ confirm = flags.get("--confirm")
86
+ if not (isinstance(confirm, str) and valid_token(confirm, github, track.name)):
87
+ print(_needs_confirm_json(github, branch, track.name))
88
+ return 0
89
+
90
+ # Resolve (and ensure) the shared-tier worktree dir.
91
+ shared_dir = pw.shared_tier_dir(entry)
92
+ if shared_dir is None:
93
+ print(f"ERROR: could not open the shared plan branch '{branch}' for "
94
+ f"'{track.folder}'. Run `plan-branch init {track.folder}` first.",
95
+ file=sys.stderr)
96
+ return 1
97
+ dest = shared_dir / f"{track.name}.md"
98
+ if dest.exists():
99
+ print(f"ERROR: a shared track '{track.name}' already exists at {dest}.",
100
+ file=sys.stderr)
101
+ return 1
102
+
103
+ # Move: write into the shared tier (frontmatter preserved), then remove the
104
+ # private copy so discover_tracks shows the track once (shared), not twice.
105
+ write_file(dest, track.meta, track.body)
106
+ try:
107
+ track.path.unlink()
108
+ except OSError as e:
109
+ print(f"WARN: wrote the shared copy but could not remove the private "
110
+ f"file {track.path}: {e} — remove it by hand to avoid a duplicate.",
111
+ file=sys.stderr)
112
+
113
+ worktree = shared_dir.parent
114
+ sha = pw.commit_shared_tier(
115
+ worktree, f"work-plan: promote track '{track.name}' to shared tier",
116
+ [f".work-plan/{track.name}.md"],
117
+ )
118
+ if sha is None:
119
+ print(f"WARN: moved '{track.name}' into the shared tier but the commit "
120
+ "did not land — commit it by hand in the plan-branch worktree.",
121
+ file=sys.stderr)
122
+
123
+ if no_push:
124
+ print(f"✓ promoted '{track.name}' to the shared tier (local commit "
125
+ f"{sha or '—'}). Run `plan-branch push {track.folder}` to share it.")
126
+ return 0
127
+
128
+ proc = pw.push_plan_branch(Path(local).expanduser(), branch)
129
+ if proc is None or proc.returncode != 0:
130
+ err = (getattr(proc, "stderr", "") or "").strip()
131
+ if "protected" in err.lower() or "pull request" in err.lower():
132
+ print(f"ERROR: origin rejected the push — '{branch}' looks protected. "
133
+ f"Exempt the plan branch from PR/branch-protection, or push it "
134
+ "by hand once. The promotion is committed locally.", file=sys.stderr)
135
+ else:
136
+ print(f"ERROR: promoted + committed locally, but the push failed: "
137
+ f"{err or 'unknown git error'}. Retry with `plan-branch push "
138
+ f"{track.folder}`.", file=sys.stderr)
139
+ return 1
140
+ print(f"✓ promoted '{track.name}' to the shared tier and pushed '{branch}'. "
141
+ "Teammates can `plan-branch init` to see it.")
142
+ return 0
143
+
144
+
145
+ def _needs_confirm_json(github, branch, name) -> str:
146
+ return json.dumps({
147
+ "needs_confirm": True,
148
+ "reason": (
149
+ f"{github} is PUBLIC (or its visibility is unknown). Promoting "
150
+ f"'{name}' to the shared tier and pushing '{branch}' makes that "
151
+ "track — its issue notes, priorities, and planning text — visible to "
152
+ "anyone on the internet, and it stays in public git history even if "
153
+ "later removed. Use --no-push to keep it local for now."
154
+ ),
155
+ "token": make_token(github, name),
156
+ })
@@ -1,12 +1,13 @@
1
1
  """set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
2
2
  import json
3
- from lib.config import load_config, ConfigError
3
+ import sys
4
+ from lib.config import load_config, ConfigError, resolve_local_path_for_folder
4
5
  from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
5
6
  from lib.frontmatter import write_file
6
7
  from lib.write_guard import needs_confirm, make_token, valid_token
7
8
  from lib.prompts import parse_flags
8
9
 
9
- ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
10
+ ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on", "plan"}
10
11
  LIST_FIELDS = {"blockers", "next_up"}
11
12
  STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
12
13
 
@@ -39,6 +40,10 @@ def run(args: list[str]) -> int:
39
40
  print(f"ERROR: {k} takes comma-separated integers (got {v!r})"); return 2
40
41
  elif k == "status" and v not in STATUSES:
41
42
  print(f"ERROR: status {v!r} invalid (allowed: {sorted(STATUSES)})"); return 2
43
+ elif k == "plan":
44
+ # Repo-relative path to the track's plan/spec doc (#285). Empty value
45
+ # clears the link. Stored as a scalar string; validated (advisory) below.
46
+ parsed[k] = v.strip()
42
47
  else:
43
48
  parsed[k] = v
44
49
  try:
@@ -58,7 +63,21 @@ def run(args: list[str]) -> int:
58
63
  "reason": f"{track.repo} is PUBLIC (or visibility unknown); edit will be written there.",
59
64
  "token": make_token(track.repo, track.name)}))
60
65
  return 0
66
+ # An empty `plan=` clears the link rather than writing `plan: ""` (#285).
67
+ if "plan" in parsed and parsed["plan"] == "":
68
+ parsed.pop("plan")
69
+ track.meta.pop("plan", None)
70
+ # Advisory validation: a non-empty plan path that doesn't resolve to a file in
71
+ # the track's repo checkout is saved anyway (the doc may not exist yet, or the
72
+ # repo may have no local clone) but flagged so a typo is caught early.
73
+ if parsed.get("plan") and track.folder:
74
+ local = resolve_local_path_for_folder(track.folder, cfg)
75
+ if local and local.exists() and not (local / parsed["plan"]).is_file():
76
+ print(f"WARN: plan path {parsed['plan']!r} does not resolve to a file "
77
+ f"under {local} — link saved anyway.", file=sys.stderr)
78
+
61
79
  track.meta.update(parsed)
62
80
  write_file(track.path, track.meta, track.body)
63
- print(f"✓ set {', '.join(parsed)} on {track.name}")
81
+ fields = ", ".join(parsed) if parsed else "plan (cleared)"
82
+ print(f"✓ set {fields} on {track.name}")
64
83
  return 0
@@ -31,7 +31,9 @@ from lib.github_state import fetch_issues, short_milestone
31
31
  from lib.git_state import (
32
32
  parse_iso_timestamp,
33
33
  current_branch, uncommitted_file_count, commits_ahead,
34
+ hot_issue_numbers,
34
35
  )
36
+ from lib.in_progress import issue_in_progress
35
37
  from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
36
38
 
37
39
 
@@ -122,6 +124,8 @@ def _orient_track(track) -> int:
122
124
  titles_by_num: dict[int, str] = {}
123
125
  states_by_num: dict[int, str] = {}
124
126
  milestones_by_num: dict[int, str] = {}
127
+ inprog_by_num: dict = {}
128
+ blocked_by_num: dict = {}
125
129
  if track.repo and next_up:
126
130
  wanted = next_up[:4]
127
131
  fetched = fetch_issues(track.repo, wanted)
@@ -129,6 +133,18 @@ def _orient_track(track) -> int:
129
133
  titles_by_num[i["number"]] = i.get("title", "")
130
134
  states_by_num[i["number"]] = (i.get("state") or "").upper()
131
135
  milestones_by_num[i["number"]] = short_milestone(i.get("milestone"))
136
+ hot = hot_issue_numbers(track.local_path) if track.local_path else set()
137
+ manual_blockers = set(track.meta.get("blockers") or [])
138
+ for i in fetched:
139
+ inprog_by_num[i["number"]] = issue_in_progress(i, hot)
140
+ disp = []
141
+ for e in (i.get("blocked_by") or []):
142
+ same = e.get("repo") == track.repo
143
+ if same and e.get("number") in manual_blockers:
144
+ continue
145
+ disp.append(f"#{e['number']}" if same else f"{e['repo']}#{e['number']}")
146
+ if disp:
147
+ blocked_by_num[i["number"]] = disp
132
148
 
133
149
  print(_top_rule(slug))
134
150
  print(f"Priority: {priority} · Milestone: {milestone} · Repo: {repo}")
@@ -150,7 +166,9 @@ def _orient_track(track) -> int:
150
166
  pick_title = titles_by_num.get(pick_num, "")
151
167
  pick_suffix = _state_suffix(states_by_num.get(pick_num))
152
168
  pick_ms = _milestone_prefix(milestones_by_num.get(pick_num))
153
- print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}".rstrip())
169
+ print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}"
170
+ f"{_inprog_suffix(inprog_by_num.get(pick_num, False))}"
171
+ f"{_blocked_suffix(blocked_by_num.get(pick_num))}".rstrip())
154
172
  if _is_closed(states_by_num.get(pick_num)):
155
173
  print(f" ⚠ next_up:[0] has shipped — run `/work-plan handoff {slug}` to rotate")
156
174
  rest = next_up[1:4]
@@ -161,7 +179,9 @@ def _orient_track(track) -> int:
161
179
  title = titles_by_num.get(num, "")
162
180
  suffix = _state_suffix(states_by_num.get(num))
163
181
  ms = _milestone_prefix(milestones_by_num.get(num))
164
- print(f" #{num} {ms}{title}{suffix}".rstrip())
182
+ print(f" #{num} {ms}{title}{suffix}"
183
+ f"{_inprog_suffix(inprog_by_num.get(num, False))}"
184
+ f"{_blocked_suffix(blocked_by_num.get(num))}".rstrip())
165
185
  else:
166
186
  print("Next pick: (none set — run `/work-plan handoff` to set one)")
167
187
 
@@ -235,6 +255,14 @@ def _state_suffix(state: Optional[str]) -> str:
235
255
  return " (closed)" if _is_closed(state) else ""
236
256
 
237
257
 
258
+ def _inprog_suffix(flag: bool) -> str:
259
+ return " ▶ in-progress" if flag else ""
260
+
261
+
262
+ def _blocked_suffix(disp: Optional[list]) -> str:
263
+ return (" ⊘ blocked by " + ", ".join(disp)) if disp else ""
264
+
265
+
238
266
  def _milestone_prefix(ms: Optional[str]) -> str:
239
267
  return f"[{ms}] " if ms else ""
240
268
 
@@ -52,10 +52,22 @@ def group_issues_by_milestone(issues, milestone_alignment=None):
52
52
  return groups
53
53
 
54
54
 
55
- def normalize_issue(i: dict) -> dict:
55
+ def normalize_issue(i: dict, in_progress: bool = False,
56
+ in_progress_label: bool = False,
57
+ blocked_by=None, blocking=None) -> dict:
56
58
  """Reshape a raw gh issue row into the viewer's `Issue` shape
57
- ({number,title,state,assignee,milestone}). Shared by the export and the
58
- `list-open-issues` command (#282) so both emit an identical issue surface."""
59
+ ({number,title,state,assignee,milestone,in_progress,in_progress_label,
60
+ blocked_by,blocking}).
61
+ Shared by the export and the `list-open-issues` command (#282) so both
62
+ emit an identical issue surface.
63
+
64
+ `in_progress` is the UNION signal (hot branch OR label) — used by the
65
+ badge. `in_progress_label` reflects LABEL presence only — used by the
66
+ toggle button so it accurately shows Mark/Clear for the label, not the
67
+ union.
68
+ `blocked_by` / `blocking` are lists of cross-issue dependency refs
69
+ (#257); default to [] when absent.
70
+ """
59
71
  state = (i.get("state") or "OPEN").lower()
60
72
  return {
61
73
  "number": i.get("number"),
@@ -63,14 +75,35 @@ def normalize_issue(i: dict) -> dict:
63
75
  "state": "closed" if state in ("closed", "merged") else "open",
64
76
  "assignee": (format_assignees(i) if i.get("assignees") else "—"),
65
77
  "milestone": short_milestone(i.get("milestone")) or None,
78
+ "in_progress": bool(in_progress),
79
+ "in_progress_label": bool(in_progress_label),
80
+ "blocked_by": list(blocked_by or []),
81
+ "blocking": list(blocking or []),
66
82
  }
67
83
 
68
84
 
69
85
  def build_export(tracks, issues_by_track, visibility, now: str,
70
- untracked_by_repo=None, config_repos=None) -> dict:
86
+ untracked_by_repo=None, config_repos=None,
87
+ plan_by_track=None, hot_by_track=None) -> dict:
88
+ plan_by_track = plan_by_track or {}
89
+ hot_by_track = hot_by_track or {}
71
90
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
72
91
  for t in tracks:
73
- issues = [normalize_issue(i) for i in issues_by_track.get(t.name, [])]
92
+ from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
93
+ hot = hot_by_track.get((t.repo, t.name), set())
94
+ raw = issues_by_track.get((t.repo, t.name), [])
95
+ issues = [
96
+ normalize_issue(
97
+ i,
98
+ in_progress=issue_in_progress(i, hot),
99
+ in_progress_label=IN_PROGRESS_LABEL in {
100
+ l.get("name") for l in (i.get("labels") or [])
101
+ },
102
+ blocked_by=i.get("blocked_by"),
103
+ blocking=i.get("blocking"),
104
+ )
105
+ for i in raw
106
+ ]
74
107
  milestone_alignment = t.meta.get("milestone_alignment")
75
108
  issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
76
109
  opened = sum(1 for i in issues if i["state"] == "open")
@@ -98,6 +131,10 @@ def build_export(tracks, issues_by_track, visibility, now: str,
98
131
  "depends_on": list(t.meta.get("depends_on") or []),
99
132
  "rollup": {"open": opened, "closed": len(issues) - opened},
100
133
  "issues": issues,
134
+ # The track's declared plan/spec doc + its execution badge (#285), or
135
+ # null when the track declares no `plan:`. `{rel, resolved:false}` when
136
+ # the link can't be resolved (no local clone / file absent).
137
+ "plan": plan_by_track.get(t.name),
101
138
  })
102
139
  out["untracked"] = [
103
140
  {"repo": repo, "issues": [normalize_issue(r) for r in rows]}