@appchy/jarvis 0.1.113 → 0.1.115

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.
@@ -400,14 +400,17 @@ def _align_versions(root: Path, s: dict) -> list:
400
400
  out.append(("warn", "released-brief", f"version {v.name}: "
401
401
  f"released but still carries {brief} — a cut-local "
402
402
  f"brief collapses at release"))
403
- # Same rule, one tier down: an epic is the plan-it-together doc, and
404
- # `release` removes it. One left standing means a release that did not
405
- # go through `jarvis work release`.
403
+ # One tier down the test inverts: a released plan is KEPT, so its
404
+ # presence is correct and what would be wrong is an unstamped one. An
405
+ # epic plan with no released banner means a release that did not go
406
+ # through `jarvis work release` — or a plan added to a cut afterwards,
407
+ # which is the same problem wearing different clothes.
408
+ from .epic import _RELEASED_MARKER
406
409
  for e in v.epics:
407
- if e.planned:
410
+ if e.planned and _RELEASED_MARKER not in e.md.read_text(errors="ignore"):
408
411
  out.append(("warn", "released-brief", f"version {v.name}: "
409
- f"released but epic '{e.name}' still carries "
410
- f"epic.mdan epic archives at release"))
412
+ f"released but epic '{e.name}' carries a plan that is "
413
+ f"not stamped as released it reads as live direction"))
411
414
  return out
412
415
  #: An agent-file reference in skill prose, in either of the two shapes an agent dir
413
416
  #: takes: `.claude/agents/<name>.md` in a repo that keeps its own agents, and a bare
@@ -220,13 +220,19 @@ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled"
220
220
  "### Forward-compat")
221
221
 
222
222
 
223
- def removal_cost(version) -> tuple:
224
- """What releasing this cut would delete: `(plans, bytes, sections)`.
223
+ def plans_held(version) -> tuple:
224
+ """What this cut's epic plans hold: `(plans, bytes, sections)`.
225
225
 
226
- One derivation, because two surfaces quote this price now — the notice
227
- `release` prints just before the unlink, and the line `status` shows a person
228
- deciding whether to run it at all. A warning and a confirmation that disagreed
229
- about the cost would make both worth less than either.
226
+ One derivation, because two surfaces quote it — the notice `release` prints as
227
+ it stamps the plans, and the line `status` shows a person deciding whether to
228
+ run it at all. Two surfaces that disagreed about what a cut carries would make
229
+ both worth less than either.
230
+
231
+ It measured what release was about to DELETE until 2026-09-12, when a released
232
+ plan started being kept instead. The number is the same number; what it is
233
+ evidence for is not. It now says how much settled prose is going quiet, which is
234
+ the argument for lifting the durable half into an owner file — not a last call
235
+ before the bytes go.
230
236
 
231
237
  `sections` is every durable heading found across the plans, deduplicated: it is
232
238
  what a reader needs to judge whether the text is safe to lose, and it is the
@@ -245,21 +251,23 @@ def removal_cost(version) -> tuple:
245
251
  return len(plans), total, sorted(held)
246
252
 
247
253
 
248
- def _cost_of_removing(version, root) -> list:
249
- """What this release is about to delete, as lines a person can act on.
254
+ def _cost_of_closing(version, root) -> list:
255
+ """What this release is about to stamp shut, as lines a person can act on.
256
+
257
+ **Said while the plans are still the live thing a reader would reach for.** The
258
+ text no longer leaves the tree — a released plan is stamped and kept — so this is
259
+ not a last call before the bytes go. It is the argument for the sweep: a stamped
260
+ plan is history, and a decision left in one is a decision the next session will
261
+ not find when it reads the owner file.
250
262
 
251
- **Said BEFORE the unlink, because the advice that follows a release is useless
252
- after it.** `release` has always printed _"promote still-load-bearing decisions
253
- to the domain that owns them"_ and printed it after the files holding those
254
- decisions had already left the tree. Measured 2026-09-11 on `01-one-board`: twelve
255
- plans, 450,405 bytes, about a hundred durable statements with no owner file, and
256
- the recovery commit had to be worked out by hand afterwards from `git log
257
- --diff-filter=D`.
263
+ Measured 2026-09-11 on `01-one-board`: twelve plans, 450,405 bytes, about a
264
+ hundred durable statements with no owner file. Those plans were deleted by the
265
+ release that day and restored on 2026-09-12, which is why deletion is no longer
266
+ what this reports.
258
267
 
