@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.
- package/dist/bin.js +20 -10
- package/dist/bin.js.map +1 -1
- package/harness/harness/align.py +9 -6
- package/harness/harness/epic.py +84 -56
- package/harness/harness/links.py +114 -0
- package/harness/harness/report.py +44 -0
- package/harness/harness/shift.py +4 -3
- package/harness/harness/version.py +5 -5
- package/harness/presets/appchy/PRESET.md +12 -9
- package/harness/test_work.py +155 -31
- package/harness/work.py +8 -3
- package/package.json +5 -5
package/harness/harness/align.py
CHANGED
|
@@ -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
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
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}'
|
|
410
|
-
f"
|
|
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
|
package/harness/harness/epic.py
CHANGED
|
@@ -220,13 +220,19 @@ _DURABLE_SECTIONS = ("## Governance this implies", "## Non-goals", "### Settled"
|
|
|
220
220
|
"### Forward-compat")
|
|
221
221
|
|
|
222
222
|
|
|
223
|
-
def
|
|
224
|
-
"""What
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
249
|
-
"""What this release is about to
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
f"
|
|
281
|
-
f"
|
|
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
|
-
+
|
|
284
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
"""
|
|
324
|
+
"""Stamp every `epic.md` in a released version as history, and KEEP it.
|
|
304
325
|
|
|
305
|
-
An epic is
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
the
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
342
|
+
stamped = []
|
|
316
343
|
for e in version.epics:
|
|
317
|
-
# An epic with no plan doc
|
|
318
|
-
#
|
|
319
|
-
#
|
|
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.
|
|
324
|
-
|
|
325
|
-
if
|
|
326
|
-
print(f"
|
|
327
|
-
f"
|
|
328
|
-
return len(
|
|
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
|
package/harness/harness/links.py
CHANGED
|
@@ -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
|
|
package/harness/harness/shift.py
CHANGED
|
@@ -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
|
|
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 =
|
|
289
|
+
plans, size, held = plans_held(v)
|
|
290
290
|
cost = ""
|
|
291
291
|
if plans:
|
|
292
|
-
cost = (f" — releasing
|
|
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
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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 —
|
|
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
|
|
312
|
-
`epic.md
|
|
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**.
|
|
342
|
-
`
|
|
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 ·
|
|
463
|
-
|
|
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`
|
|
720
|
-
|
|
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.
|