@appchy/jarvis 0.1.124 → 0.1.126

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.
@@ -77,7 +77,9 @@ KINDS = (
77
77
  "gate-refused", # completion was refused, and why
78
78
  "completed", # a task landed
79
79
  "released", # a version shipped
80
- "archived", # ...and left the board
80
+ "archived", # ...and left the board — a whole cut, or ONE item we
81
+ # decided not to do, which is the same event at two tiers
82
+ "restored", # an archived item was wanted again and put back
81
83
  )
82
84
 
83
85
 
@@ -10,7 +10,7 @@ from .tree import TASK_FM_ORDER
10
10
  #: verify command's name and result. `--question "does it ship signed, or later?"`
11
11
  #: rendered inline would come back as two list entries, and the answer would be
12
12
  #: filed against half a question.
13
- BLOCK_LISTS = ("sessions", "plans", "asked", "verified", "observed")
13
+ BLOCK_LISTS = ("sessions", "plans", "asked", "verified", "observed", "archived")
14
14
 
15
15
  #: A field this parser reads: a name it models, and whatever follows the colon.
16
16
  _KEY = re.compile(r"^([A-Za-z0-9_]+):\s*(.*)$")
@@ -3,7 +3,8 @@ import re
3
3
  from datetime import date
4
4
  from pathlib import Path
5
5
 
6
- from .tree import backlog_dir, BUCKETS, cli, DONE_TIER, PRIORITIES, RESERVED_MD
6
+ from .tree import (archive_dir, backlog_dir, BUCKETS, cli, DONE_TIER,
7
+ PRIORITIES, RESERVED_MD)
7
8
  from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, title_of
8
9
 
9
10
 
@@ -364,9 +365,50 @@ def locate(root: Path, name: str, filed: bool = False):
364
365
  for t in v.all_tasks():
365
366
  if t.name == name:
366
367
  return t
368
+ for t in scan_archived(root):
369
+ if t.name == name:
370
+ return t
367
371
  return None
368
372
 
369
373
 
374
+ def scan_archived(root: Path) -> list:
375
+ """Every ITEM taken off the board without being done — and without being deleted.
376
+
377
+ A task folder sitting directly under `versions/archive/`, beside the cuts that
378
+ left the board for the same reason. What tells the two apart is the marker file
379
+ a folder carries — a cut has `version.md`, an item has `task.md` — which is the
380
+ discriminator `scan` already reads the backlog with, so there is no second list
381
+ that could disagree with the tree.
382
+
383
+ **Deliberately not part of `scan`, and that is the whole of the design.** `scan`
384
+ answers what is MOVING, and an archived item is precisely what stopped. Keeping
385
+ it out means `list`, the README table, every lint, a release's count of what a
386
+ cut still owes and an epic's is-it-done all skip it by construction rather than
387
+ by each remembering to. The board had one state added to it before — `blocked` —
388
+ and four lints quietly excused a parked task from the rules its siblings answer
389
+ to, because a set spelled out in four places is wrong in three of them.
390
+
391
+ A READ opts back in through `locate(..., filed=True)`; a WRITE never sees it.
392
+ """
393
+ base = archive_dir(root)
394
+ if not base.is_dir():
395
+ return []
396
+ return [Task(p, status="archived", version=None)
397
+ for p in sorted(base.iterdir())
398
+ if p.is_dir() and (p / "task.md").is_file()]
399
+
400
+
401
+ def archived_record(task) -> str:
402
+ """The most recent line of an item's archive history, or "".
403
+
404
+ One reader, because the record is append-only — archiving an item that was
405
+ restored and dropped again writes a second entry rather than replacing the
406
+ first — so "what happened to this" is always the LAST line and never the field.
407
+ """
408
+ entries = as_list(task.fm.get("archived"))
409
+ return entries[-1] if entries else ""
410
+
411
+
370
412
  def scan_filed(root: Path) -> list:
371
413
  """Every cut that has LEFT the board — both `versions/complete/` and
372
414
  `versions/archive/`.
@@ -395,6 +437,11 @@ def filed_home(root: Path, name: str) -> str:
395
437
  filed. The lookup that fails looks once more, in the cuts that left the board,
396
438
  so the refusal can name the release instead of denying the work ever existed.
397
439
  """
