@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""plan-ack — persist an acknowledgment into a plan/spec doc's YAML frontmatter
|
|
2
|
+
(#286 slice 1).
|
|
3
|
+
|
|
4
|
+
The VS Code viewer's default "Acknowledge (stop flagging)" persists in
|
|
5
|
+
per-machine `workspaceState` — ephemeral and unshared. This command writes a
|
|
6
|
+
durable `acknowledged: true` into the doc's **frontmatter only** (never the body,
|
|
7
|
+
manifest, checkboxes, or status banner), so the acknowledgment is committed with
|
|
8
|
+
the repo and shared with teammates. `plan-status` reads it back and demotes the
|
|
9
|
+
doc the same way a local ack does.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
work_plan.py plan-ack --repo=<key> [--confirm=<token>] -- <rel>
|
|
13
|
+
work_plan.py plan-ack --repo=<key> --clear [--confirm=<token>] -- <rel>
|
|
14
|
+
|
|
15
|
+
`<rel>` is the repo-relative POSIX path of the plan doc (as emitted by
|
|
16
|
+
`plan-status --json`); it is validated to resolve to a real file inside the repo.
|
|
17
|
+
"""
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from lib import config as config_mod
|
|
21
|
+
from lib import plan_fm
|
|
22
|
+
from lib.prompts import parse_flags
|
|
23
|
+
|
|
24
|
+
KNOWN = {"--repo", "--clear", "--confirm"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(args: list) -> int:
|
|
28
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
29
|
+
|
|
30
|
+
repo = flags.get("--repo")
|
|
31
|
+
if not repo or repo is True:
|
|
32
|
+
print("ERROR: --repo=<key> is required.", file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
if not positional:
|
|
35
|
+
print("usage: work_plan.py plan-ack --repo=<key> [--clear] -- <rel>",
|
|
36
|
+
file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
rel = positional[0]
|
|
39
|
+
clear = bool(flags.get("--clear"))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
cfg = config_mod.load_config()
|
|
43
|
+
except config_mod.ConfigError as e:
|
|
44
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
local = config_mod.resolve_local_path_for_folder(repo, cfg)
|
|
48
|
+
if not local or not local.exists():
|
|
49
|
+
print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
doc_path = plan_fm.resolve_doc_path(local, rel)
|
|
53
|
+
if doc_path is None:
|
|
54
|
+
print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
slug = config_mod.resolve_github_for_folder(repo, cfg)
|
|
58
|
+
action = "clearing the acknowledgment on" if clear else f"acknowledging '{rel}' via"
|
|
59
|
+
if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
if clear:
|
|
63
|
+
if not plan_fm.set_key(doc_path, "acknowledged", None):
|
|
64
|
+
print(f"✓ {rel} was not acknowledged in frontmatter (nothing to clear).")
|
|
65
|
+
return 0
|
|
66
|
+
print(f"✓ cleared acknowledgment on {rel} (frontmatter only).")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
plan_fm.set_key(doc_path, "acknowledged", True)
|
|
70
|
+
print(f"✓ {rel} acknowledged — wrote acknowledged:true to frontmatter only.")
|
|
71
|
+
return 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""plan-baseline — stamp the CURRENT computed verdict into a plan/spec doc's YAML
|
|
2
|
+
frontmatter as a drift baseline (#286 slice 2).
|
|
3
|
+
|
|
4
|
+
Distinct from `plan-confirm` (a human *pin* that overrides the verdict) and from
|
|
5
|
+
the body status banner (`plan-status --stamp`). This records `verdict_baseline:
|
|
6
|
+
<computed>` so `plan-status` can later flag **drift** — when the live computed
|
|
7
|
+
verdict diverges from the stamped baseline (e.g. a once-shipped plan whose
|
|
8
|
+
declared files were deleted, silently regressing to partial). It is the third
|
|
9
|
+
"started, then drifted off" signal the Plans view otherwise can't see.
|
|
10
|
+
|
|
11
|
+
Frontmatter-only (never the body/manifest/checkboxes/banner). The baseline value
|
|
12
|
+
is computed authoritatively here (the same evaluator plan-status uses), not taken
|
|
13
|
+
from the caller.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
work_plan.py plan-baseline --repo=<key> [--confirm=<token>] -- <rel>
|
|
17
|
+
work_plan.py plan-baseline --repo=<key> --clear [--confirm=<token>] -- <rel>
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import date
|
|
21
|
+
|
|
22
|
+
from lib import config as config_mod
|
|
23
|
+
from lib import doc_discovery
|
|
24
|
+
from lib import plan_fm
|
|
25
|
+
from lib import verdict as verdict_mod
|
|
26
|
+
from lib.prompts import parse_flags
|
|
27
|
+
from commands.plan_status import evaluate_doc
|
|
28
|
+
|
|
29
|
+
KNOWN = {"--repo", "--clear", "--confirm"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(args: list) -> int:
|
|
33
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
34
|
+
|
|
35
|
+
repo = flags.get("--repo")
|
|
36
|
+
if not repo or repo is True:
|
|
37
|
+
print("ERROR: --repo=<key> is required.", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
if not positional:
|
|
40
|
+
print("usage: work_plan.py plan-baseline --repo=<key> [--clear] -- <rel>",
|
|
41
|
+
file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
rel = positional[0]
|
|
44
|
+
clear = bool(flags.get("--clear"))
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
cfg = config_mod.load_config()
|
|
48
|
+
except config_mod.ConfigError as e:
|
|
49
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
local = config_mod.resolve_local_path_for_folder(repo, cfg)
|
|
53
|
+
if not local or not local.exists():
|
|
54
|
+
print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
doc_path = plan_fm.resolve_doc_path(local, rel)
|
|
58
|
+
if doc_path is None:
|
|
59
|
+
print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
slug = config_mod.resolve_github_for_folder(repo, cfg)
|
|
63
|
+
action = "clearing the verdict baseline on" if clear else f"stamping a verdict baseline on '{rel}' via"
|
|
64
|
+
if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
if clear:
|
|
68
|
+
if not plan_fm.set_key(doc_path, "verdict_baseline", None):
|
|
69
|
+
print(f"✓ {rel} had no verdict baseline (nothing to clear).")
|
|
70
|
+
return 0
|
|
71
|
+
print(f"✓ cleared verdict baseline on {rel} (frontmatter only).")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
# Compute the verdict authoritatively (same evaluator plan-status uses).
|
|
75
|
+
doc = doc_discovery.Doc(path=doc_path, rel=rel,
|
|
76
|
+
kind=doc_discovery.classify_kind(rel))
|
|
77
|
+
cfg_stall = cfg.get("stall_days")
|
|
78
|
+
stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
|
|
79
|
+
row = evaluate_doc(doc, local, date.today(), verdict_mod.DEAD_DAYS, stall_days)
|
|
80
|
+
verdict = row["verdict"]
|
|
81
|
+
|
|
82
|
+
plan_fm.set_key(doc_path, "verdict_baseline", verdict)
|
|
83
|
+
print(f"✓ {rel} baseline stamped at '{verdict}' — wrote verdict_baseline to "
|
|
84
|
+
f"frontmatter only. plan-status will flag drift if the live verdict changes.")
|
|
85
|
+
return 0
|
|
@@ -0,0 +1,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)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""plan-confirm — write a human `verdict_override` into a plan/spec doc's YAML
|
|
2
|
+
frontmatter (#286).
|
|
3
|
+
|
|
4
|
+
This is the one viewer-driven write to a plan doc, and it is **frontmatter-only**
|
|
5
|
+
by hard constraint: it touches the doc's YAML frontmatter and nothing else — never
|
|
6
|
+
the prose body, the declared-file manifest, the checkboxes, or the status banner.
|
|
7
|
+
A reviewer uses it to affirm a verdict the mechanical heuristic got wrong (e.g. a
|
|
8
|
+
genuinely-shipped plan whose phase checkboxes were never ticked), which silences
|
|
9
|
+
the "shipped but boxes unchecked" lie-gap on the next `plan-status` read.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
work_plan.py plan-confirm --repo=<key> --verdict=shipped|partial|dead [--confirm=<token>] -- <rel>
|
|
13
|
+
work_plan.py plan-confirm --repo=<key> --clear [--confirm=<token>] -- <rel>
|
|
14
|
+
|
|
15
|
+
`<rel>` is the repo-relative POSIX path of the plan doc (as emitted by
|
|
16
|
+
`plan-status --json`). It is validated to resolve to a real file inside the repo,
|
|
17
|
+
so a caller can't redirect the write outside the checkout.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from lib import config as config_mod
|
|
22
|
+
from lib import plan_fm
|
|
23
|
+
from lib.prompts import parse_flags
|
|
24
|
+
|
|
25
|
+
VALID_VERDICTS = {"shipped", "partial", "dead"}
|
|
26
|
+
KNOWN = {"--repo", "--verdict", "--clear", "--confirm"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(args: list) -> int:
|
|
30
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
31
|
+
|
|
32
|
+
repo = flags.get("--repo")
|
|
33
|
+
if not repo or repo is True:
|
|
34
|
+
print("ERROR: --repo=<key> is required.", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
if not positional:
|
|
37
|
+
print("usage: work_plan.py plan-confirm --repo=<key> "
|
|
38
|
+
"--verdict=shipped|partial|dead [--clear] -- <rel>", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
rel = positional[0]
|
|
41
|
+
|
|
42
|
+
clear = bool(flags.get("--clear"))
|
|
43
|
+
verdict = flags.get("--verdict")
|
|
44
|
+
if not clear:
|
|
45
|
+
if not verdict or verdict is True or verdict not in VALID_VERDICTS:
|
|
46
|
+
print("ERROR: --verdict must be one of shipped|partial|dead "
|
|
47
|
+
"(or pass --clear to remove the override).", file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
cfg = config_mod.load_config()
|
|
52
|
+
except config_mod.ConfigError as e:
|
|
53
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
local = config_mod.resolve_local_path_for_folder(repo, cfg)
|
|
57
|
+
if not local or not local.exists():
|
|
58
|
+
print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
doc_path = plan_fm.resolve_doc_path(local, rel)
|
|
62
|
+
if doc_path is None:
|
|
63
|
+
print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
# Public-repo confirm gate (the extension surfaces this as a modal). The token
|
|
67
|
+
# is keyed on (slug, rel) — the same shape close/set use, so the viewer's
|
|
68
|
+
# existing executeWrite token flow drives it unchanged.
|
|
69
|
+
slug = config_mod.resolve_github_for_folder(repo, cfg)
|
|
70
|
+
action = "clearing the verdict override on" if clear else f"marking '{rel}' as {verdict} via"
|
|
71
|
+
if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if clear:
|
|
75
|
+
if not plan_fm.set_key(doc_path, "verdict_override", None):
|
|
76
|
+
print(f"✓ no verdict override on {rel} (nothing to clear).")
|
|
77
|
+
return 0
|
|
78
|
+
print(f"✓ cleared verdict override on {rel} (frontmatter only).")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
plan_fm.set_key(doc_path, "verdict_override", verdict)
|
|
82
|
+
print(f"✓ {rel} confirmed {verdict} — wrote verdict_override to frontmatter only.")
|
|
83
|
+
return 0
|