@stylusnexus/work-plan 2026.6.11-2 → 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 +8 -3
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- 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/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/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_plan_status_stalled.py +219 -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 +14 -4
package/README.md
CHANGED
|
@@ -511,11 +511,12 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
511
511
|
| `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
|
|
512
512
|
| `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below). |
|
|
513
513
|
| `close <track> [--state=shipped\|parked\|abandoned] [--note=<text>]` | Mark track shipped, parked, or abandoned. Moves to `archive/<state>/` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. |
|
|
514
|
-
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
514
|
+
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. If the live fetch comes back incomplete (GitHub timeout/permission error, or a frontmatter issue that no longer resolves), that track is **skipped and left untouched** rather than rewriting valid rows as `(not fetched)`, and the command exits nonzero so sweeps can flag the degraded run. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
515
515
|
| `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
|
|
516
516
|
| `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. |
|
|
517
517
|
| `init <path> [--priority=P0..P3] [--milestone=<m>]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. |
|
|
518
|
-
| `init-repo <key> --github=<slug> [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required; `--local` is optional. |
|
|
518
|
+
| `init-repo <key> --github=<slug> [--local=<path>] [--update [--clear-local]]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required for an add; `--local` is optional. `--update` on an existing key changes its local/github; `--update --clear-local` forgets the saved local path (keeps github + other fields). `--clear-local` and `--local` are mutually exclusive. |
|
|
519
|
+
| `remove-repo <key>` | Unregister a repo: delete its block from your config. **Config-only** — the notes folder, any tracks, and the local clone are left untouched (a notes folder or tracks that referenced it are now orphaned and can be removed by hand). Completes the add/update/remove trio with `init-repo`. |
|
|
519
520
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
520
521
|
| `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
|
|
521
522
|
| `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
|
|
@@ -538,7 +539,11 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
|
|
|
538
539
|
|
|
539
540
|
`needs_confirm` fails **closed** — unknown visibility prompts too. An all-private team can opt out of the *unknown-visibility* case (e.g. when a `gh` lookup flakes) by setting `assume_private_when_unknown: true` in `~/.claude/work-plan/config.yml`; **public repos always prompt regardless.**
|
|
540
541
|
|
|
541
|
-
`export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo.
|
|
542
|
+
`export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo. It also emits a top-level `repos` list — every configured repo (`folder`/`repo`/`local`/`has_local`/`visibility`), tracked or not — so the viewer can show a freshly registered repo in the sidebar independent of whether it has any tracks yet.
|
|
543
|
+
|
|
544
|
+
`list-open-issues --repo=<owner/name> [--exclude=<csv>]` is a second viewer read surface: it emits a repo's **open** issues as JSON (`{repo, issues:[…]}`, the same per-issue shape as `export`). The extension's **Slot** command uses it to offer a pick-list instead of a typed number; `--exclude` drops the track's current issues so they don't reappear. Unlike `export`'s `untracked` (open issues in *no* track), this includes issues tracked by *other* tracks — they're valid slot targets. Read-only.
|
|
545
|
+
|
|
546
|
+
`plan-status --json` is the viewer's **Plans view** read surface: alongside each doc's verdict it now also emits `manifest_last_touched` (the most recent commit date across the plan's declared files), `stalled`, `lie_gap`, `unchecked_items`, and `stall_days`. The staleness window honors `stall_days:` in `~/.claude/work-plan/config.yml` and a `--stall-days=<n>` flag (precedence: flag → config → default 14). The viewer consumes these to flag plans whose declared-file build has gone cold — a `partial` plan with no recent commit on its manifest ("stalled") — and plans scored shipped whose own phase checkboxes are mostly unticked ("lie-gap").
|
|
542
547
|
|
|
543
548
|
## Version
|
|
544
549
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.13+627d944
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.13",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""export subcommand — emit the viewer-ready JSON read surface."""
|
|
2
2
|
import json
|
|
3
3
|
from datetime import datetime
|
|
4
|
-
from lib.config import load_config, ConfigError
|
|
4
|
+
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
5
5
|
from lib.tracks import discover_tracks
|
|
6
6
|
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
7
|
from lib.export_model import build_export
|
|
@@ -60,10 +60,28 @@ def run(args: list[str]) -> int:
|
|
|
60
60
|
open_rows = fetch_open_issues(repo)
|
|
61
61
|
untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
|
|
62
62
|
|
|
63
|
+
# Every CONFIGURED repo, regardless of whether any track references it (#288).
|
|
64
|
+
# Lets the viewer show a registered-but-empty repo so the user can start
|
|
65
|
+
# adding tracks to it. visibility is filled here for repos no track covered.
|
|
66
|
+
config_repos = []
|
|
67
|
+
for folder, block in (cfg.get("repos") or {}).items():
|
|
68
|
+
slug = block.get("github") if isinstance(block, dict) else None
|
|
69
|
+
local = resolve_local_path_for_folder(folder, cfg)
|
|
70
|
+
if slug and slug not in visibility:
|
|
71
|
+
visibility[slug] = repo_visibility(slug)
|
|
72
|
+
config_repos.append({
|
|
73
|
+
"folder": folder,
|
|
74
|
+
"repo": slug,
|
|
75
|
+
"local": str(local) if local else None,
|
|
76
|
+
"has_local": bool(local and local.exists()),
|
|
77
|
+
"visibility": visibility.get(slug),
|
|
78
|
+
})
|
|
79
|
+
|
|
63
80
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
64
81
|
print(json.dumps(
|
|
65
82
|
build_export(tracks, issues_by_track, visibility, now,
|
|
66
|
-
untracked_by_repo=untracked_by_repo
|
|
83
|
+
untracked_by_repo=untracked_by_repo,
|
|
84
|
+
config_repos=config_repos),
|
|
67
85
|
indent=2,
|
|
68
86
|
))
|
|
69
87
|
return 0
|
|
@@ -50,10 +50,56 @@ def _report_shared_tracks(local_path: "Path | None") -> None:
|
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _update_existing(key: str, github: str, local: "str | None", clear_local: bool = False) -> int:
|
|
54
|
+
"""Update an already-registered repo's local (and github if it differs).
|
|
55
|
+
|
|
56
|
+
Does NOT recreate the notes folder / archive dirs — they already exist.
|
|
57
|
+
Uses the same env()-via-opaque-block yq pattern as a fresh add, setting only
|
|
58
|
+
the fields that change so other keys in the block are preserved.
|
|
59
|
+
|
|
60
|
+
clear_local sets `.repos.<key>.local = null` (forget a stale checkout path)
|
|
61
|
+
while keeping github + every other field. Mutually exclusive with `local`
|
|
62
|
+
(enforced in run() before this is called).
|
|
63
|
+
"""
|
|
64
|
+
updates = {}
|
|
65
|
+
if clear_local:
|
|
66
|
+
# JSON null → YAML null; the * merge overwrites local with null, leaving
|
|
67
|
+
# github + other keys intact (same opaque-env discipline as below).
|
|
68
|
+
updates["local"] = None
|
|
69
|
+
elif local:
|
|
70
|
+
updates["local"] = local
|
|
71
|
+
if github:
|
|
72
|
+
updates["github"] = github
|
|
73
|
+
if not updates:
|
|
74
|
+
print(f"ℹ Nothing to update for repo '{key}' (no --local, --clear-local, or --github given).")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
|
|
78
|
+
# so it's safe in the yq path. Field values travel as an OPAQUE env value via
|
|
79
|
+
# env() (parsed as JSON), never interpolated — uniform with the add path.
|
|
80
|
+
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
81
|
+
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
85
|
+
check=True, capture_output=True, text=True, env=env,
|
|
86
|
+
)
|
|
87
|
+
except subprocess.CalledProcessError as e:
|
|
88
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
89
|
+
return 1
|
|
90
|
+
if clear_local:
|
|
91
|
+
print(f"✓ Cleared local path for '{key}'")
|
|
92
|
+
elif local:
|
|
93
|
+
print(f"✓ Updated repo '{key}' local path → {local}")
|
|
94
|
+
else:
|
|
95
|
+
print(f"✓ Updated repo '{key}' in {DEFAULT_CONFIG_PATH}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
53
99
|
def run(args: list[str]) -> int:
|
|
54
|
-
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
100
|
+
flags, positional = parse_flags(args, {"--github", "--local", "--update", "--clear-local"})
|
|
55
101
|
if not positional:
|
|
56
|
-
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>]")
|
|
102
|
+
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>] [--update [--clear-local]]")
|
|
57
103
|
return 2
|
|
58
104
|
|
|
59
105
|
key = positional[0]
|
|
@@ -61,13 +107,23 @@ def run(args: list[str]) -> int:
|
|
|
61
107
|
print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
|
|
62
108
|
return 2
|
|
63
109
|
|
|
64
|
-
|
|
110
|
+
clear_local = bool(flags.get("--clear-local"))
|
|
111
|
+
local = flags.get("--local") or None
|
|
112
|
+
|
|
113
|
+
# --clear-local forgets the saved local path; pairing it with --local (which
|
|
114
|
+
# SETS a path) is contradictory.
|
|
115
|
+
if clear_local and local:
|
|
116
|
+
print("ERROR: --clear-local and --local are mutually exclusive.")
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
# --github is required for a fresh add / a github change, but --clear-local is
|
|
120
|
+
# a field-only edit on an existing block, so we don't force it there.
|
|
65
121
|
github = flags.get("--github")
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
if github and "/" not in github:
|
|
123
|
+
print("ERROR: github slug must be in the form 'org/repo'.")
|
|
124
|
+
return 2
|
|
125
|
+
if not github and not clear_local:
|
|
126
|
+
print("ERROR: --github is required (e.g. --github=org/repo).")
|
|
71
127
|
return 2
|
|
72
128
|
|
|
73
129
|
try:
|
|
@@ -77,19 +133,33 @@ def run(args: list[str]) -> int:
|
|
|
77
133
|
print("\nRun ./install.sh from the toolkit root to seed your config first.")
|
|
78
134
|
return 1
|
|
79
135
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
print("Edit it manually, or pick a different key.")
|
|
83
|
-
return 1
|
|
136
|
+
update = bool(flags.get("--update"))
|
|
137
|
+
existing = cfg.get("repos", {})
|
|
84
138
|
|
|
85
|
-
# --local is
|
|
86
|
-
|
|
139
|
+
# --clear-local is an update-only operation on an existing key.
|
|
140
|
+
if clear_local:
|
|
141
|
+
if not update:
|
|
142
|
+
print("ERROR: --clear-local requires --update (it edits an existing repo).")
|
|
143
|
+
return 2
|
|
144
|
+
if key not in existing:
|
|
145
|
+
print(f"ERROR: repo '{key}' not found in {DEFAULT_CONFIG_PATH} — nothing to clear.")
|
|
146
|
+
return 1
|
|
147
|
+
return _update_existing(key, github or "", None, clear_local=True)
|
|
148
|
+
|
|
149
|
+
# --local is optional; if absent, skip (no prompt). Validate it exists.
|
|
87
150
|
local_path = None
|
|
88
151
|
if local:
|
|
89
152
|
local_path = Path(local).expanduser()
|
|
90
153
|
if not local_path.exists():
|
|
91
154
|
print(f"WARN: {local_path} does not exist. Saving anyway — fix later if wrong.")
|
|
92
155
|
|
|
156
|
+
if key in existing:
|
|
157
|
+
if not update:
|
|
158
|
+
print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
|
|
159
|
+
print("Pass --update to change its local path, or pick a different key.")
|
|
160
|
+
return 1
|
|
161
|
+
return _update_existing(key, github, local)
|
|
162
|
+
|
|
93
163
|
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
94
164
|
if not notes_root.exists():
|
|
95
165
|
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""list-open-issues subcommand — emit a repo's open issues as JSON.
|
|
2
|
+
|
|
3
|
+
A read surface for the VS Code viewer's Slot command (#282): Slot adds an issue
|
|
4
|
+
that is typically NOT already in the track, so the per-track export can't supply
|
|
5
|
+
the candidate list. This fetches the repo's open issues live via `gh` and emits
|
|
6
|
+
them in the same `Issue` shape the export uses, so the viewer can offer a
|
|
7
|
+
pick-list instead of a free-typed number.
|
|
8
|
+
|
|
9
|
+
Read-only. Never writes anything. The viewer passes the track's current issue
|
|
10
|
+
numbers via --exclude so already-slotted issues are filtered out here.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from lib.github_state import fetch_open_issues
|
|
15
|
+
from lib.export_model import normalize_issue
|
|
16
|
+
from lib.prompts import parse_flags
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(args: list[str]) -> int:
|
|
20
|
+
flags, _ = parse_flags(args, {"--repo", "--exclude"})
|
|
21
|
+
|
|
22
|
+
repo = flags.get("--repo")
|
|
23
|
+
if not repo or repo is True:
|
|
24
|
+
print(json.dumps({"error": "list-open-issues requires --repo=<owner/name>"}))
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
exclude = _parse_exclude(flags.get("--exclude"))
|
|
28
|
+
|
|
29
|
+
# fetch_open_issues validates the slug and returns [] on any error/bad repo,
|
|
30
|
+
# so a malformed --repo yields an empty list rather than raising.
|
|
31
|
+
rows = fetch_open_issues(repo)
|
|
32
|
+
issues = [
|
|
33
|
+
normalize_issue(r) for r in rows
|
|
34
|
+
if r.get("number") not in exclude
|
|
35
|
+
]
|
|
36
|
+
print(json.dumps({"repo": repo, "issues": issues}, indent=2))
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_exclude(raw) -> set:
|
|
41
|
+
"""Parse the --exclude CSV (e.g. "87,91,103") into a set of ints.
|
|
42
|
+
|
|
43
|
+
Tolerates blanks and non-numeric tokens (skipped), so a stray trailing
|
|
44
|
+
comma or empty value never errors. `True` (bare --exclude) → empty set."""
|
|
45
|
+
if not raw or raw is True:
|
|
46
|
+
return set()
|
|
47
|
+
out = set()
|
|
48
|
+
for tok in str(raw).split(","):
|
|
49
|
+
tok = tok.strip()
|
|
50
|
+
if tok.isdigit():
|
|
51
|
+
out.add(int(tok))
|
|
52
|
+
return out
|
|
@@ -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
|
|
@@ -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.
|