440
+ for t in scan_archived(root):
441
+ if t.name == name:
442
+ return (f" — it was archived: {archived_record(t)}. That is off the "
443
+ f"board, not gone: read it with `{cli()} path {name}`, or put "
444
+ f"it back with `{cli()} place {name} --version <v> --epic <e>`.")
398
445
  for v in scan_filed(root):
399
446
  for t in v.all_tasks():
400
447
  if t.name == name:
@@ -1,8 +1,9 @@
1
1
  import sys
2
2
  from pathlib import Path
3
3
 
4
- from .tree import find_work_root
5
- from .model import Task, _ordered, scan, scan_shipped
4
+ from .tree import cli, find_work_root
5
+ from .frontmatter import as_list
6
+ from .model import Task, _ordered, scan, scan_archived, scan_shipped
6
7
  from .lint import lint_warnings
7
8
  from .generate import _regen_readme, settle_epic_tier
8
9
  from .align import (_align_acceptance, _align_agents, _align_citations,
@@ -25,6 +26,38 @@ def _task_line(t: Task) -> str:
25
26
  tail = (" [" + " · ".join(extra) + "]") if extra else ""
26
27
  status = t.display_status()
27
28
  return f"{status:>11} {t.priority} {t.name} — {t.title}{tail}"
29
+ def _list_archived(root) -> int:
30
+ """Work we stopped doing — what left the board without being done.
31
+
32
+ Newest first, because the question this answers is almost always about a
33
+ proposal that has just come back. Each item prints its whole history rather
34
+ than its last line: one that was archived, wanted again and archived a second
35
+ time is telling you something the final entry alone does not.
36
+ """
37
+ items = scan_archived(root)
38
+ if not items:
39
+ print("nothing is archived — no item has left the board unfinished.")
40
+ return 0
41
+ # Two passes rather than one reversed key, the same shape `scan_shipped` uses:
42
+ # reversing a compound key would stand the names on their head too.
43
+ items.sort(key=lambda t: t.name)
44
+ items.sort(key=lambda t: _archived_on(t), reverse=True)
45
+ print(f"ARCHIVED ({len(items)}) — off the board, not gone.")
46
+ print(f" `{cli()} path <name>` reads one · "
47
+ f"`{cli()} place <name> --version <v> --epic <e>` puts it back")
48
+ for t in items:
49
+ print(f"\n {t.name} — {t.title}")
50
+ for entry in as_list(t.fm.get("archived")):
51
+ print(f" {entry}")
52
+ return 0
53
+
54
+
55
+ def _archived_on(t) -> str:
56
+ """The date an item FIRST left the board, for ordering. "" when it says none."""
57
+ entries = as_list(t.fm.get("archived"))
58
+ return entries[0][:10] if entries else ""
59
+
60
+
28
61
  def _say_staleness(root) -> None:
29
62
  """Print how far behind the origin this checkout is, or nothing when it is level.
30
63
 
@@ -47,6 +80,14 @@ def cmd_list(args) -> int:
47
80
  from harness.branches import cmd_at
48
81
  return cmd_at({"ref": args["branch"], **args})
49
82
  root = find_work_root()
83
+ # What was taken OFF the board is its own question and its own answer. The board
84
+ # deliberately does not show these — an item nobody means to do makes the queue
85
+ # overstate what is coming, which is the whole reason the state exists — so this
86
+ # is the door that does. Without it the only way to reach a decision is to
87
+ # already know its name, and a record nobody can browse stops the same proposal
88
+ # coming back only by luck.
89
+ if str(args.get("archived", "")).lower() in ("true", "1", "yes"):
90
+ return _list_archived(root)
50
91
  s = scan(root)
51
92
 
52
93
  # A read does not pull, so the board on screen can be an older world than the
@@ -158,9 +158,7 @@ def cmd_place(args) -> int:
158
158
  if version.released:
159
159
  die(f"version '{version_name}' is released — pick a planned version")
160
160
 
161
- task = locate(root, name)
162
- if not task:
163
- die(missing(root, name, "task or epic"))
161
+ task, restored = _resolve_placeable(root, name)
164
162
  if task.version == version.name:
165
163
  die(f"'{name}' is already in version '{version_name}'")
166
164
  if task.status == "complete":
@@ -194,17 +192,54 @@ def cmd_place(args) -> int:
194
192
  moved[task.folder.resolve()] = dest.resolve()
195
193
  shutil.move(str(task.folder), str(dest))
196
194
 
197
- md = dest / "task.md"
198
- rewrite_file(
199
- md,
200
- lambda d: d.update({"updated": date.today().isoformat()}),
201
- )
195
+ rewrite_file(dest / "task.md",
196
+ _placed(restored, f"{version.name}/{epic.name}"))
202
197
  record_session(dest)
203
- print(f"placed '{name}' -> {rel(dest, root)}")
198
+ if restored:
199
+ events.append(root, "restored", name, to=f"{version.name}/{epic.name}")
200
+ print(f"{'restored' if restored else 'placed'} '{name}' -> {rel(dest, root)}")
204
201
  _sync(root)
205
202
  return 0
206
203
 
207
204
 
205
+ def _resolve_placeable(root, name: str):
206
+ """The item this `place` acts on, and whether it is coming back from the archive.
207
+
208
+ A live item resolves the ordinary way. One that was ARCHIVED resolves too, and
209
+ that is the difference between this state and a delete: work we stopped doing is
210
+ off the board, not gone, and wanting it again is a real thing that happens. It
211
+ is a person's deliberate act rather than a default, which is what keeps the
212
+ archive read as a set of decisions instead of a second queue.
213
+
214
+ Work that SHIPPED still does not resolve. `missing` says which of the two
215
+ happened, so the refusal names the release rather than denying the work existed.
216
+ """
217
+ task = locate(root, name)
218
+ if task:
219
+ return task, False
220
+ filed = locate(root, name, filed=True)
221
+ if filed and filed.status == "archived":
222
+ return filed, True
223
+ die(missing(root, name, "task or epic"))
224
+
225
+
226
+ def _placed(restored: bool, home: str):
227
+ """The frontmatter edit a `place` makes, with the archive history APPENDED.
228
+
229
+ Never erased. A board that can forget it changed its mind cannot be asked why it
230
+ did — and the whole reason an item is kept rather than deleted is so the next
231
+ person proposing it can read what was decided and when. The same append-only
232
+ shape `answer` already uses on `asked:`.
233
+ """
234
+ def mutate(d):
235
+ d["updated"] = date.today().isoformat()
236
+ if restored:
237
+ d["archived"] = as_list(d.get("archived")) + [
238
+ f"restored {date.today().isoformat()} to {home}"]
239
+
240
+ return mutate
241
+
242
+
208
243
  def _to_backlog(root, name: str, args) -> int:
209
244
  """Take work back OUT of a cut, into a backlog epic.
210
245
 
@@ -234,9 +269,7 @@ def _to_backlog(root, name: str, args) -> int:
234
269
  f"moving work there is `rehome <name> --version {epic.version} "
235
270
  f"--epic {epic_name}`")