259
- So this names the size, the sections that can outlive the plan, and the commit
260
- where the text still lives. It does not judge whether a statement is durable
261
- that is the judgement a lint cannot make, and claiming it would be worse than
262
- saying nothing.
268
+ It names the size and the sections that can outlive the plan. It does not judge
269
+ whether a statement is durable that is the judgement a lint cannot make, and
270
+ claiming it would be worse than saying nothing.
263
271
  """
264
272
  plans = [e for e in version.epics if e.planned]
265
273
  if not plans:
@@ -275,57 +283,77 @@ def _cost_of_removing(version, root) -> list:
275
283
  for h in _DURABLE_SECTIONS if h in text]
276
284
  note = f" — holds §{', §'.join(held)}" if held else ""
277
285
  out.append(f" {e.name} {len(text.encode()):,} bytes{note}")
278
- head = _still_holds(root)
279
- return ([f"\n DELETING {len(plans)} epic plan(s), {total:,} bytes. Anything "
280
- f"durable in them must already be in the file that owns it — after this "
281
- f"they are in git only, and nobody reads git for a decision:"]
286
+ return ([f"\n STAMPING {len(plans)} epic plan(s), {total:,} bytes, as released. "
287
+ f"They stay in the tree and every link to them keeps resolving — but a "
288
+ f"stamped plan is history, so anything durable belongs in the file that "
289
+ f"owns it:"]
282
290
  + out
283
- + ([f" the text stays recoverable at {head}"] if head else [])
284
- + [" `git show <commit>:<path>` — and `jarvis work align` reports "
285
- "what cites a file that is gone"])
291
+ + [" `jarvis work align` reports what cites a file that is gone"])
292
+
286
293
 
294
+ #: What a released plan opens with. Matched on the marker rather than the whole line,
295
+ #: so a cut reopened and released again finds its own stamp instead of stacking a second.
296
+ _RELEASED_MARKER = "> **A released plan"
287
297
 
288
- def _still_holds(root) -> str:
289
- """The commit whose tree still contains these plans, or empty when git cannot say.
290
298
 
291
- HEAD is the right answer, and it is worth being precise about why: the unlink has
292
- not happened yet and the board write commits after it, so at the moment this
293
- prints, the newest commit in the history is one that still holds every file about
294
- to go. Through the same seam every other git question goes through a second way
295
- of asking would eventually give a second answer.
299
+ def _stamp_released(epic, version_name) -> bool:
300
+ """Mark a plan as history in the file itself. True when it was added.
301
+
302
+ In the document rather than in the folder name, because a reader who followed a
303
+ link is holding the document and never saw the folder. After the frontmatter and
304
+ before the first heading, which is the one place it cannot be scrolled past.
296
305
  """
297
- from . import git
298
- code, out, _ = git._git(root.parent, "rev-parse", "--short", "HEAD")
299
- return out.strip() if code == 0 else ""
306
+ text = epic.md.read_text()
307
+ if _RELEASED_MARKER in text:
308
+ return False
309
+ banner = (f"{_RELEASED_MARKER} — `{version_name}` shipped "
310
+ f"{date.today().isoformat()}.**\n"
311
+ f"> It says how the work was planned, never what is true now. Anything here\n"
312
+ f"> that still governs belongs in the file that owns it — `jarvis work where "
313
+ f"<id>` finds one.\n\n")
314
+ if text.startswith("---\n"):
315
+ close = text.index("\n---\n", 3) + len("\n---\n")
316
+ text = text[:close] + "\n" + banner + text[close:].lstrip("\n")
317
+ else:
318
+ text = banner + text
319
+ epic.md.write_text(text)
320
+ return True
300
321
 
301
322
 
302
323
  def cmd_epic_release(root, version) -> int:
303
- """Remove every `epic.md` in a released version, and report it.
324
+ """Stamp every `epic.md` in a released version as history, and KEEP it.
304
325
 
