@stylusnexus/work-plan 2026.6.10 → 2026.6.11-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.
Files changed (47) hide show
  1. package/README.md +31 -8
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/group.py +5 -1
  7. package/skills/work-plan/commands/handoff.py +15 -6
  8. package/skills/work-plan/commands/init.py +13 -3
  9. package/skills/work-plan/commands/init_repo.py +8 -2
  10. package/skills/work-plan/commands/new_track.py +15 -2
  11. package/skills/work-plan/commands/notes_vcs.py +172 -0
  12. package/skills/work-plan/commands/plan_branch.py +314 -0
  13. package/skills/work-plan/commands/refresh_md.py +106 -37
  14. package/skills/work-plan/commands/rename_track.py +243 -0
  15. package/skills/work-plan/commands/set_notes_root.py +8 -4
  16. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  17. package/skills/work-plan/lib/config.py +11 -0
  18. package/skills/work-plan/lib/frontmatter.py +12 -3
  19. package/skills/work-plan/lib/git_state.py +61 -52
  20. package/skills/work-plan/lib/github_state.py +46 -13
  21. package/skills/work-plan/lib/notes_vcs.py +276 -0
  22. package/skills/work-plan/lib/plan_worktree.py +288 -0
  23. package/skills/work-plan/lib/prompts.py +12 -1
  24. package/skills/work-plan/lib/status_table.py +95 -5
  25. package/skills/work-plan/lib/tracks.py +15 -6
  26. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  27. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  28. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  29. package/skills/work-plan/tests/test_config.py +12 -12
  30. package/skills/work-plan/tests/test_github_state.py +3 -3
  31. package/skills/work-plan/tests/test_init_repo.py +12 -7
  32. package/skills/work-plan/tests/test_new_track.py +7 -7
  33. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  34. package/skills/work-plan/tests/test_notes_vcs_command.py +389 -0
  35. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  36. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  37. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  38. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  39. package/skills/work-plan/tests/test_rename_track.py +351 -0
  40. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  41. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  42. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  43. package/skills/work-plan/tests/test_status_table.py +61 -0
  44. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  45. package/skills/work-plan/tests/test_tracks.py +4 -4
  46. package/skills/work-plan/work_plan.py +176 -17
  47. /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -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)
@@ -3,7 +3,10 @@ from lib.config import load_config, ConfigError
3
3
  from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
4
4
  from lib.github_state import fetch_issues, state_to_status_label
5
5
  from lib.frontmatter import write_file
6
- from lib.status_table import find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
6
+ from lib.status_table import (
7
+ find_all_status_tables, find_canonical_status_tables, sync_missing_rows,
8
+ render_canonical_table, insert_canonical_block, ISSUE_NUM_RE,
9
+ )
7
10
  from lib.prompts import prompt_yes_no, parse_flags
8
11
 
9
12
 
@@ -81,7 +84,7 @@ def _refresh_many(tracks: list, yes: bool) -> int:
81
84
 
82
85
  # Frontmatter is canonical for membership: issues listed there but
83
86
  # missing from the table need a fresh row (issue #77). Fetch the union
84
- # so appended rows carry live title/assignee/status too.
87
+ # so rows carry live title/assignee/status too.
85
88
  frontmatter_nums = track.meta.get("github", {}).get("issues") or []
86
89
  fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
87
90
  if not fetch_nums:
@@ -91,55 +94,121 @@ def _refresh_many(tracks: list, yes: bool) -> int:
91
94
  issues_by_num = {i["number"]: i for i in issues}
92
95
  state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
93
96
 
94
- lines = track.body.split("\n")
95
- cell_updates = 0
96
- for table in tables:
97
- sidx = table["status_col_index"]
98
- for row in table["rows"]:
99
- nums = []
100
- for cell in row["cells"]:
101
- nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
102
- for num in nums:
103
- if num not in state_by_num:
104
- continue
105
- new_status = state_by_num[num]
106
- if sidx >= len(row["cells"]):
107
- continue
108
- current = row["cells"][sidx].strip()
109
- if current == new_status.strip():
110
- continue
111
- new_label = new_status.strip().split(" ", 1)[-1].lower()
112
- if new_label and new_label in current.lower():
113
- continue
114
- new_cells = list(row["cells"])
115
- new_cells[sidx] = " " + new_status + " "
116
- lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
117
- cell_updates += 1
118
-
119
- new_body = "\n".join(lines)
120
- # Slot in rows for frontmatter issues missing from the table, each at
121
- # its frontmatter-order position. Cell updates above preserve the line
122
- # count, so the table's line indices stay valid for sync_missing_rows.
123
- new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
97
+ if canonical:
98
+ # Canonical table → RE-DERIVE the whole block from frontmatter
99
+ # membership + live data, milestone-ordered (#101). Re-deriving from
100
+ # the one shared renderer is what keeps the markdown table from
101
+ # decaying: order, columns, missing rows, and statuses are all
102
+ # rebuilt every run, so it can't drift from the viewer.
103
+ new_body, detail = _rederive_canonical(
104
+ track, canonical, frontmatter_nums, issues_by_num, state_by_num
105
+ )
106
+ else:
107
+ new_body, detail = _refresh_narrative(
108
+ track, tables, frontmatter_nums, issues_by_num, state_by_num
109
+ )
124
110
 