236
271
 
237
- task = locate(root, name)
238
- if not task:
239
- die(missing(root, name, "task or epic"))
272
+ task, restored = _resolve_placeable(root, name)
240
273
  if task.in_backlog:
241
274
  die(f"'{name}' is already in the backlog")
242
275
  if task.status == "complete":
@@ -260,12 +293,11 @@ def _to_backlog(root, name: str, args) -> int:
260
293
  with links.repairing(root.parent) as moved:
261
294
  moved[task.folder.resolve()] = dest.resolve()
262
295
  shutil.move(str(task.folder), str(dest))
263
- md = dest / "task.md"
264
- rewrite_file(
265
- md,
266
- lambda d: d.update({"updated": date.today().isoformat()}),
267
- )
268
- print(f"took '{name}' out of the cut -> {rel(dest, root)}")
296
+ rewrite_file(dest / "task.md", _placed(restored, f"backlog/{epic.name}"))
297
+ if restored:
298
+ events.append(root, "restored", name, to=f"backlog/{epic.name}")
299
+ print(f"{'restored' if restored else 'took'} '{name}' "
300
+ f"{'' if restored else 'out of the cut '}-> {rel(dest, root)}")
269
301
  _sync(root)
270
302
  return 0
271
303
 
@@ -46,7 +46,14 @@ PRIORITIES = ("P0", "P1", "P2")
46
46
  RESERVED_MD = ("README.md",)