305
- An epic is TEMPORARY by definition: it is the plan-it-together doc, and how
306
- the work was planned stops being true the moment it ships. The folder stays
307
- as the grouping of what shipped that record is worth keeping — and git
308
- holds the plan. Called from `cmd_release`, never on its own.
326
+ An epic is temporary: how the work was planned stops being true the moment it
327
+ ships. What does not follow is that the file should go. Deleting it was the rule
328
+ until 2026-09-12, and the price was measured rather than argued: releasing
329
+ `01-one-board` removed twelve plans, left 104 links across the repo pointing at a
330
+ file that no longer existed, and put the text somewhere only a person who knew the
331
+ commit could reach. Nobody reads git for a decision.
309
332
 
310
- It says what it is deleting FIRST. The cost of a release is not the stamp, it is
311
- the plans that go with it, and a person deciding needs that in front of them
312
- while the files still exist."""
313
- for line in _cost_of_removing(version, root):
333
+ So the plan stays where every link already points and travels to the archive with
334
+ the rest of the cut, which `links.repairing` handles like any other move. The
335
+ sweep that lifts durable statements into their owner files is still worth doing —
336
+ it is just no longer a race against an unlink.
337
+
338
+ Called from `cmd_release`, never on its own.
339
+ """
340
+ for line in _cost_of_closing(version, root):
314
341
  print(line)
315
- removed = []
342
+ stamped = []
316
343
  for e in version.epics:
317
- # An epic with no plan doc is already in the shape this produces a folder
318
- # grouping tasks. Unlinking regardless raised AFTER `released:` had been
319
- # stamped, so the cut read as released while every other epic kept the file
320
- # this exists to remove.
344
+ # An epic with no plan doc has nothing to stamp. It was also the case that
345
+ # unlinking regardless raised AFTER `released:` was stamped, leaving the cut
346
+ # reading as released with every other plan untouched.
321
347
  if not e.planned:
322
348
  continue
323
- e.md.unlink()
324
- removed.append(e.name)
325
- if removed:
326
- print(f" archived {len(removed)} epic(s) epic.md removed, "
327
- f"folders kept as the grouping: {', '.join(removed)}")
328
- return len(removed)
349
+ if _stamp_released(e, version.name):
350
+ stamped.append(e.name)
351
+ if stamped:
352
+ print(f" stamped {len(stamped)} epic plan(s) as released and kept them "
353
+ f"in place: {', '.join(stamped)}")
354
+ return len(stamped)
355
+
356
+
329
357
  def epic_for_task(root, args, version_name):