125
111
  if new_body == track.body:
126
112
  continue
127
- pending.append((track, new_body, cell_updates, rows_added))
113
+ pending.append((track, new_body, detail))
128
114
 
129
115
  if not pending:
130
116
  print("All tracks in sync.")
131
117
  return 0
132
118
 
133
119
  print(f"Pending updates across {len(pending)} track(s):\n")
134
- for track, _, cells, added in pending:
135
- added_str = f", {added} row(s) added" if added else ""
136
- print(f" {track.path.name:50} {cells} cell(s){added_str}")
120
+ for track, _, detail in pending:
121
+ print(f" {track.path.name:50} {detail}")
137
122
 
138
123
  if not yes and not prompt_yes_no("\nApply all? [y/N]"):
139
124
  print("Cancelled.")
140
125
  return 0
141
126
 
142
- for track, new_body, _, _ in pending:
127
+ for track, new_body, _ in pending:
143
128
  write_file(track.path, track.meta, new_body)
144
129
  print(f"\n✓ Updated {len(pending)} file(s).")
145
130
  return 0
131
+
132
+
133
+ def _rederive_canonical(track, canonical_tables, frontmatter_nums,
134
+ issues_by_num, state_by_num):
135
+ """Rebuild the canonical block, milestone-ordered, from live data.
136
+
137
+ Returns (new_body, detail_str). detail reports rows added vs. the old table
138
+ and status changes, falling back to a format/order note when the only
139
+ change is reordering or the one-time 4→5 column migration."""
140
+ old_nums, old_status = set(), {}
141
+ for table in canonical_tables:
142
+ sidx = table["status_col_index"]
143
+ for row in table["rows"]:
144
+ row_nums = [int(m) for cell in row["cells"]
145
+ for m in ISSUE_NUM_RE.findall(cell)]
146
+ for num in row_nums:
147
+ old_nums.add(num)
148
+ if sidx < len(row["cells"]):
149
+ old_status[num] = row["cells"][sidx].strip()
150
+
151
+ table_md = render_canonical_table(
152
+ frontmatter_nums, issues_by_num,
153
+ milestone_alignment=track.meta.get("milestone_alignment"),
154
+ )
155
+ new_body = insert_canonical_block(track.body, table_md, replace=True)
156
+
157
+ rows_added = len(set(frontmatter_nums) - old_nums)
158
+ # Frontmatter is membership truth: a row in the old table but no longer in
159
+ # frontmatter is dropped on re-derive. Surface it so an approving user can
160
+ # see a deletion, not just additions.
161
+ rows_removed = len(old_nums - set(frontmatter_nums))
162
+ status_changes = sum(
163
+ 1 for n in frontmatter_nums
164
+ if n in old_status and n in state_by_num
165
+ and old_status[n] != state_by_num[n].strip()
166
+ )
167
+ bits = []
168
+ if status_changes:
169
+ bits.append(f"{status_changes} status change(s)")
170
+ if rows_added:
171
+ bits.append(f"{rows_added} row(s) added")
172
+ if rows_removed:
173
+ bits.append(f"{rows_removed} row(s) removed")
174
+ detail = ", ".join(bits) if bits else "canonical table re-derived"
175
+ return new_body, detail
176
+
177
+
178
+ def _refresh_narrative(track, tables, frontmatter_nums, issues_by_num, state_by_num):
179
+ """Original behavior for tracks WITHOUT a canonical table: update status
180
+ cells in narrative tables in place, then slot in missing frontmatter rows.
181
+ Conservative — never reorders or restructures a hand-written table."""
182
+ lines = track.body.split("\n")
183
+ cell_updates = 0
184
+ for table in tables:
185
+ sidx = table["status_col_index"]
186
+ for row in table["rows"]:
187
+ nums = []
188
+ for cell in row["cells"]:
189
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
190
+ for num in nums:
191
+ if num not in state_by_num:
192
+ continue
193
+ new_status = state_by_num[num]
194
+ if sidx >= len(row["cells"]):
195
+ continue
196
+ current = row["cells"][sidx].strip()
197
+ if current == new_status.strip():
198
+ continue
199
+ new_label = new_status.strip().split(" ", 1)[-1].lower()
200
+ if new_label and new_label in current.lower():
201
+ continue
202
+ new_cells = list(row["cells"])
203
+ new_cells[sidx] = " " + new_status + " "
204
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
205
+ cell_updates += 1
206
+
207
+ new_body = "\n".join(lines)
208
+ # Slot in rows for frontmatter issues missing from the table, each at its
209
+ # frontmatter-order position. Cell updates preserve the line count, so the
210
+ # table's line indices stay valid for sync_missing_rows.
211
+ new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
212
+
213
+ added_str = f", {rows_added} row(s) added" if rows_added else ""
214
+ return new_body, f"{cell_updates} cell(s){added_str}"