47
47
  TASK_FM_ORDER = ("priority", "tier", "depends_on", "tags", "owner", "code", "covers",
48
48
  "start", "end", "created", "updated", "sessions", "plans",
49
- "asked", "verified", "observed", "completed")
49
+ "asked", "verified", "observed", "completed", "archived")
50
+ # `archived:` is a LIST of dated entries, not the date a version carries under the
51
+ # same key — an item can be archived, wanted again, and archived a second time, and
52
+ # a board that overwrites the first line cannot be asked why it changed its mind.
53
+ # It is the same append-only shape `asked:`, `sessions:` and `plans:` already use,
54
+ # and for the same reason. It is a RECORD, never a status: whether an item is
55
+ # archived is answered by where it sits, exactly as every other state is.
56
+
50
57
  # What a task's blast radius is, and therefore what an unattended run may do with
51
58
  # it alone. This is the axis the harness did not have: `code:` region COUNT
52
59
  # measured size (a three-region task is an epic in disguise), and size is not risk
@@ -1,14 +1,16 @@
1
1
  import re
2
2
  import shutil
3
3
  import subprocess
4
+ import sys
4
5
  from datetime import date
5
6
  from pathlib import Path
6
7
 
7
8
  from . import ids
8
- from .tree import (archive_dir, complete_dir, DONE_TIER, VERSION_FM_ORDER, cli, die,
9
- find_work_root, rel)
10
- from .frontmatter import rewrite_file
11
- from .model import _is_epic_dir, locate_version, scan
9
+ from .tree import (archive_dir, complete_dir, DONE_TIER, TASK_FM_ORDER,
10
+ VERSION_FM_ORDER, cli, die, find_work_root, rel)
11
+ from .frontmatter import as_list, rewrite_file
12
+ from .model import (_is_epic_dir, archived_record, locate, locate_version, missing,
13
+ record_session, scan)
12
14
  from .scaffold import _check_unused, _check_version_name, _scaffold_version
13
15
  from .epic import cmd_epic_release
14
16
  from .generate import _sync
@@ -256,11 +258,148 @@ def _home_for(version, root: Path) -> Path:
256
258
 
257
259
 
258
260
  def cmd_archive(args) -> int:
261
+ """Take work off the board — ONE ITEM, or a whole cut. The name decides which.
262
+
263
+ One verb over two tiers, dispatching on what the name IS, exactly as `place`
264
+ already does. The word always meant the right thing at the wrong size: it took
265
+ a CUT and refused unless every task in it was complete, so there was no way at
266
+ all to say *we decided not to do this* about a single item. The only thing that
267
+ did take one off the board was deleting its folder by hand — which the method
268
+ forbids, and which removes the evidence rather than the work.
269
+
270
+ Founder, 2026-09-10, naming the state the board did not have and objecting to
271
+ the word it had taken instead: _"there is a difference between archive and
272
+ completed — archive should be tasks that we dropped and no longer want them,
273
+ but don't want to delete just yet."_ That reading is the natural one, and the
274
+ fix is to make the sentence true rather than to invent a second vocabulary.
275
+
276
+ Measured across four repos on 2026-09-12, counting NAMES rather than files
277
+ because a folder moving between buckets is a rename: 62 items had ever left
278
+ three of those boards against 1,290 created. Removal was not merely missing —
279
+ its absence is why a board only grows, since the cheapest legal action was
280
+ always to leave the item where it was.
281
+ """
259
282
  root = find_work_root()
260
283
  name = args["name"]
