@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2
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 +38 -6
- 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/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/new_track.py +8 -2
- 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_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- 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 +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- 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_branch.py +279 -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_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/work_plan.py +125 -6
|
@@ -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
|
|
@@ -19,9 +20,45 @@ from lib.scratch import cache_dir
|
|
|
19
20
|
from lib.prompts import parse_flags, prompt_yes_no
|
|
20
21
|
|
|
21
22
|
KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
|
|
22
|
-
"--llm", "--apply", "--archive", "--issues"}
|
|
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")
|
|
@@ -35,8 +72,54 @@ def _resolve_repo_root(flags) -> Path:
|
|
|
35
72
|
return Path.cwd()
|
|
36
73
|
|
|
37
74
|
|
|
38
|
-
def
|
|
39
|
-
|
|
75
|
+
def _resolve_stall_days(flags) -> int:
|
|
76
|
+
"""Precedence for the staleness threshold (#164):
|
|
77
|
+
--stall-days flag (int) -> config.yml `stall_days` (int) -> default 14.
|
|
78
|
+
A non-integer flag value falls through to the config/default tier.
|
|
79
|
+
"""
|
|
80
|
+
raw = flags.get("--stall-days")
|
|
81
|
+
if raw not in (None, True):
|
|
82
|
+
try:
|
|
83
|
+
return int(raw)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
pass
|
|
86
|
+
cfg_val = config_mod.load_config().get("stall_days")
|
|
87
|
+
if isinstance(cfg_val, int):
|
|
88
|
+
return cfg_val
|
|
89
|
+
return verdict_mod.STALL_DAYS
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read(path) -> str:
|
|
93
|
+
"""Read a doc's text. Indirected so tests can patch it without a real file."""
|
|
94
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _declared_paths_on_disk(decls, repo_root) -> list:
|
|
98
|
+
"""Repo-relative declared paths that point at a real FILE inside repo_root.
|
|
99
|
+
|
|
100
|
+
Both guards matter for the staleness clock: a junk declared path like `/`
|
|
101
|
+
resolves (via `repo_root / "/"`) to the filesystem root — a directory that
|
|
102
|
+
exists — and an out-of-tree `../x` path can exist too. Either one passed to
|
|
103
|
+
`git log -- <paths…>` poisons the WHOLE call (it returns nothing), which
|
|
104
|
+
would falsely mark an actively-built plan as stalled. So require an actual
|
|
105
|
+
file (`is_file`) that resolves under repo_root."""
|
|
106
|
+
root = Path(repo_root).resolve()
|
|
107
|
+
out = []
|
|
108
|
+
for d in decls:
|
|
109
|
+
p = Path(repo_root) / d.path
|
|
110
|
+
try:
|
|
111
|
+
if not p.is_file():
|
|
112
|
+
continue
|
|
113
|
+
resolved = p.resolve()
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
if resolved == root or root in resolved.parents:
|
|
117
|
+
out.append(d.path)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
|
|
122
|
+
text = _read(doc.path)
|
|
40
123
|
decls = manifest.parse_declared_paths(text)
|
|
41
124
|
pdate = manifest.plan_date_from_filename(doc.path.name)
|
|
42
125
|
score = manifest.score_manifest(decls, repo_root, pdate)
|
|
@@ -48,25 +131,72 @@ def _evaluate(doc, repo_root, today, dead_days) -> dict:
|
|
|
48
131
|
"foreign", "🧳", "declared paths point outside this repo — misfiled?")
|
|
49
132
|
else:
|
|
50
133
|
v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
|
|
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
|
+
|
|
143
|
+
# Staleness clock (#164): a partial plan whose declared manifest files have
|
|
144
|
+
# gone cold = "started executing, then drifted off." Key off the manifest
|
|
145
|
+
# files' commit date, NOT the plan doc's git date — plan docs are gitignored,
|
|
146
|
+
# so the doc date is null and would make this a silent no-op.
|
|
147
|
+
manifest_dt = None
|
|
148
|
+
stalled = False
|
|
149
|
+
if v.label == "partial":
|
|
150
|
+
on_disk = _declared_paths_on_disk(decls, repo_root)
|
|
151
|
+
if on_disk:
|
|
152
|
+
manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
|
|
153
|
+
if manifest_dt is None:
|
|
154
|
+
stalled = True # present on disk but never committed
|
|
155
|
+
else:
|
|
156
|
+
stalled = (today - manifest_dt.date()).days >= stall_days
|
|
157
|
+
# else: no declared files on disk yet -> brand-new, not stalled.
|
|
158
|
+
|
|
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
|
|
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
|
|
51
167
|
return {
|
|
52
168
|
"rel": doc.rel, "kind": doc.kind,
|
|
53
169
|
"verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
|
|
54
170
|
"files_present": score.satisfied, "files_declared": score.total,
|
|
55
171
|
"checkboxes_done": done, "checkboxes_total": total_chk,
|
|
56
172
|
"last_touched": last_d.isoformat() if last_d else None,
|
|
173
|
+
"manifest_last_touched": manifest_dt.date().isoformat() if manifest_dt else None,
|
|
174
|
+
"stalled": stalled,
|
|
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),
|
|
181
|
+
"unchecked_items": manifest.unchecked_checkbox_labels(text),
|
|
182
|
+
"stall_days": stall_days,
|
|
57
183
|
}
|
|
58
184
|
|
|
59
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
|
+
|
|
60
194
|
def _render(rows, repo_root) -> None:
|
|
61
195
|
print(f"# plan-status — {repo_root}\n")
|
|
62
196
|
by = {}
|
|
63
197
|
for r in rows:
|
|
64
198
|
by.setdefault(r["verdict"], []).append(r)
|
|
65
|
-
lie_gap = sum(
|
|
66
|
-
1 for r in rows
|
|
67
|
-
if r["verdict"] == "shipped" and r["checkboxes_total"]
|
|
68
|
-
and r["checkboxes_done"] / r["checkboxes_total"] < 0.25
|
|
69
|
-
)
|
|
199
|
+
lie_gap = sum(1 for r in rows if r["lie_gap"])
|
|
70
200
|
summary = " · ".join(f"{len(by[k])} {k}" for k in _ORDER if by.get(k))
|
|
71
201
|
print(f"{len(rows)} docs · {summary}")
|
|
72
202
|
print(f"lie-gap (shipped but <25% boxes checked): {lie_gap}\n")
|
|
@@ -271,7 +401,8 @@ def run(args: list) -> int:
|
|
|
271
401
|
if type_filter and type_filter is not True:
|
|
272
402
|
docs = [d for d in docs if d.kind == type_filter]
|
|
273
403
|
|
|
274
|
-
|
|
404
|
+
stall_days = _resolve_stall_days(flags)
|
|
405
|
+
rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
|
|
275
406
|
|
|
276
407
|
if flags.get("--llm"):
|
|
277
408
|
if flags.get("--apply"):
|
|
@@ -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
|
+
})
|
|
@@ -41,6 +41,20 @@ from lib.write_guard import needs_confirm
|
|
|
41
41
|
PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _track_key(track) -> tuple:
|
|
45
|
+
"""Stable, unique identity for a track across a reconcile run.
|
|
46
|
+
|
|
47
|
+
Track slugs are NOT unique — the same slug can name a track in two different
|
|
48
|
+
repos (this is explicitly supported). Keying reconcile's in-flight state by
|
|
49
|
+
slug let a later repo's fetch overwrite an earlier same-slug track's, so
|
|
50
|
+
under `--all --yes` issues from one repo could be written into the
|
|
51
|
+
same-named track in ANOTHER repo — cross-repo membership corruption (#255).
|
|
52
|
+
The (repo, path) pair is unique per track file and stable for the whole run.
|
|
53
|
+
Display still uses `track.name`; only dict keys use this.
|
|
54
|
+
"""
|
|
55
|
+
return (track.repo, str(track.path))
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
def _resolve_labels(track) -> list[str]:
|
|
45
59
|
"""Return the GitHub label(s) marking issues as belonging to this track.
|
|
46
60
|
|
|
@@ -143,7 +157,7 @@ def run(args: list[str]) -> int:
|
|
|
143
157
|
|
|
144
158
|
# Phase 1: parallel fetch of labeled issues for all tracks
|
|
145
159
|
work_items = [(track, _resolve_labels(track)) for track in targets if track.repo]
|
|
146
|
-
results: dict = {} # track
|
|
160
|
+
results: dict = {} # _track_key(track) → list[dict] or None (timeout/error)
|
|
147
161
|
|
|
148
162
|
total = len(work_items)
|
|
149
163
|
if total > 1:
|
|
@@ -156,21 +170,21 @@ def run(args: list[str]) -> int:
|
|
|
156
170
|
# Iterate in submit order for readable output; futures run in parallel
|
|
157
171
|
for i, track, future in submitted:
|
|
158
172
|
try:
|
|
159
|
-
results[track
|
|
160
|
-
print(f" [{i}/{total}] ✓ {track.name}")
|
|
173
|
+
results[_track_key(track)] = future.result()
|
|
174
|
+
print(f" [{i}/{total}] ✓ {track.name} ({track.repo})")
|
|
161
175
|
except RuntimeError as e:
|
|
162
|
-
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
163
|
-
results[track
|
|
176
|
+
print(f" [{i}/{total}] ⚠ {track.name} ({track.repo}): {e} — skipping")
|
|
177
|
+
results[_track_key(track)] = None
|
|
164
178
|
else:
|
|
165
179
|
# Single track: fetch directly (no thread overhead)
|
|
166
180
|
for i, (track, labels) in enumerate(work_items, 1):
|
|
167
181
|
print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
|
|
168
182
|
try:
|
|
169
|
-
results[track
|
|
170
|
-
print(f" [{i}/{total}] ✓ {track.name}")
|
|
183
|
+
results[_track_key(track)] = _fetch_labeled_issues(track.repo, labels)
|
|
184
|
+
print(f" [{i}/{total}] ✓ {track.name} ({track.repo})")
|
|
171
185
|
except RuntimeError as e:
|
|
172
|
-
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
173
|
-
results[track
|
|
186
|
+
print(f" [{i}/{total}] ⚠ {track.name} ({track.repo}): {e} — skipping")
|
|
187
|
+
results[_track_key(track)] = None
|
|
174
188
|
|
|
175
189
|
# Phase 2a: index which fetched track(s) label each issue. Used to turn a
|
|
176
190
|
# bare FLAG (in a track's frontmatter, but it has lost that track's label)
|
|
@@ -178,45 +192,46 @@ def run(args: list[str]) -> int:
|
|
|
178
192
|
# track in the same repo.
|
|
179
193
|
labeled_index: dict = {} # issue number -> list[track]
|
|
180
194
|
for track in targets:
|
|
181
|
-
if not track.repo or results.get(track
|
|
195
|
+
if not track.repo or results.get(_track_key(track)) is None:
|
|
182
196
|
continue
|
|
183
|
-
for num in {i["number"] for i in results[track
|
|
197
|
+
for num in {i["number"] for i in results[_track_key(track)]}:
|
|
184
198
|
labeled_index.setdefault(num, []).append(track)
|
|
185
199
|
|
|
186
200
|
# Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
|
|
187
201
|
# in track A's frontmatter, no longer carries A's label, and is now labeled
|
|
188
202
|
# by exactly one OTHER active track B in the same repo. Ambiguous cases
|
|
189
203
|
# (two or more candidate targets) stay as plain FLAGs.
|
|
190
|
-
moved_out: dict = {} # src
|
|
191
|
-
moved_in: dict = {} # dst
|
|
192
|
-
move_dst: dict = {} # (src
|
|
204
|
+
moved_out: dict = {} # _track_key(src) -> set(num)
|
|
205
|
+
moved_in: dict = {} # _track_key(dst) -> set(num)
|
|
206
|
+
move_dst: dict = {} # (_track_key(src), num) -> dst track
|
|
193
207
|
for track in targets:
|
|
194
|
-
if not track.repo or results.get(track
|
|
208
|
+
if not track.repo or results.get(_track_key(track)) is None:
|
|
195
209
|
continue
|
|
196
|
-
labeled_nums = {i["number"] for i in results[track
|
|
210
|
+
labeled_nums = {i["number"] for i in results[_track_key(track)]}
|
|
197
211
|
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
198
212
|
for num in sorted(listed_nums - labeled_nums):
|
|
199
213
|
cands = [b for b in labeled_index.get(num, [])
|
|
200
214
|
if b is not track and b.repo == track.repo]
|
|
201
215
|
if len(cands) == 1:
|
|
202
216
|
dst = cands[0]
|
|
203
|
-
moved_out.setdefault(track
|
|
204
|
-
moved_in.setdefault(dst
|
|
205
|
-
move_dst[(track
|
|
217
|
+
moved_out.setdefault(_track_key(track), set()).add(num)
|
|
218
|
+
moved_in.setdefault(_track_key(dst), set()).add(num)
|
|
219
|
+
move_dst[(_track_key(track), num)] = dst
|
|
206
220
|
|
|
207
221
|
# Phase 2c: per-track diff, report, confirm. Membership changes accumulate
|
|
208
|
-
# in `final` (
|
|
222
|
+
# in `final` (_track_key -> desired issue set); each affected track is
|
|
209
223
|
# written exactly ONCE at the end, so a move that touches two tracks never
|
|
210
224
|
# double-writes or clobbers a sibling's accepted ADDs. A move is governed by
|
|
211
225
|
# the confirmation on its SOURCE track (where the issue currently lives).
|
|
212
|
-
final: dict = {} # track
|
|
213
|
-
affected: dict = {} # track
|
|
226
|
+
final: dict = {} # _track_key(track) -> set(num)
|
|
227
|
+
affected: dict = {} # _track_key(track) -> track (only those we may write)
|
|
214
228
|
|
|
215
229
|
def _final_for(t):
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
key = _track_key(t)
|
|
231
|
+
if key not in final:
|
|
232
|
+
final[key] = set(t.meta.get("github", {}).get("issues") or [])
|
|
233
|
+
affected[key] = t
|
|
234
|
+
return final[key]
|
|
220
235
|
|
|
221
236
|
any_changes = False
|
|
222
237
|
for track in targets:
|
|
@@ -224,19 +239,19 @@ def run(args: list[str]) -> int:
|
|
|
224
239
|
if not track.repo:
|
|
225
240
|
continue
|
|
226
241
|
|
|
227
|
-
labeled = results.get(track
|
|
242
|
+
labeled = results.get(_track_key(track))
|
|
228
243
|
if labeled is None:
|
|
229
244
|
continue
|
|
230
245
|
|
|
231
246
|
labels = _resolve_labels(track)
|
|
232
247
|
labeled_nums = {i["number"] for i in labeled}
|
|
233
248
|
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
234
|
-
out_moves = sorted(moved_out.get(track
|
|
249
|
+
out_moves = sorted(moved_out.get(_track_key(track), set()))
|
|
235
250
|
|
|
236
251
|
# MOVE issues are reported (and applied) as moves, not as ADD on the
|
|
237
252
|
# destination or FLAG on the source.
|
|
238
|
-
adds = sorted(labeled_nums - listed_nums - moved_in.get(track
|
|
239
|
-
flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track
|
|
253
|
+
adds = sorted(labeled_nums - listed_nums - moved_in.get(_track_key(track), set()))
|
|
254
|
+
flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(_track_key(track), set()))
|
|
240
255
|
|
|
241
256
|
if not adds and not flag_nums and not out_moves:
|
|
242
257
|
continue
|
|
@@ -253,7 +268,7 @@ def run(args: list[str]) -> int:
|
|
|
253
268
|
if out_moves:
|
|
254
269
|
print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
|
|
255
270
|
for num in out_moves:
|
|
256
|
-
dst = move_dst[(track
|
|
271
|
+
dst = move_dst[(_track_key(track), num)]
|
|
257
272
|
dst_slug = dst.meta.get("track", dst.name)
|
|
258
273
|
pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
|
|
259
274
|
print(f" #{num} {slug} → {dst_slug}{pub}")
|
|
@@ -286,7 +301,7 @@ def run(args: list[str]) -> int:
|
|
|
286
301
|
if adds:
|
|
287
302
|
_final_for(track).update(adds)
|
|
288
303
|
for num in out_moves:
|
|
289
|
-
dst = move_dst[(track
|
|
304
|
+
dst = move_dst[(_track_key(track), num)]
|
|
290
305
|
# Public-repo guard (#163): under --yes we never silently write
|
|
291
306
|
# membership into a PUBLIC/shared destination track — that move is
|
|
292
307
|
# skipped with a pointer to the gated `move` verb. Interactive runs
|
|
@@ -300,8 +315,8 @@ def run(args: list[str]) -> int:
|
|
|
300
315
|
_final_for(dst).add(num)
|
|
301
316
|
|
|
302
317
|
# Write each affected track exactly once, only if its set actually changed.
|
|
303
|
-
for
|
|
304
|
-
track = affected[
|
|
318
|
+
for key, issues in final.items():
|
|
319
|
+
track = affected[key]
|
|
305
320
|
original = set(track.meta.get("github", {}).get("issues") or [])
|
|
306
321
|
if issues == original:
|
|
307
322
|
continue
|
|
@@ -65,8 +65,17 @@ def run(args: list[str]) -> int:
|
|
|
65
65
|
|
|
66
66
|
def _refresh_many(tracks: list, yes: bool) -> int:
|
|
67
67
|
"""Refresh one or more tracks. Computes proposed updates, then asks one
|
|
68
|
-
confirmation (or applies all if --yes).
|
|
68
|
+
confirmation (or applies all if --yes).
|
|
69
|
+
|
|
70
|
+
A track whose live fetch comes back incomplete (GitHub timeout, permission
|
|
71
|
+
error, or a frontmatter issue that no longer resolves) is SKIPPED, not
|
|
72
|
+
refreshed: the canonical table is rebuilt from frontmatter membership, so a
|
|
73
|
+
missing issue would render as '(not fetched)' and silently overwrite its
|
|
74
|
+
valid last-known row (#256). Skipped tracks are reported and force a nonzero
|
|
75
|
+
exit so `--yes` / `hygiene` callers can tell a degraded run from a clean one.
|
|
76
|
+
"""
|
|
69
77
|
pending = []
|
|
78
|
+
degraded = [] # (track, missing_nums) — fetch was incomplete; left untouched
|
|
70
79
|
for i, track in enumerate(tracks, 1):
|
|
71
80
|
print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
|
|
72
81
|
canonical = find_canonical_status_tables(track.body)
|
|
@@ -94,6 +103,22 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
94
103
|
issues_by_num = {i["number"]: i for i in issues}
|
|
95
104
|
state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
|
|
96
105
|
|
|
106
|
+
# Both render paths rebuild the table from frontmatter membership, so a
|
|
107
|
+
# frontmatter issue we couldn't fetch would land as a '(not fetched)'
|
|
108
|
+
# row, replacing its valid last-known values. Refuse to publish that:
|
|
109
|
+
# skip the track and surface the gap (#256). Table-only numbers that
|
|
110
|
+
# aren't in frontmatter don't feed the rebuild, so they don't gate.
|
|
111
|
+
unique_fm = set(frontmatter_nums)
|
|
112
|
+
missing = sorted(n for n in unique_fm if n not in issues_by_num)
|
|
113
|
+
if missing:
|
|
114
|
+
degraded.append((track, missing))
|
|
115
|
+
scope = ("no issues" if len(missing) == len(unique_fm)
|
|
116
|
+
else f"{len(missing)}/{len(unique_fm)} issues")
|
|
117
|
+
nums = ", ".join(f"#{n}" for n in missing)
|
|
118
|
+
print(f" ⚠ fetch returned {scope} short ({nums}) "
|
|
119
|
+
f"— skipping to preserve current rows")
|
|
120
|
+
continue
|
|
121
|
+
|
|
97
122
|
if canonical:
|
|
98
123
|
# Canonical table → RE-DERIVE the whole block from frontmatter
|
|
99
124
|
# membership + live data, milestone-ordered (#101). Re-deriving from
|
|
@@ -113,6 +138,9 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
113
138
|
pending.append((track, new_body, detail))
|
|
114
139
|
|
|
115
140
|
if not pending:
|
|
141
|
+
if degraded:
|
|
142
|
+
_report_degraded(degraded)
|
|
143
|
+
return 1
|
|
116
144
|
print("All tracks in sync.")
|
|
117
145
|
return 0
|
|
118
146
|
|
|
@@ -127,9 +155,29 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
127
155
|
for track, new_body, _ in pending:
|
|
128
156
|
write_file(track.path, track.meta, new_body)
|
|
129
157
|
print(f"\n✓ Updated {len(pending)} file(s).")
|
|
158
|
+
|
|
159
|
+
if degraded:
|
|
160
|
+
_report_degraded(degraded)
|
|
161
|
+
return 1
|
|
130
162
|
return 0
|
|
131
163
|
|
|
132
164
|
|
|
165
|
+
def _report_degraded(degraded: list) -> None:
|
|
166
|
+
"""Summarize tracks skipped because their live fetch was incomplete (#256).
|
|
167
|
+
|
|
168
|
+
Their tables are left exactly as they were — better a stale-but-valid row
|
|
169
|
+
than a '(not fetched)' placeholder published as truth. A persistently
|
|
170
|
+
missing number usually means the issue was deleted/transferred and should
|
|
171
|
+
be dropped from frontmatter."""
|
|
172
|
+
print(f"\n⚠ Skipped {len(degraded)} track(s) — live fetch was incomplete, "
|
|
173
|
+
f"so their tables were left untouched:")
|
|
174
|
+
for track, missing in degraded:
|
|
175
|
+
nums = ", ".join(f"#{n}" for n in missing)
|
|
176
|
+
print(f" {track.path.name}: could not fetch {nums}")
|
|
177
|
+
print(" Re-run once GitHub is reachable, or drop deleted issues from "
|
|
178
|
+
"frontmatter (`/work-plan reconcile`).")
|
|
179
|
+
|
|
180
|
+
|
|
133
181
|
def _rederive_canonical(track, canonical_tables, frontmatter_nums,
|
|
134
182
|
issues_by_num, state_by_num):
|
|
135
183
|
"""Rebuild the canonical block, milestone-ordered, from live data.
|