330
358
  """Resolve the `--epic` a task command names, and check it agrees with any
331
359
  `--version` also given. Returns (epic, version_name) — the version is
@@ -191,3 +191,117 @@ def repairing(repo: Path, indent: str = " "):
191
191
  print(f"{indent}repaired {repaired} link(s) that pointed at what moved")
192
192
  for line in unresolved:
193
193
  print(f"{indent}COULD NOT PLACE {line}")
194
+
195
+
196
+ #: The three files that ARE a board item, so a folder holding one is addressable by
197
+ #: its name. Names are globally unique across tasks, epics and versions, which is what
198
+ #: makes a name a usable second resolver when a path has stopped working.
199
+ _ITEM_FILES = ("task.md", "epic.md", "version.md")
200
+
201
+
202
+ def _index(base: Path) -> dict:
203
+ """Board item folders by name. A name with two homes is left out rather than guessed."""
204
+ homes: dict = {}
205
+ for doc in base.rglob("*.md"):
206
+ if SKIP & set(doc.parts) or doc.name not in _ITEM_FILES:
207
+ continue
208
+ homes.setdefault(doc.parent.name, set()).add(doc.parent.resolve())
209
+ return {name: next(iter(dirs)) for name, dirs in homes.items() if len(dirs) == 1}
210
+
211
+
212
+ def _settle(doc: Path, href: str, index: dict):
213
+ """Where a broken link meant to point, or None when that is a guess.
214
+
215
+ Two resolvers, and both must land on exactly one file. **Depth first**: the tail
216
+ is right and the `../` count is wrong, which is what a file written at one tier
217
+ and moved to another leaves behind. **Then by name**: the item moved bucket or
218
+ epic, so no `../` count reaches it, but the folder it lives in is named in the
219
+ link and a board name has one home. Anything either resolver finds twice, or
220
+ neither finds at all, is reported for a person — a link repaired to the wrong
221
+ real file is worse than one that is visibly broken.
222
+ """
223
+ path = href.split("#")[0]
224
+ # Only the leading `./` and `../` hops come off. `lstrip("./")` would take the
225
+ # dot of `.github` with them and then resolve nothing, which is a link reported
226
+ # as unfixable because the checker damaged it on the way in.
227
+ tail = _HOPS.sub("", path)
228
+ hits = {c.resolve() for n in range(10)
229
+ if (c := doc.parent / ("../" * n) / tail).exists()}
230
+ if len(hits) == 1:
231
+ return next(iter(hits))
232
+
233
+ parts = [p for p in path.split("/") if p not in ("..", ".", "")]
234
+ if len(parts) >= 2 and (home := index.get(parts[-2])) is not None:
235
+ want = home / parts[-1]
236
+ if want.exists():
237
+ return want.resolve()
238
+ return None
239
+
240
+
241
+ #: An inline code span. A doc that TEACHES the link syntax writes `](../…md)` inside
242
+ #: backticks, and a checker that reads it as a link reports a file nobody ever meant
243
+ #: to exist. Same shape of suppression the governance lints use, and the same reason:
244
+ #: a blocking check's whole cost is its false positives.
245
+ #:
246
+ #: It crosses newlines because a quoted anchor wraps like any other prose, and the
247
+ #: one live false positive this check ever produced was a span that opened on one
248
+ #: line and closed on the next. Bounded rather than greedy: an unbalanced backtick
249
+ #: somewhere in a long document must not swallow the links after it.
250
+ CODE_SPAN = re.compile(r"`[^`]{0,500}`", re.S)
251
+
252
+ #: Leading `./` and `../` hops, and nothing else.
253
+ _HOPS = re.compile(r"^(?:\.{1,2}/)+")
254
+
255
+
256
+ def dangling(base: Path) -> list:
257
+ """Every relative markdown link under `base` that resolves to nothing.
258
+
259
+ Returns `(doc, href, fix_or_None)` per broken link. This is the set `snapshot()`
260
+ records and `repair()` refuses on purpose — a link broken BEFORE a move has no
261
+ before-state to be repaired by identity, so it is settled from its own text or
262
+ not at all.
263
+ """
264
+ index = _index(base)
265
+ out = []
266
+ for doc in sorted(_docs(base)):
267
+ try:
268
+ text = doc.read_text(errors="ignore")
269
+ except OSError:
270
+ continue
271
+ text = CODE_SPAN.sub("", text)
272
+ for href in dict.fromkeys(m.group(1) for m in LINK.finditer(text)):
273
+ if "://" in href or href.startswith(("#", "mailto:", "/")):
274
+ continue
275
+ if (doc.parent / href.split("#")[0]).exists():
276
+ continue
277
+ out.append((doc, href, _settle(doc, href, index)))
278
+ return out
279
+
280
+
281
+ def repair_dangling(base: Path, apply: bool = False) -> tuple:
282
+ """Point every settled broken link at what it names. Returns (fixed, stuck).
283
+
284
+ Rewrites on the link's own syntax, never on the bare path, so a path that also
285
+ appears in a sentence is left alone — the same anchoring `repair()` uses, for the
286
+ same reason.
287
+ """
288
+ fixed, stuck = 0, []
289
+ edits: dict = {}
290
+ for doc, href, target in dangling(base):
291
+ if target is None:
292
+ stuck.append((doc, href))
293
+ continue
294
+ _, _, anchor = href.partition("#")
295
+ new = os.path.relpath(target, doc.parent) + (f"#{anchor}" if anchor else "")
296
+ if new == href:
297
+ stuck.append((doc, href))
298
+ continue
299
+ edits.setdefault(doc, []).append((href, new))
300
+ fixed += 1
301
+ if apply:
302
+ for doc, pairs in edits.items():
303
+ text = doc.read_text(errors="ignore")
304
+ for href, new in pairs:
305
+ text = text.replace(f"]({href})", f"]({new})")
306
+ doc.write_text(text)
307
+ return fixed, stuck
@@ -165,6 +165,50 @@ def misalignments(root: Path) -> list:
165
165
  + _align_agents(root) + _align_retired(root) + _align_acceptance(root))
166
166
 
167
167
 
168
+ #: A cut that has shipped, and the archive it ends up in. Their links are reported
169
+ #: and never blocking: a plan naming source that has since been deleted, or a
170
+ #: `handoff.md` that `archive` stripped, is an accurate record of a world that is
171
+ #: gone. Demanding those resolve would make the gate unsatisfiable for a reason
172
+ #: nobody could act on.
173
+ _SHIPPED = ("versions/complete/", "archive/")
174
+
175
+
176
+ def cmd_links(args) -> int:
177
+ """Board links that point at nothing. Exits 1 on a LIVE one, so a gate can block.
178
+
179
+ Blocking is safe here in a way it is not for the prose checks next door: a link
180
+ either resolves or it does not, there is no sentence to argue with, and the steady
181
+ state is zero because every folder move repairs its own links. A red therefore
182
+ means somebody hand-wrote a path, which is a ten-second fix — not a judgement call
183
+ a reader has to relitigate.
184
+
185
+ `--fix` repairs what is settled and leaves the rest. It is the only write, and it
186
+ is refused nothing: repairing a link that resolves to exactly one file cannot lose
187
+ anything, because the old path resolved to no file at all.
188
+ """
189
+ from . import links as links_mod
190
+ root = find_work_root()
191
+ if args.get("fix"):
192
+ fixed, _ = links_mod.repair_dangling(root, apply=True)
193
+ print(f" repaired {fixed} link(s)")
194
+
195
+ broken = links_mod.dangling(root)
196
+ live = [(d, h) for d, h, _ in broken
197
+ if not any(m in str(d) for m in _SHIPPED)]
198
+ shipped = len(broken) - len(live)
199
+
200
+ for doc, href in live:
201
+ print(f" ✗ {doc.relative_to(root.parent)} -> {href}")
202
+ if shipped:
203
+ print(f" · {shipped} more in cuts that have shipped — reported, never blocking")
204
+ if not live:
205
+ print(" every link in a live brief or governance file resolves")
206
+ return 0
207
+ print(f"\n {len(live)} broken link(s) in live files · exit 1 "
208
+ f"(`jarvis work links --fix` repairs what it can settle)")
209
+ return 1
210
+
211
+
168
212
  def cmd_align(args) -> int:
169
213
  """Report every misalignment class. REPORT-ONLY, exit 0 — by founder call.