284
+ # An ITEM first, then a CUT. Names are globally unique across all three tiers,
285
+ # so this cannot be ambiguous — and an item is what a caller reaches for far
286
+ # more often, since a cut leaves the board once and its work leaves item by item.
287
+ if locate(root, name):
288
+ return _archive_task(root, name, args)
289
+ if locate_version(root, name):
290
+ return _archive_version(root, name, args)
291
+ # Saying "no such name" about something this very command filed is the least
292
+ # useful true thing available, so the one case it can answer precisely, it does.
293
+ already = locate(root, name, filed=True)
294
+ if already and already.status == "archived":
295
+ die(f"'{name}' is already archived — {archived_record(already)}. It is off "
296
+ f"the board, not gone. Put it back with `{cli()} place {name} --version "
297
+ f"<v> --epic <e>` if it is wanted again.")
298
+ die(missing(root, name, "task or version"))
299
+
300
+
301
+ def _archive_task(root, name: str, args) -> int:
302
+ """Take ONE item off the board without finishing it and without deleting it.
303
+
304
+ **It strips NOTHING**, which is the opposite of what archiving a cut does and is
305
+ the point of the difference. A cut is archived AFTER its plans have been
306
+ distilled into the domains that own them; an item is archived INSTEAD of being
307
+ done, so the thinking that reached that decision is the whole of what there is
308
+ to keep. An archived item with no reasoning is one somebody re-proposes next
309
+ month, which is the failure this state exists to end.
310
+
311
+ A marked, reasoned state rather than a delete, and there is outside evidence for
312
+ that rather than only taste: Anthropic's harness guidance argues the other way
313
+ for a good reason, refusing to let an agent remove tests because it could shrink
314
+ the spec it is being measured against. That failure is real and this must not
315
+ create it — so nothing disappears, and what was decided against stays citable.
316
+ """
317
+ why = (args.get("why") or "").strip()
318
+ # A bare `--why` parses to "true" (see parse_argv), which is a flag somebody
319
+ # typed rather than a reason they gave.
320
+ if not why or why == "true":
321
+ die(f"archiving an item needs --why: a decision NOT to do something is only "
322
+ f"worth keeping if it says what the decision was. Without it this is a "
323
+ f"delete that happens to leave a folder behind.\n"
324
+ f" e.g. {cli()} archive {name} --why \"duplicate of <other item>\"")
325
+
326
+ task = locate(root, name)
327
+ if task.status == "complete":
328
+ die(f"'{name}' is complete — it shipped in '{task.version}' and stays "
329
+ f"there. What a release delivered is a matter of record, and a record "
330
+ f"that can be edited afterwards cannot answer what any release "
331
+ f"contained.")
332
+
333
+ # Work something else is WAITING ON. Archiving it leaves every dependent waiting
334
+ # on a name nothing will ever complete, and the unattended selector skips those
335
+ # for good saying "depends on incomplete" — which reads as a queue that has
336
+ # stalled rather than as this. Refused rather than warned, because the repair is
337
+ # in the dependents and doing it afterwards means finding them first.
338
+ s = scan(root)
339
+ dependents = sorted({t.name for v in s["versions"] for t in v.all_tasks()
340
+ if name in t.depends_on and t.status != "complete"}
341
+ | {t.name for t in s["backlog"] if name in t.depends_on})
342
+ if dependents:
343
+ die(f"'{name}' cannot be archived — {len(dependents)} item(s) still depend "
344
+ f"on it: {', '.join(dependents)}. Archiving it would leave them waiting "
345
+ f"on work nothing will ever finish. Drop the dependency, or archive "
346
+ f"those first.")
347
+
348
+ # Somebody may be on it. Both notes WARN rather than refuse, exactly as `move`
349
+ # and the take-it-out-of-a-cut door already do: a harness that argues about a
350
+ # call the person has made gets worked around.
351
+ from .shift import read_claim
352
+ from . import peers
353
+ held = read_claim(task.folder)
354
+ if held and held.get("instance") != peers.me():
355
+ print(f"note: '{name}' is held by another session — {peers.of_claim(held)}",
356
+ file=sys.stderr)
357
+ if task.status == "in-progress":
358
+ print(f"note: '{name}' is in progress — somebody may be working it right "
359
+ f"now, and this takes it out from under them.", file=sys.stderr)
360
+
361
+ # WHERE it was proposed, carried in the record rather than in the path. An
362
+ # archived item leaves the cut entirely — that is what makes a release behind
363
+ # one arithmetically correct without a single count learning to skip it — so the
364
+ # tree can no longer say what it was part of and the entry has to.
365
+ home = "/".join(x for x in (task.version or "backlog", task.epic) if x)
366
+
367
+ dest = archive_dir(root) / name
368
+ if dest.exists():
369
+ die(f"{rel(dest, root)} already exists")
370
+ dest.parent.mkdir(parents=True, exist_ok=True)
371
+ with links.repairing(root.parent, indent=" ") as moved:
372
+ moved[task.folder.resolve()] = dest.resolve()
373
+ shutil.move(str(task.folder), str(dest))
374
+
375
+ entry = f"{date.today().isoformat()} {why} [from {home}]"
376
+
377
+ def mutate(d):
378
+ d["archived"] = as_list(d.get("archived")) + [entry]
379
+ d["updated"] = date.today().isoformat()
380
+
381
+ rewrite_file(dest / "task.md", mutate, TASK_FM_ORDER)
382
+ record_session(dest)
383
+ events.append(root, "archived", name, why=why, **{"from": home})
384
+ print(f"archived '{name}' — off the board, not gone")
385
+ print(f" why: {why}")
386
+ print(f" it was in {home}; `{cli()} path {name}` still reads it, and "
387
+ f"`{cli()} place {name} --version <v> --epic <e>` puts it back")
388
+ _sync(root)
389
+ return 0
390
+
391
+
392
+ def _archive_version(root, name: str, args) -> int:
393
+ """File a whole CUT off the board — the tier this verb has always served.
394
+
395
+ Which of the two homes it goes to is read from the tree, never passed in: a cut
396
+ carrying `released:` SHIPPED and is filed under `versions/complete/`, one
397
+ without it left for the other reason and goes to `versions/archive/`. See
398
+ `_home_for`.
399
+ """
261
400
  dry = str(args.get("dry-run", "")).lower() in ("true", "1", "yes")
