@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.
- package/README.md +19 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/brief.py +12 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +70 -5
- package/skills/work-plan/commands/in_progress.py +110 -0
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +65 -1
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/commands/where_was_i.py +30 -2
- package/skills/work-plan/lib/export_model.py +42 -5
- package/skills/work-plan/lib/git_state.py +71 -0
- package/skills/work-plan/lib/github_state.py +132 -4
- package/skills/work-plan/lib/in_progress.py +23 -0
- package/skills/work-plan/lib/manifest.py +18 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/render.py +5 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +161 -8
- package/skills/work-plan/tests/test_export_command.py +103 -0
- package/skills/work-plan/tests/test_git_state.py +73 -1
- package/skills/work-plan/tests/test_github_state.py +66 -0
- package/skills/work-plan/tests/test_in_progress.py +43 -0
- package/skills/work-plan/tests/test_in_progress_command.py +166 -0
- package/skills/work-plan/tests/test_list_open_issues.py +8 -3
- package/skills/work-plan/tests/test_manifest.py +30 -1
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_register_in_progress.py +22 -0
- package/skills/work-plan/tests/test_render.py +48 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +80 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}"
|
|
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}"
|
|
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
|
|
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
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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]}
|