@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.
- package/dist/bin.js +14 -4
- package/dist/bin.js.map +1 -1
- package/harness/harness/events.py +3 -1
- package/harness/harness/frontmatter.py +1 -1
- package/harness/harness/model.py +48 -1
- package/harness/harness/report.py +43 -2
- package/harness/harness/task.py +50 -18
- package/harness/harness/tree.py +8 -1
- package/harness/harness/version.py +144 -5
- package/harness/presets/appchy/PRESET.md +46 -8
- package/harness/test_work.py +348 -0
- package/harness/work.py +19 -3
- package/package.json +1 -1
|
@@ -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*(.*)$")
|
package/harness/harness/model.py
CHANGED
|
@@ -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,
|
|
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 .
|
|
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
|
package/harness/harness/task.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
package/harness/harness/tree.py
CHANGED
|
@@ -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,
|
|
9
|
-
find_work_root, rel)
|
|
10
|
-
from .frontmatter import rewrite_file
|
|
11
|
-
from .model import _is_epic_dir, locate_version,
|
|
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
|
-
**
|
|
317
|
-
each `epic.md` as history, keeping it**,
|
|
318
|
-
and
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
├──
|
|
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 |
|