262
401
  version = locate_version(root, name)
263
- if not version:
402
+ if not version: # pragma: no cover — dispatched
264
403
  die(f"no version named '{name}'")
265
404
  # A cut whose work all moved elsewhere has nothing left to deliver and no
266
405
  # outcome it honestly met, so releasing it would be a lie — and until now
@@ -313,12 +313,44 @@ files are deleted. **Read a dissolving `epic.md` in full first** — a thin epic
313
313
  fully-planned, deliberately-deferred goal hides.
314
314
 
315
315
  A version's status is derived: **planned** until tasks start, **current** while any is in-progress,
316
- **released** once `jarvis work release` stamps it. Release **flattens the done tier** and **stamps
317
- each `epic.md` as history, keeping it**, then `jarvis work archive` strips each task to its `task.md`
318
- and moves the version to `work/archive/versions/<v>/`.
316
+ **ready** once every task is complete and nobody has closed it, **released** once `jarvis work release`
317
+ stamps it. Release **flattens the done tier** and **stamps each `epic.md` as history, keeping it**,
318
+ then `jarvis work archive <v>` strips each task to its `task.md` and files the cut off the board.
319
319
 
320
- **Archived is off the board, not gone.** `list`, the README table and every lint see only `versions/`.
321
- `jarvis work path <v>` and `jarvis work where <id>` still resolve into the archive.
320
+ ### Complete, released, archived three words, and they are not synonyms
321
+
322
+ They get used interchangeably and they mean different things at different tiers. Nobody should have to
323
+ read the source to learn which:
324
+
325
+ | Word | What it is | Tier | Where it ends up |
326
+ |---|---|---|---|
327
+ | **complete** | this piece of work is FINISHED | one item | `complete/` inside its epic |
328
+ | **released** | the cut shipped and met its `outcome:` | a whole cut | stamped `released:`, still in `versions/` |
329
+ | **archived** | taken OFF the board — the two cases below | either | `versions/archive/` |
330
+
331
+ **Archived means one of two things, and which one is read off the tree rather than chosen.** A cut
332
+ that SHIPPED is filed under `versions/complete/<cut>/`; a cut leaving the board without shipping —
333
+ its work moved elsewhere, so there is no outcome it could honestly claim — goes to
334
+ `versions/archive/<cut>/`. Asking the caller to restate which is how the folder and the `released:`
335
+ stamp end up disagreeing.
336
+
337
+ **An ITEM is archived when we decided not to do it** — `jarvis work archive <name> --why "…"`, a
338
+ person's command. It lands in `versions/archive/<item>/` beside those cuts, it strips NOTHING, and the
339
+ reason is required: a decision not to do something is only worth keeping if it says what the decision
340
+ was. Founder, 2026-09-10, naming the state the board did not have: *"there is a difference between
341
+ archive and completed — archive should be tasks that we dropped and no longer want them, but don't
342
+ want to delete just yet."*
343
+
344
+ **Archived is off the board, not gone.** `list`, the README table, every lint, a release's count of
345
+ what a cut still owes and an epic's is-it-done all stop seeing it. `jarvis work path <name>` and
346
+ `jarvis work where <id>` still resolve into the archive, `jarvis work list --archived` browses what
347
+ left, and `jarvis work place <name> --version <v> --epic <e>` puts an item back — putting one back is
348
+ a person's deliberate act, which is what keeps the archive a set of decisions rather than a second
349
+ queue. **Deleting a task folder by hand is not a third way off the board**; it is the thing the verb
350
+ exists to remove, and it is the one write that destroys the evidence rather than the work.
351
+
352
+ A COMPLETED item is the one thing that is fixed: it shipped in its cut and cannot be moved out of it
353
+ or archived, because a release whose contents can be edited afterwards cannot answer what it delivered.
322
354
 