170
214
 
@@ -27,7 +27,7 @@ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
27
  from .frontmatter import rewrite_file
28
28
  from .model import locate, missing, record_session, scan
29
29
  from .generate import _sync
30
- from .epic import removal_cost
30
+ from .epic import plans_held
31
31
  from . import autonomy, events, links, peers
32
32
  # The ceiling is read through the MODULE, never bound in with `from … import`.
33
33
  # A `from .autonomy import CEILING` captures the value at import time, so
@@ -286,10 +286,11 @@ def cmd_status(args) -> int:
286
286
  ready = [v for v in s["versions"] if v.finishable()]
287
287
  print(f"WAITING ON YOU ({len(waiting) + len(ready)})")
288
288
  for v in ready:
289
- plans, size, held = removal_cost(v)
289
+ plans, size, held = plans_held(v)
290
290
  cost = ""
291
291
  if plans:
292
- cost = (f" — releasing deletes {plans} epic plan(s), {size:,} bytes"
292
+ cost = (f" — releasing stamps {plans} epic plan(s) as history, "
293
+ f"{size:,} bytes"
293
294
  + (f", holding §{', §'.join(held)}" if held else ""))
294
295
  print(f" {v.name}: every task is complete and the cut is still open "
295
296
  f"— `jarvis work release {v.name}`{cost}")
@@ -198,12 +198,12 @@ def cmd_release(args) -> int:
198
198
  # how the work was planned stops being true once it ships.
199
199
  cmd_epic_release(root, version)
200
200
  print("\nDistill before archiving:")
201
- # Points at the commit the deletion just named, because this instruction used to
202
- # send a reader to files the same command had already removed — and the one time
203
- # it mattered, the recovery commit had to be reconstructed afterwards with
204
- # `git log --diff-filter=D`.
201
+ # The plans are still in the tree, stamped as history, so this points at them
202
+ # rather than at a commit. It sent a reader to files the same command had already
203
+ # removed until 2026-09-12, and the one time it mattered the recovery commit had
204
+ # to be reconstructed afterwards with `git log --diff-filter=D`.
205
205
  print(" 1. Promote still-load-bearing decisions to the domain or system that owns "
206
- "them — read them from the commit named above; they are no longer in the tree")
206
+ "them — the plans are stamped and kept, so read them where they sit")
207
207
  print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
