@stylusnexus/work-plan 2026.6.11 → 2026.6.13
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 +26 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- 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_branch.py +314 -0
- package/skills/work-plan/commands/plan_status.py +76 -9
- 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/lib/export_model.py +21 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/manifest.py +10 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_export.py +40 -0
- package/skills/work-plan/tests/test_export_command.py +19 -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_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -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_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/work_plan.py +95 -6
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""plan-branch subcommand — bootstrap + share a repo's canonical plan branch (#260).
|
|
2
|
+
|
|
3
|
+
The shared (`.work-plan/`) tier is pinned to ONE per-repo `plan_branch`, read and
|
|
4
|
+
written through a dedicated git worktree (Phases 1+2). This command sets that up
|
|
5
|
+
and shares it:
|
|
6
|
+
|
|
7
|
+
init <repo> Create the plan branch + `.work-plan/` skeleton for <repo>, or
|
|
8
|
+
connect to a teammate's already-published one, and record
|
|
9
|
+
`plan_branch` in config. LOCAL ONLY — no network push. Default
|
|
10
|
+
branch is an ORPHAN `work-plan/plan` (zero shared history with
|
|
11
|
+
code, like gh-pages); override with --branch=<name>.
|
|
12
|
+
status <repo> Report the configured plan_branch: does it exist, is it
|
|
13
|
+
published to origin, how many local commits are unpushed.
|
|
14
|
+
Add --json for the machine shape.
|
|
15
|
+
push <repo> Push the plan branch to origin to share it. This is the exposure
|
|
16
|
+
point: on a PUBLIC repo it prints a confirm heads-up + token and
|
|
17
|
+
exits; re-run with --confirm=<token>. --dry-run previews the
|
|
18
|
+
commits that would be pushed without pushing.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
plan-branch <init|status|push> <repo> [--branch=<name>] [--confirm=<token>]
|
|
22
|
+
[--dry-run] [--json]
|
|
23
|
+
"""
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from lib.config import (
|
|
32
|
+
load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo,
|
|
33
|
+
)
|
|
34
|
+
from lib.git_state import is_safe_ref
|
|
35
|
+
from lib.notes_readme import seed_readme
|
|
36
|
+
from lib.prompts import parse_flags
|
|
37
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
38
|
+
from lib import plan_worktree as pw
|
|
39
|
+
|
|
40
|
+
_ACTIONS = ("init", "status", "push")
|
|
41
|
+
_DEFAULT_BRANCH = "work-plan/plan"
|
|
42
|
+
# A git refname segment: starts alnum, then alnum / . _ - and / separators. We
|
|
43
|
+
# additionally reject `..`, leading/trailing `/`, and `//` below.
|
|
44
|
+
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
|
45
|
+
_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$") # safe for the yq config path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _valid_branch(name: str) -> bool:
|
|
49
|
+
return (
|
|
50
|
+
is_safe_ref(name)
|
|
51
|
+
and bool(_BRANCH_RE.fullmatch(name))
|
|
52
|
+
and ".." not in name
|
|
53
|
+
and "//" not in name
|
|
54
|
+
and not name.startswith("/")
|
|
55
|
+
and not name.endswith("/")
|
|
56
|
+
and not name.endswith(".lock")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _set_plan_branch(key: str, branch: str) -> bool:
|
|
61
|
+
"""Persist .repos.<key>.plan_branch=<branch> into config via yq. The branch
|
|
62
|
+
travels as an opaque env value (strenv), never interpolated. `key` is the
|
|
63
|
+
config repo key (validated by the caller). Returns True on success."""
|
|
64
|
+
env = {**os.environ, "WP_PLAN_BRANCH": branch}
|
|
65
|
+
expr = f".repos.{key}.plan_branch = strenv(WP_PLAN_BRANCH)"
|
|
66
|
+
try:
|
|
67
|
+
subprocess.run(
|
|
68
|
+
["yq", "-i", expr, str(DEFAULT_CONFIG_PATH)],
|
|
69
|
+
check=True, capture_output=True, text=True, env=env, timeout=20,
|
|
70
|
+
)
|
|
71
|
+
return True
|
|
72
|
+
except subprocess.CalledProcessError as e:
|
|
73
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
74
|
+
return False
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
76
|
+
# yq missing / hung — degrade cleanly rather than crash with a traceback.
|
|
77
|
+
print(f"ERROR: could not run yq to update config: {e}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_repo(cfg: dict, repo_arg: Optional[str]):
|
|
82
|
+
"""Return (key, entry, github, local_path) for a CONFIGURED repo, or print an
|
|
83
|
+
error and return None. plan-branch writes into a repo's config entry, so the
|
|
84
|
+
repo must be registered (init-repo) and have a local clone path."""
|
|
85
|
+
repos = cfg.get("repos") or {}
|
|
86
|
+
if repo_arg is None:
|
|
87
|
+
if len(repos) == 1:
|
|
88
|
+
key = next(iter(repos))
|
|
89
|
+
else:
|
|
90
|
+
print("ERROR: specify which repo — e.g. `plan-branch init <key>`. "
|
|
91
|
+
f"Configured: {', '.join(repos) or '(none)'}.")
|
|
92
|
+
return None
|
|
93
|
+
elif repo_arg in repos:
|
|
94
|
+
key = repo_arg
|
|
95
|
+
else:
|
|
96
|
+
print(f"ERROR: '{repo_arg}' is not a configured repo. Register it first "
|
|
97
|
+
"with `init-repo <key> --github=org/repo --local=<path>`.")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if not _KEY_RE.fullmatch(key):
|
|
101
|
+
print(f"ERROR: repo key '{key}' has unexpected characters; refusing to "
|
|
102
|
+
"edit config for it.")
|
|
103
|
+
return None
|
|
104
|
+
entry = repos[key] or {}
|
|
105
|
+
github = entry.get("github")
|
|
106
|
+
local_raw = entry.get("local")
|
|
107
|
+
if not local_raw:
|
|
108
|
+
print(f"ERROR: repo '{key}' has no local clone path in config. Add one "
|
|
109
|
+
f"with `init-repo {key} --github={github or 'org/repo'} --local=<path>`.")
|
|
110
|
+
return None
|
|
111
|
+
local_path = Path(local_raw).expanduser()
|
|
112
|
+
if not is_valid_git_repo(local_path):
|
|
113
|
+
print(f"ERROR: {local_path} is not a git repository.")
|
|
114
|
+
return None
|
|
115
|
+
return key, entry, github, local_path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _do_init(cfg, key, entry, github, local_path, flags) -> int:
|
|
119
|
+
raw = flags.get("--branch")
|
|
120
|
+
branch = raw if isinstance(raw, str) and raw else _DEFAULT_BRANCH
|
|
121
|
+
if not _valid_branch(branch):
|
|
122
|
+
print(f"ERROR: '{branch}' is not a valid branch name.")
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
existing = entry.get("plan_branch")
|
|
126
|
+
if existing and existing != branch:
|
|
127
|
+
print(f"ERROR: repo '{key}' already has plan_branch '{existing}'. "
|
|
128
|
+
"Refusing to silently switch it — edit config to change.")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
# Fetch so the connect-vs-create decision sees a teammate's published branch.
|
|
132
|
+
pw.fetch_branch(local_path, branch)
|
|
133
|
+
|
|
134
|
+
if pw._branch_exists(local_path, branch):
|
|
135
|
+
# Connect: a branch already exists (local or origin) — reuse it.
|
|
136
|
+
wt = pw.ensure_worktree(local_path, branch)
|
|
137
|
+
if wt is None:
|
|
138
|
+
print(f"ERROR: branch '{branch}' exists but its worktree could not be "
|
|
139
|
+
"created. Resolve any conflicting worktree and retry.")
|
|
140
|
+
return 1
|
|
141
|
+
# The branch already carries its own .work-plan/ — connecting just wires
|
|
142
|
+
# it up; no seeding (and no write into a possibly-absent dir).
|
|
143
|
+
print(f"✓ Connected repo '{key}' to existing plan branch '{branch}'.")
|
|
144
|
+
published = pw.is_published(local_path, branch)
|
|
145
|
+
print(f" Source: {'origin (a teammate published it)' if published else 'local'}.")
|
|
146
|
+
else:
|
|
147
|
+
# Create a fresh orphan branch with only .work-plan/.
|
|
148
|
+
dest = pw.create_orphan_worktree(local_path, branch)
|
|
149
|
+
if dest is None:
|
|
150
|
+
print(f"ERROR: could not create the plan worktree for '{branch}'. "
|
|
151
|
+
"Is there a stale worktree at the cache path, or no commits in "
|
|
152
|
+
"the repo yet?")
|
|
153
|
+
return 1
|
|
154
|
+
seed_readme(dest / ".work-plan")
|
|
155
|
+
paths = pw.dirty_work_plan_paths(dest)
|
|
156
|
+
sha = pw.commit_shared_tier(
|
|
157
|
+
dest, f"work-plan: initialize plan branch {branch}", paths)
|
|
158
|
+
if sha is None:
|
|
159
|
+
print(f"ERROR: created the worktree but the initial commit failed.")
|
|
160
|
+
return 1
|
|
161
|
+
print(f"✓ Created orphan branch '{branch}' for '{key}' ({sha}, local only).")
|
|
162
|
+
print(" It holds only plan data — no shared history with your code, so "
|
|
163
|
+
"it won't appear in pull requests or deploys.")
|
|
164
|
+
|
|
165
|
+
if not _set_plan_branch(key, branch):
|
|
166
|
+
return 1
|
|
167
|
+
print(f"✓ Recorded plan_branch '{branch}' in config for '{key}'.")
|
|
168
|
+
print()
|
|
169
|
+
print("Next:")
|
|
170
|
+
print(f" • Add a shared track: /work-plan new-track {key} <slug>")
|
|
171
|
+
print(f" • Share the branch: /work-plan plan-branch push {key}")
|
|
172
|
+
if github and needs_confirm(github, cfg):
|
|
173
|
+
print(f" ⚠ {github} is public — `push` will make the plan visible to anyone.")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _do_status(cfg, key, entry, github, local_path, flags) -> int:
|
|
178
|
+
branch = entry.get("plan_branch")
|
|
179
|
+
want_json = "--json" in flags
|
|
180
|
+
if not branch:
|
|
181
|
+
if want_json:
|
|
182
|
+
print(json.dumps({"repo": key, "plan_branch": None,
|
|
183
|
+
"configured": False}))
|
|
184
|
+
else:
|
|
185
|
+
print(f"repo '{key}': no plan_branch configured.")
|
|
186
|
+
print(f" Run `plan-branch init {key}` to set one up.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
pw.fetch_branch(local_path, branch) # best-effort: accurate published/unpushed
|
|
190
|
+
local_exists = pw.local_branch_exists(local_path, branch)
|
|
191
|
+
published = pw.is_published(local_path, branch)
|
|
192
|
+
unpushed = pw.unpushed_oneline(local_path, branch)
|
|
193
|
+
|
|
194
|
+
if want_json:
|
|
195
|
+
print(json.dumps({
|
|
196
|
+
"repo": key, "plan_branch": branch, "configured": True,
|
|
197
|
+
"local_exists": local_exists, "published": published,
|
|
198
|
+
"unpushed_count": len(unpushed),
|
|
199
|
+
}))
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
print(f"repo '{key}': plan_branch '{branch}'")
|
|
203
|
+
print(f" local branch: {'✓ present' if local_exists else '✗ missing (run init)'}")
|
|
204
|
+
print(f" published: {'✓ on origin' if published else '✗ local only — not shared yet'}")
|
|
205
|
+
if unpushed:
|
|
206
|
+
print(f" unpushed: {len(unpushed)} commit(s) — run "
|
|
207
|
+
f"`plan-branch push {key}` to share:")
|
|
208
|
+
for line in unpushed[:10]:
|
|
209
|
+
print(f" {line}")
|
|
210
|
+
if len(unpushed) > 10:
|
|
211
|
+
print(f" … and {len(unpushed) - 10} more")
|
|
212
|
+
else:
|
|
213
|
+
print(" unpushed: none — origin is up to date.")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _do_push(cfg, key, entry, github, local_path, flags) -> int:
|
|
218
|
+
branch = entry.get("plan_branch")
|
|
219
|
+
if not branch:
|
|
220
|
+
print(f"ERROR: repo '{key}' has no plan_branch. Run `plan-branch init "
|
|
221
|
+
f"{key}` first.")
|
|
222
|
+
return 1
|
|
223
|
+
if not pw.local_branch_exists(local_path, branch):
|
|
224
|
+
print(f"ERROR: plan branch '{branch}' doesn't exist locally. Run "
|
|
225
|
+
f"`plan-branch init {key}` first.")
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
pw.fetch_branch(local_path, branch)
|
|
229
|
+
commits = pw.unpushed_oneline(local_path, branch)
|
|
230
|
+
|
|
231
|
+
if "--dry-run" in flags:
|
|
232
|
+
if not commits:
|
|
233
|
+
print(f"Nothing to push — origin/{branch} is up to date.")
|
|
234
|
+
return 0
|
|
235
|
+
print(f"Would push {len(commits)} commit(s) to origin/{branch}:")
|
|
236
|
+
for line in commits:
|
|
237
|
+
print(f" {line}")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
if not commits:
|
|
241
|
+
print(f"Nothing to push — origin/{branch} is up to date.")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
# Exposure gate: publishing planning notes to a PUBLIC repo is a meaningful,
|
|
245
|
+
# effectively-permanent disclosure. Same confirm-token flow as other public
|
|
246
|
+
# writes, with concrete wording about what becomes visible. No `github and`
|
|
247
|
+
# short-circuit — needs_confirm() fails CLOSED on empty/unknown visibility,
|
|
248
|
+
# and that fail-closed behaviour must NOT be defeated (a config entry with a
|
|
249
|
+
# null/empty github would otherwise push unguarded).
|
|
250
|
+
if needs_confirm(github, cfg):
|
|
251
|
+
confirm = flags.get("--confirm")
|
|
252
|
+
if not (isinstance(confirm, str) and valid_token(confirm, github, branch)):
|
|
253
|
+
print(json.dumps({
|
|
254
|
+
"needs_confirm": True,
|
|
255
|
+
"reason": (
|
|
256
|
+
f"{github} is PUBLIC (or its visibility is unknown). Pushing "
|
|
257
|
+
f"'{branch}' makes your plan files — issue notes, priorities, "
|
|
258
|
+
"and planning text — visible to anyone on the internet, and "
|
|
259
|
+
"they remain in public git history even if the branch is "
|
|
260
|
+
"later deleted."
|
|
261
|
+
),
|
|
262
|
+
"token": make_token(github, branch),
|
|
263
|
+
}))
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
proc = pw.push_plan_branch(local_path, branch)
|
|
267
|
+
if proc is None:
|
|
268
|
+
print("ERROR: could not run git to push.")
|
|
269
|
+
return 1
|
|
270
|
+
if proc.returncode != 0:
|
|
271
|
+
err = (proc.stderr or "").strip()
|
|
272
|
+
if "protected" in err.lower() or "pull request" in err.lower():
|
|
273
|
+
print(f"ERROR: origin rejected the push — '{branch}' looks protected. "
|
|
274
|
+
f"Exempt '{branch.split('/')[0]}/**' from PR/branch-protection "
|
|
275
|
+
"rules for the plan branch, or push it manually once.")
|
|
276
|
+
else:
|
|
277
|
+
print(f"ERROR: push failed: {err or 'unknown git error'}")
|
|
278
|
+
return 1
|
|
279
|
+
print(f"✓ Pushed '{branch}' to origin ({len(commits)} commit(s)). "
|
|
280
|
+
"Teammates can `plan-branch init` to connect.")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run(args: list[str]) -> int:
|
|
285
|
+
flags, positional = parse_flags(
|
|
286
|
+
args, {"--branch", "--confirm", "--dry-run", "--json"})
|
|
287
|
+
|
|
288
|
+
action = positional[0] if positional else None
|
|
289
|
+
if action not in _ACTIONS:
|
|
290
|
+
print(f"usage: work_plan.py plan-branch <{'|'.join(_ACTIONS)}> <repo> "
|
|
291
|
+
"[--branch=<name>] [--confirm=<token>] [--dry-run] [--json]")
|
|
292
|
+
return 2
|
|
293
|
+
|
|
294
|
+
repo_arg = positional[1] if len(positional) > 1 else None
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
cfg = load_config()
|
|
298
|
+
except (ConfigError, subprocess.CalledProcessError, OSError) as e:
|
|
299
|
+
# ConfigError is the expected case; CalledProcessError / OSError cover a
|
|
300
|
+
# malformed config or a missing `yq` so the command degrades to a clean
|
|
301
|
+
# error instead of a traceback (never-raise contract).
|
|
302
|
+
print(f"ERROR: could not load config: {e}")
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
resolved = _resolve_repo(cfg, repo_arg)
|
|
306
|
+
if resolved is None:
|
|
307
|
+
return 1
|
|
308
|
+
key, entry, github, local_path = resolved
|
|
309
|
+
|
|
310
|
+
if action == "init":
|
|
311
|
+
return _do_init(cfg, key, entry, github, local_path, flags)
|
|
312
|
+
if action == "status":
|
|
313
|
+
return _do_status(cfg, key, entry, github, local_path, flags)
|
|
314
|
+
return _do_push(cfg, key, entry, github, local_path, flags)
|
|
@@ -19,7 +19,7 @@ from lib.scratch import cache_dir
|
|
|
19
19
|
from lib.prompts import parse_flags, prompt_yes_no
|
|
20
20
|
|
|
21
21
|
KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
|
|
22
|
-
"--llm", "--apply", "--archive", "--issues"}
|
|
22
|
+
"--llm", "--apply", "--archive", "--issues", "--stall-days"}
|
|
23
23
|
_ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
|
|
24
24
|
|
|
25
25
|
|
|
@@ -35,8 +35,54 @@ def _resolve_repo_root(flags) -> Path:
|
|
|
35
35
|
return Path.cwd()
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def
|
|
39
|
-
|
|
38
|
+
def _resolve_stall_days(flags) -> int:
|
|
39
|
+
"""Precedence for the staleness threshold (#164):
|
|
40
|
+
--stall-days flag (int) -> config.yml `stall_days` (int) -> default 14.
|
|
41
|
+
A non-integer flag value falls through to the config/default tier.
|
|
42
|
+
"""
|
|
43
|
+
raw = flags.get("--stall-days")
|
|
44
|
+
if raw not in (None, True):
|
|
45
|
+
try:
|
|
46
|
+
return int(raw)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
pass
|
|
49
|
+
cfg_val = config_mod.load_config().get("stall_days")
|
|
50
|
+
if isinstance(cfg_val, int):
|
|
51
|
+
return cfg_val
|
|
52
|
+
return verdict_mod.STALL_DAYS
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read(path) -> str:
|
|
56
|
+
"""Read a doc's text. Indirected so tests can patch it without a real file."""
|
|
57
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _declared_paths_on_disk(decls, repo_root) -> list:
|
|
61
|
+
"""Repo-relative declared paths that point at a real FILE inside repo_root.
|
|
62
|
+
|
|
63
|
+
Both guards matter for the staleness clock: a junk declared path like `/`
|
|
64
|
+
resolves (via `repo_root / "/"`) to the filesystem root — a directory that
|
|
65
|
+
exists — and an out-of-tree `../x` path can exist too. Either one passed to
|
|
66
|
+
`git log -- <paths…>` poisons the WHOLE call (it returns nothing), which
|
|
67
|
+
would falsely mark an actively-built plan as stalled. So require an actual
|
|
68
|
+
file (`is_file`) that resolves under repo_root."""
|
|
69
|
+
root = Path(repo_root).resolve()
|
|
70
|
+
out = []
|
|
71
|
+
for d in decls:
|
|
72
|
+
p = Path(repo_root) / d.path
|
|
73
|
+
try:
|
|
74
|
+
if not p.is_file():
|
|
75
|
+
continue
|
|
76
|
+
resolved = p.resolve()
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
if resolved == root or root in resolved.parents:
|
|
80
|
+
out.append(d.path)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
|
|
85
|
+
text = _read(doc.path)
|
|
40
86
|
decls = manifest.parse_declared_paths(text)
|
|
41
87
|
pdate = manifest.plan_date_from_filename(doc.path.name)
|
|
42
88
|
score = manifest.score_manifest(decls, repo_root, pdate)
|
|
@@ -48,12 +94,36 @@ def _evaluate(doc, repo_root, today, dead_days) -> dict:
|
|
|
48
94
|
"foreign", "🧳", "declared paths point outside this repo — misfiled?")
|
|
49
95
|
else:
|
|
50
96
|
v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
|
|
97
|
+
|
|
98
|
+
# Staleness clock (#164): a partial plan whose declared manifest files have
|
|
99
|
+
# gone cold = "started executing, then drifted off." Key off the manifest
|
|
100
|
+
# files' commit date, NOT the plan doc's git date — plan docs are gitignored,
|
|
101
|
+
# so the doc date is null and would make this a silent no-op.
|
|
102
|
+
manifest_dt = None
|
|
103
|
+
stalled = False
|
|
104
|
+
if v.label == "partial":
|
|
105
|
+
on_disk = _declared_paths_on_disk(decls, repo_root)
|
|
106
|
+
if on_disk:
|
|
107
|
+
manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
|
|
108
|
+
if manifest_dt is None:
|
|
109
|
+
stalled = True # present on disk but never committed
|
|
110
|
+
else:
|
|
111
|
+
stalled = (today - manifest_dt.date()).days >= stall_days
|
|
112
|
+
# else: no declared files on disk yet -> brand-new, not stalled.
|
|
113
|
+
|
|
114
|
+
lie_gap = (v.label == "shipped" and total_chk > 0
|
|
115
|
+
and done / total_chk < 0.25)
|
|
51
116
|
return {
|
|
52
117
|
"rel": doc.rel, "kind": doc.kind,
|
|
53
118
|
"verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
|
|
54
119
|
"files_present": score.satisfied, "files_declared": score.total,
|
|
55
120
|
"checkboxes_done": done, "checkboxes_total": total_chk,
|
|
56
121
|
"last_touched": last_d.isoformat() if last_d else None,
|
|
122
|
+
"manifest_last_touched": manifest_dt.date().isoformat() if manifest_dt else None,
|
|
123
|
+
"stalled": stalled,
|
|
124
|
+
"lie_gap": lie_gap,
|
|
125
|
+
"unchecked_items": manifest.unchecked_checkbox_labels(text),
|
|
126
|
+
"stall_days": stall_days,
|
|
57
127
|
}
|
|
58
128
|
|
|
59
129
|
|
|
@@ -62,11 +132,7 @@ def _render(rows, repo_root) -> None:
|
|
|
62
132
|
by = {}
|
|
63
133
|
for r in rows:
|
|
64
134
|
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
|
-
)
|
|
135
|
+
lie_gap = sum(1 for r in rows if r["lie_gap"])
|
|
70
136
|
summary = " · ".join(f"{len(by[k])} {k}" for k in _ORDER if by.get(k))
|
|
71
137
|
print(f"{len(rows)} docs · {summary}")
|
|
72
138
|
print(f"lie-gap (shipped but <25% boxes checked): {lie_gap}\n")
|
|
@@ -271,7 +337,8 @@ def run(args: list) -> int:
|
|
|
271
337
|
if type_filter and type_filter is not True:
|
|
272
338
|
docs = [d for d in docs if d.kind == type_filter]
|
|
273
339
|
|
|
274
|
-
|
|
340
|
+
stall_days = _resolve_stall_days(flags)
|
|
341
|
+
rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
|
|
275
342
|
|
|
276
343
|
if flags.get("--llm"):
|
|
277
344
|
if flags.get("--apply"):
|
|
@@ -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
|