323
355
  **A finished epic goes off the board too**: an epic whose every task is complete drops into
324
356
  `<v>/complete/` and prints as one `DONE` line. It is **derived and materialized, never declared** —
@@ -338,6 +370,7 @@ is a brief and nothing else.
338
370
  | in-progress | `task.md` — the design is in `epic.md` §Plan and is not restated here. (`plan.md` **only** for a call the epic plan left unsettled; `handoff.md` **only** when handing to a new conversation) | handoff ≤80 |
339
371
  | complete (version unreleased) | same; if a handoff exists, stamp it DONE | — |
340
372
  | archived (version released) | `task.md` only | — |
373
+ | archived (the ITEM, we decided not to do it) | everything it had — archiving an item strips NOTHING, because the thinking that reached the decision is the whole of what there is to keep | — |
341
374
 
342
375
  **A task is ONE goal, phased internally — never shredded.** If it spans **3+ `code` regions** the lint
343
376
  says so, and it is right: that is an epic wearing a task costume. Split it by *goal*, not by layer.
@@ -376,8 +409,10 @@ work/
376
409
  │ ├── design.md · architecture.md (conditional)
377
410
  │ ├── <epic>/epic.md + {queue,in-progress,complete}/<task>/
378
411
  │ └── complete/<epic>/ every task done — off the board, still in the cut
379
- ├── backlog/<epic>/<task>/ epics planned but not yet in a cut — NO buckets here
380
- ├── archive/versions/<v>/ released + archived cuts
412
+ ├── versions/backlog/<epic>/<task>/ epics planned but not yet in a cut — NO buckets here
413
+ ├── versions/complete/<cut>/ a cut that SHIPPED
414
+ ├── versions/archive/<cut>/ a cut taken off the board unreleased
415
+ ├── versions/archive/<item>/ ONE item we decided not to do (`--why` is required)
381
416
  ├── research/<dir>/ shared research (each has a 00-report.md summary)
382
417
  ├── product/README.md what this domain owns + its rules; behaviour specs sit beside it
383
418
  │ ├── <feature>.md what the app does — bridged to versions/ via task.md's `owner:`
@@ -439,7 +474,10 @@ the only way forward has hit a real gap: **park a question, do not shell out.**
439
474
  | Place work in a cut | `jarvis work place <epic> --version <v>` · `jarvis work place <task> --version <v> --epic <e>` | a person's |
440
475
  | Take it back out | `jarvis work place <name> --backlog --epic <e>` | a person's |
441
476
  | Carry an epic forward | `jarvis work epic-new <name> --version <v> --continues <earlier epic>` | a person's |
442
- | Release / archive | `jarvis work release <v>` · `jarvis work archive <v>` | a person's |
477
+ | Release / archive a cut | `jarvis work release <v>` · `jarvis work archive <v>` | a person's |
478
+ | Stop doing an item | `jarvis work archive <name> --why "…"` — off the board, not deleted | a person's |
479
+ | See what we stopped doing | `jarvis work list --archived` | either |
480
+ | Want it again | `jarvis work place <name> --version <v> --epic <e>` | a person's |
443
481
  | Answer a parked question | `jarvis work needs` → `jarvis work answer <task> --choose "…"` | a person's |
444
482
  | Read the shift | `jarvis work status` · `jarvis work digest --since YYYY-MM-DD` | a person's |
445
483
  | Config | `jarvis work config` | a person's |