208
208
  print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
209
209
  f"files a RELEASED cut under versions/complete/)")
@@ -308,9 +308,9 @@ files are deleted. **Read a dissolving `epic.md` in full first** — a thin epic
308
308
  fully-planned, deliberately-deferred goal hides.
309
309
 
310
310
  A version's status is derived: **planned** until tasks start, **current** while any is in-progress,
311
- **released** once `jarvis work release` stamps it. Release **flattens the done tier**, removes each
312
- `epic.md`, then `jarvis work archive` strips each task to its `task.md` and moves the version to
313
- `work/archive/versions/<v>/`.
311
+ **released** once `jarvis work release` stamps it. Release **flattens the done tier** and **stamps
312
+ each `epic.md` as history, keeping it**, then `jarvis work archive` strips each task to its `task.md`
313
+ and moves the version to `work/archive/versions/<v>/`.
314
314
 
315
315
  **Archived is off the board, not gone.** `list`, the README table and every lint see only `versions/`.
316
316
  `jarvis work path <v>` and `jarvis work where <id>` still resolve into the archive.
@@ -338,8 +338,9 @@ is a brief and nothing else.
338
338
  says so, and it is right: that is an epic wearing a task costume. Split it by *goal*, not by layer.
339
339
 
340
340
  `epic.md` is mandatory for an epic and owns three things nothing else does — the **Goal** in user
341
- terms, the **Plan** every task builds against, and **Governance this implies**. It is **removed by
342
- `jarvis work release`**.
341
+ terms, the **Plan** every task builds against, and **Governance this implies**. `jarvis work
342
+ release` **stamps it as history and keeps it** — it used to delete it, which cost 104 dangling links
343
+ and put the text where only somebody who knew the commit could read it (2026-09-12).
343
344
 
344
345
  `handoff.md` is the **cross-conversation** artifact, not a per-task journal: **not** scaffolded on
345
346
  pickup — create it with `jarvis work handoff <name>` only when handing the task to a fresh session. A task
@@ -459,8 +460,8 @@ The two task-count rules are one rule from both ends: **a task is one goal, an e
459
460
  **done** epic is exempt from the floor; it is history, not a plan.
460
461
 
461
462
  `jarvis work align` reports every misalignment class — dangling citation · orphan/uncovered AC · unenforced
462
- rule · retired concept still named live · complete-but-unreleased version · an `epic.md` surviving a
463
- release. Report-only, exit 0.
463
+ rule · retired concept still named live · complete-but-unreleased version · a released cut whose
464
+ `epic.md` is not stamped as history. Report-only, exit 0.
464
465
 
465
466
  ## Every board write lands in git
466
467
 
@@ -716,8 +717,10 @@ The spend is authored prose. Keep it tight:
716
717
  - **Cannot `new` or `epic-new` into a released version.**
717
718
  - **Cannot `release`** a version with incomplete tasks, zero tasks, or no `outcome:`. **Cannot
718
719
  `archive`** one that isn't released.
719
- - **`release` deletes every `epic.md`.** Anything durable in an epic's §Plan or §Governance must have
720
- landed in its owner doc before you releasegit keeps the text, but nobody will look.
720
+ - **`release` stamps every `epic.md` as history and keeps it** (2026-09-12; it deleted them until
721
+ then). Nothing is lost and every link still resolves but a stamped plan is history, so anything
722
+ durable in its §Plan or §Governance still belongs in the file that owns it. A reader who needs a
723
+ decision reads the owner file, and will not go looking in a shipped cut's plan.
721
724
  - **Names are globally unique across tasks, epics and versions.** Renaming = move the folder by hand +
722
725
  update any `depends_on:`.
723
726
  - **`README.md` BACKLOG markers are load-bearing** — preserve them if you hand-edit around the table.