@appchy/jarvis 0.1.78 → 0.1.80
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 -5
- package/dist/bin.js.map +1 -1
- package/dist/ui/app.js +25 -25
- package/harness/harness/architecture.py +6 -1
- package/harness/harness/gate.py +27 -0
- package/harness/harness/generate.py +18 -1
- package/harness/harness/git.py +41 -0
- package/harness/harness/migrate.py +15 -7
- package/harness/harness/model.py +33 -5
- package/harness/harness/report.py +18 -2
- package/harness/harness/tree.py +22 -1
- package/harness/harness/version.py +27 -5
- package/harness/test_work.py +240 -3
- package/harness/work.py +1 -0
- package/package.json +4 -4
|
@@ -131,7 +131,12 @@ def cmd_init(args) -> int:
|
|
|
131
131
|
|
|
132
132
|
write(root / "README.md", _README)
|
|
133
133
|
write(root / "ROADMAP.md", _ROADMAP)
|
|
134
|
-
|
|
134
|
+
# All THREE states a cut's work is in, not two. `complete/` was missing here
|
|
135
|
+
# and in `migrate`, so the folder a released cut belongs in did not exist until
|
|
136
|
+
# something created it — and the first repo to release a cut had nowhere to put
|
|
137
|
+
# it that was not a lie about what happened.
|
|
138
|
+
for d in ("versions", "versions/backlog", "versions/archive",
|
|
139
|
+
"versions/complete"):
|
|
135
140
|
(root / d).mkdir(parents=True, exist_ok=True)
|
|
136
141
|
# Reading order, not alphabetical — and read from config, so a repo that added a
|
|
137
142
|
# tenth domain gets it scaffolded too rather than having to remember to.
|
package/harness/harness/gate.py
CHANGED
|
@@ -608,6 +608,33 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
|
|
|
608
608
|
elif not head and when != date.today().isoformat():
|
|
609
609
|
reasons.append(f"verify last passed on {when}, not today, and there "
|
|
610
610
|
f"is no git sha to pin it to — re-run it")
|
|
611
|
+
|
|
612
|
+
# …and that the code ARRIVED, which is the other half and was missing.
|
|
613
|
+
# Everything above reads COMMITTED history, so it proves the code moved
|
|
614
|
+
# FORWARD PAST the evidence and never that it got there at all. A task can
|
|
615
|
+
# be green with its whole implementation in one working tree; three were,
|
|
616
|
+
# for as long as ten days, and the only commits around them were
|
|
617
|
+
# `docs(work):` ones, which look exactly like a healthy board.
|
|
618
|
+
#
|
|
619
|
+
# It refuses rather than warns because the gates RAN AGAINST THE WORKING
|
|
620
|
+
# TREE. If that tree is not what is in git, the evidence and the commit it
|
|
621
|
+
# is pinned to describe different code, and calling that shipped is the
|
|
622
|
+
# false claim this whole file exists to prevent.
|
|
623
|
+
#
|
|
624
|
+
# The harness names the files and stops there: committing somebody's source
|
|
625
|
+
# is a far larger claim on their repo than committing the board, and a board
|
|
626
|
+
# write deliberately leaves a session's own code alone (founder, 2026-09-10).
|
|
627
|
+
loose = git.uncommitted_code(root.parent) if git.enabled() else []
|
|
628
|
+
if loose:
|
|
629
|
+
shown = ", ".join(loose[:5])
|
|
630
|
+
more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
|
|
631
|
+
reasons.append(
|
|
632
|
+
f"{len(loose)} uncommitted change(s) outside the board — the gates "
|
|
633
|
+
f"ran against this working tree, so completing now would record "
|
|
634
|
+
f"shipped for code no commit contains: {shown}{more}. Commit them "
|
|
635
|
+
f"(or stash or gitignore what is not this task's), then re-run "
|
|
636
|
+
f"`jarvis work verify --task {task.name}` — committing moves HEAD, "
|
|
637
|
+
f"so the evidence has to be taken on the tree that shipped.")
|
|
611
638
|
else:
|
|
612
639
|
reasons.append("no `verify` commands configured — an unconfigured repo "
|
|
613
640
|
"cannot prove anything, so nothing in it can complete. Set "
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from .tree import (BACKLOG_END, BACKLOG_START, DONE_TIER, SYSTEMS_END,
|
|
9
9
|
SYSTEMS_START, die, rel)
|
|
10
|
-
from .model import Task, Version, _ordered, scan
|
|
10
|
+
from .model import Task, Version, _ordered, scan, scan_shipped
|
|
11
11
|
from .registry import rules_by_domain, scan_domains, scan_systems
|
|
12
12
|
from .lint import print_lint
|
|
13
13
|
|
|
@@ -105,6 +105,23 @@ def _regen_readme(root: Path) -> str:
|
|
|
105
105
|
rows.append(f"| [{d.name}]({d.name}/README.md) | {counts.get(d.name) or '—'} |")
|
|
106
106
|
sections.insert(0, "\n".join(rows))
|
|
107
107
|
|
|
108
|
+
# What this repo has DELIVERED, last and one line each — the mirror of the
|
|
109
|
+
# SHIPPED block in `list`, and here for the same reason. A README whose board
|
|
110
|
+
# simply stops at the live cuts describes a repo that has never released
|
|
111
|
+
# anything, which was false the day the first cut shipped.
|
|
112
|
+
shipped = scan_shipped(root)
|
|
113
|
+
if shipped:
|
|
114
|
+
rows = ["### Shipped _( released and off the board — the record is in "
|
|
115
|
+
"`versions/complete/` )_", ""]
|
|
116
|
+
for v in shipped:
|
|
117
|
+
link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
|
|
118
|
+
n = len(v.all_tasks())
|
|
119
|
+
rows.append(f"- **{link}** — `{v.name}` · released {v.released} "
|
|
120
|
+
f"· {n} task(s)")
|
|
121
|
+
if v.outcome:
|
|
122
|
+
rows.append(f" <br>{v.outcome}")
|
|
123
|
+
sections.append("\n".join(rows))
|
|
124
|
+
|
|
108
125
|
block = "\n\n".join(sections) if sections else "_backlog is empty_"
|
|
109
126
|
# The replacement is a CALLABLE so that what goes in comes back out. A title is
|
|
110
127
|
# something a person typed, and as a replacement template `\d` is a bad escape
|
package/harness/harness/git.py
CHANGED
|
@@ -95,6 +95,47 @@ def enabled() -> bool:
|
|
|
95
95
|
return bool(GIT.get("commit"))
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def uncommitted_code(repo) -> list:
|
|
99
|
+
"""Everything outside the board that is not in git yet, sorted.
|
|
100
|
+
|
|
101
|
+
The mirror of `changed`, and the completion gate's half of it. `changed` asks
|
|
102
|
+
what a board write would CARRY; this asks what a completion would LEAVE BEHIND.
|
|
103
|
+
|
|
104
|
+
**Why this is accuracy rather than caution.** A verify run executes the
|
|
105
|
+
configured commands against the WORKING TREE, so a pass means "these gates pass
|
|
106
|
+
here, now". It is then recorded against HEAD. If anything outside the board
|
|
107
|
+
differs from HEAD, those two are statements about different trees, and the
|
|
108
|
+
board ends up saying shipped about code no commit contains. Measured three
|
|
109
|
+
times, once on the task whose entire subject was the harness losing what it is
|
|
110
|
+
trusted to keep — it completed with 7/7 gates recorded twice and none of its
|
|
111
|
+
fix in HEAD.
|
|
112
|
+
|
|
113
|
+
Untracked files count, and they are the important half: the lost work's own
|
|
114
|
+
shape was NEW files git had never heard of. Anything gitignored never appears
|
|
115
|
+
here, so a scratch directory somebody has already told git to ignore is not
|
|
116
|
+
this check's business.
|
|
117
|
+
|
|
118
|
+
Claims and the run file are excluded for the reason they are never committed —
|
|
119
|
+
they are one machine's coordination, true for minutes.
|
|
120
|
+
"""
|
|
121
|
+
board = [r.rstrip("/") for r in (GIT.get("paths") or [])]
|
|
122
|
+
code, out, _ = _git(repo, "status", "--porcelain", "-z",
|
|
123
|
+
"--untracked-files=all", "--no-renames")
|
|
124
|
+
if code != 0:
|
|
125
|
+
return []
|
|
126
|
+
found = []
|
|
127
|
+
for entry in out.split("\0"):
|
|
128
|
+
if len(entry) <= 3:
|
|
129
|
+
continue
|
|
130
|
+
p = entry[3:]
|
|
131
|
+
if Path(p).name in LOCAL:
|
|
132
|
+
continue
|
|
133
|
+
if any(p == r or p.startswith(f"{r}/") for r in board):
|
|
134
|
+
continue
|
|
135
|
+
found.append(p)
|
|
136
|
+
return sorted(found)
|
|
137
|
+
|
|
138
|
+
|
|
98
139
|
def machine() -> str:
|
|
99
140
|
"""Which machine acted. `WORK_MACHINE` where something knows a better name for
|
|
100
141
|
this box than its hostname — a daemon that already has an identity for it —
|
|
@@ -24,7 +24,7 @@ import shutil
|
|
|
24
24
|
import subprocess
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
from .tree import archive_dir, backlog_dir, die, find_work_root, rel
|
|
27
|
+
from .tree import archive_dir, backlog_dir, complete_dir, die, find_work_root, rel
|
|
28
28
|
from . import links
|
|
29
29
|
|
|
30
30
|
|
|
@@ -79,6 +79,7 @@ def cmd_migrate(args) -> int:
|
|
|
79
79
|
old = root / "backlog"
|
|
80
80
|
new = backlog_dir(root)
|
|
81
81
|
archive = archive_dir(root)
|
|
82
|
+
complete = complete_dir(root)
|
|
82
83
|
cuts = _archived_cuts(root)
|
|
83
84
|
|
|
84
85
|
# The hard refusals. Two of anything means somebody has already started this,
|
|
@@ -101,6 +102,8 @@ def cmd_migrate(args) -> int:
|
|
|
101
102
|
todo.append(f"{len(cuts)} archived cut(s) move inside versions/")
|
|
102
103
|
if not archive.is_dir():
|
|
103
104
|
todo.append("versions/archive/ is created")
|
|
105
|
+
if not complete.is_dir():
|
|
106
|
+
todo.append("versions/complete/ is created")
|
|
104
107
|
if not todo:
|
|
105
108
|
print(f"{rel(root, root)} is already on the current layout — nothing to do")
|
|
106
109
|
return 0
|
|
@@ -142,12 +145,17 @@ def cmd_migrate(args) -> int:
|
|
|
142
145
|
if len(stale) > 10:
|
|
143
146
|
print(f" … and {len(stale) - 10} more")
|
|
144
147
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
# Both off-board homes, for the same reason: an empty directory does not
|
|
149
|
+
# survive git, and the point of making them now is that `archive` has somewhere
|
|
150
|
+
# to put a cut without inventing it later — under either outcome. A repo that
|
|
151
|
+
# migrated before `complete/` existed gets it here rather than on the day it
|
|
152
|
+
# first releases something, which is the worst moment to find a folder missing.
|
|
153
|
+
for d in (archive, complete):
|
|
154
|
+
if d.is_dir():
|
|
155
|
+
continue
|
|
156
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
(d / ".gitkeep").write_text("")
|
|
158
|
+
print(f"created {rel(d, root)}")
|
|
151
159
|
|
|
152
160
|
if cuts:
|
|
153
161
|
# One block for every cut. Each carries a whole released version's worth of
|
package/harness/harness/model.py
CHANGED
|
@@ -310,15 +310,43 @@ def locate(root: Path, name: str):
|
|
|
310
310
|
return t
|
|
311
311
|
return None
|
|
312
312
|
def locate_version(root: Path, name: str):
|
|
313
|
-
"""Find a version by name. Looks in
|
|
314
|
-
|
|
315
|
-
answering, while `scan` (and
|
|
316
|
-
sees only live work.
|
|
317
|
-
|
|
313
|
+
"""Find a version by name. Looks in BOTH homes a cut leaves the board for —
|
|
314
|
+
`versions/complete/` and `versions/archive/` — so a finished cut stays
|
|
315
|
+
LOOKUPABLE after it goes, and `path` keeps answering, while `scan` (and
|
|
316
|
+
therefore `list`, the README and every lint) sees only live work. Off the
|
|
317
|
+
board is not gone.
|
|
318
|
+
|
|
319
|
+
Both are named here rather than one, because a lookup that knows only the
|
|
320
|
+
home a cut USED to have is how a move makes a cut vanish: 232 backlog rows
|
|
321
|
+
dropped out of one README when the tree moved under a reader that had not."""
|
|
322
|
+
for base in ("versions", "versions/complete", "versions/archive"):
|
|
318
323
|
folder = root / base / name
|
|
319
324
|
if (folder / "version.md").is_file():
|
|
320
325
|
return Version(folder)
|
|
321
326
|
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def scan_shipped(root: Path) -> list:
|
|
330
|
+
"""Every cut filed under `versions/complete/`, newest release first.
|
|
331
|
+
|
|
332
|
+
Deliberately NOT part of `scan`. `scan` answers "what is moving", and a shipped
|
|
333
|
+
cut is not — that separation is why archiving one stopped `list` having to be
|
|
334
|
+
read past 60 finished tasks. This answers the other question, "what has this
|
|
335
|
+
repo actually delivered", and its two readers (`list` and the README) render one
|
|
336
|
+
LINE per cut from it rather than its tasks.
|
|
337
|
+
|
|
338
|
+
Sorted by release date descending so the most recent thing shipped reads first.
|
|
339
|
+
Two passes rather than one reversed key: a same-day pair of cuts must still read
|
|
340
|
+
in roadmap order, and reversing a compound key would stand those on their head
|
|
341
|
+
too."""
|
|
342
|
+
cdir = root / "versions" / "complete"
|
|
343
|
+
if not cdir.is_dir():
|
|
344
|
+
return []
|
|
345
|
+
cuts = [Version(v) for v in sorted(cdir.iterdir())
|
|
346
|
+
if v.is_dir() and (v / "version.md").is_file()]
|
|
347
|
+
cuts.sort(key=lambda v: (v.order, v.name))
|
|
348
|
+
cuts.sort(key=lambda v: v.released or "", reverse=True)
|
|
349
|
+
return cuts
|
|
322
350
|
def locate_epic(root: Path, name: str):
|
|
323
351
|
"""Find an epic by name anywhere — in a version or in the backlog. Names are
|
|
324
352
|
globally unique across tasks, epics and versions, so a name alone resolves."""
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from .tree import find_work_root, rel
|
|
7
7
|
from .frontmatter import _eol, read_item, split_frontmatter, write_item
|
|
8
|
-
from .model import Task, _ordered, scan
|
|
8
|
+
from .model import Task, _ordered, scan, scan_shipped
|
|
9
9
|
from .lint import lint_warnings
|
|
10
10
|
from .generate import _regen_readme, _sync, settle_epic_tier
|
|
11
11
|
from .align import (_align_acceptance, _align_agents, _align_citations,
|
|
@@ -69,7 +69,12 @@ def cmd_list(args) -> int:
|
|
|
69
69
|
for d in domains]
|
|
70
70
|
print("DOMAINS " + " · ".join(cells) + " (n) = rules hosted")
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
# READ BEFORE THE EMPTINESS TEST, because a shipped cut is not nothing. A repo
|
|
73
|
+
# whose only cut has been released has no live versions and no backlog, and the
|
|
74
|
+
# early return printed "backlog is empty" over the top of a delivered release —
|
|
75
|
+
# the precise reading this block exists to stop.
|
|
76
|
+
shipped = scan_shipped(root)
|
|
77
|
+
if not s["versions"] and not s["backlog"] and not shipped:
|
|
73
78
|
print("\nbacklog is empty")
|
|
74
79
|
return 0
|
|
75
80
|
|
|
@@ -116,6 +121,17 @@ def cmd_list(args) -> int:
|
|
|
116
121
|
print()
|
|
117
122
|
for t in loose:
|
|
118
123
|
print(f" {_task_line(t)}")
|
|
124
|
+
|
|
125
|
+
# WHAT THIS REPO HAS ACTUALLY DELIVERED, at the bottom and one line each.
|
|
126
|
+
# A shipped cut leaves the live board — that is what made this list readable —
|
|
127
|
+
# but leaving it out entirely made a repo with a release behind it read exactly
|
|
128
|
+
# like a repo that has never shipped anything. The count is the whole cut, so
|
|
129
|
+
# `142/142` says a release closed rather than a bucket did.
|
|
130
|
+
for v in shipped:
|
|
131
|
+
n = len(v.all_tasks())
|
|
132
|
+
print(f"\nSHIPPED {v.name} — {v.title} · released {v.released} · {n}/{n}")
|
|
133
|
+
if v.outcome:
|
|
134
|
+
print(f" {v.outcome}")
|
|
119
135
|
print()
|
|
120
136
|
warns = lint_warnings(root)
|
|
121
137
|
if warns:
|
package/harness/harness/tree.py
CHANGED
|
@@ -218,8 +218,29 @@ def backlog_dir(root: Path) -> Path:
|
|
|
218
218
|
def archive_dir(root: Path) -> Path:
|
|
219
219
|
"""Where a finished cut goes when releasing it would be a lie. A cut whose work
|
|
220
220
|
all moved elsewhere has nothing left to deliver and no outcome it met; without
|
|
221
|
-
this it sits in the live list looking open forever.
|
|
221
|
+
this it sits in the live list looking open forever.
|
|
222
|
+
|
|
223
|
+
One of TWO homes a cut leaves the board for, and the other is `complete_dir`.
|
|
224
|
+
Which one is not a choice a caller makes: `archive` reads the `released:` stamp,
|
|
225
|
+
because whether a cut shipped is a fact the tree already holds."""
|
|
222
226
|
return root / "versions" / "archive"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def complete_dir(root: Path) -> Path:
|
|
230
|
+
"""Where a cut that SHIPPED is filed — released, outcome met, nothing owed.
|
|
231
|
+
|
|
232
|
+
The third folder of `versions/{backlog,archive,complete}`, and it exists because
|
|
233
|
+
the other two could not say this. A released cut sent to `archive/` is filed
|
|
234
|
+
under "releasing would be a lie" — the exact opposite of what happened to it —
|
|
235
|
+
and one left in `versions/` sits among the live cuts looking open. Measured: a
|
|
236
|
+
repo released its first cut and had nowhere to put it, so it stayed on the board
|
|
237
|
+
for a day rather than be described wrongly.
|
|
238
|
+
|
|
239
|
+
Off the live board is not gone. `locate_version` resolves a cut here, and the
|
|
240
|
+
board keeps ONE line for it — what it was, when it shipped, what it delivered —
|
|
241
|
+
because a board that drops its releases reads as a board that has never shipped
|
|
242
|
+
anything."""
|
|
243
|
+
return root / "versions" / "complete"
|
|
223
244
|
def assets_dir() -> Path:
|
|
224
245
|
return PAYLOAD_DIR / "assets"
|
|
225
246
|
def rel(p: Path, root: Path) -> str:
|
|
@@ -5,7 +5,8 @@ from datetime import date
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from . import ids
|
|
8
|
-
from .tree import archive_dir, DONE_TIER, VERSION_FM_ORDER, cli, die,
|
|
8
|
+
from .tree import (archive_dir, complete_dir, DONE_TIER, VERSION_FM_ORDER, cli, die,
|
|
9
|
+
find_work_root, rel)
|
|
9
10
|
from .frontmatter import rewrite_file
|
|
10
11
|
from .model import _is_epic_dir, locate_version, scan
|
|
11
12
|
from .scaffold import _check_unused, _check_version_name, _scaffold_version
|
|
@@ -199,7 +200,8 @@ def cmd_release(args) -> int:
|
|
|
199
200
|
print("\nDistill before archiving:")
|
|
200
201
|
print(" 1. Promote still-load-bearing decisions to the domain or system that owns them")
|
|
201
202
|
print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
|
|
202
|
-
print(f" 3. {cli()} archive {name} (strips each task to task.md
|
|
203
|
+
print(f" 3. {cli()} archive {name} (strips each task to task.md, and "
|
|
204
|
+
f"files a RELEASED cut under versions/complete/)")
|
|
203
205
|
_sync(root)
|
|
204
206
|
return 0
|
|
205
207
|
def _short_sha() -> str:
|
|
@@ -233,6 +235,21 @@ def _contained(p: Path, root: Path) -> Path:
|
|
|
233
235
|
return rp
|
|
234
236
|
|
|
235
237
|
|
|
238
|
+
def _home_for(version, root: Path) -> Path:
|
|
239
|
+
"""Which of the two off-board homes this cut belongs in, read from the tree.
|
|
240
|
+
|
|
241
|
+
A cut carrying `released:` SHIPPED, and goes to `versions/complete/`. One
|
|
242
|
+
without it is leaving the board for the other reason — its work moved elsewhere
|
|
243
|
+
and there is no outcome it honestly met — and goes to `versions/archive/`,
|
|
244
|
+
which is what that folder's own description has always said it was for.
|
|
245
|
+
|
|
246
|
+
Not a flag, and deliberately not a second verb. Whether a cut shipped is a fact
|
|
247
|
+
the tree already holds, and asking the caller to restate it is how the folder
|
|
248
|
+
and the stamp end up disagreeing — the same two-writers-for-one-fact that makes
|
|
249
|
+
a status field drift from the bucket it sits in."""
|
|
250
|
+
return complete_dir(root) if version.released else archive_dir(root)
|
|
251
|
+
|
|
252
|
+
|
|
236
253
|
def cmd_archive(args) -> int:
|
|
237
254
|
root = find_work_root()
|
|
238
255
|
name = args["name"]
|
|
@@ -285,7 +302,7 @@ def cmd_archive(args) -> int:
|
|
|
285
302
|
removed += 1
|
|
286
303
|
|
|
287
304
|
if dry:
|
|
288
|
-
dest =
|
|
305
|
+
dest = _home_for(version, root) / name
|
|
289
306
|
print(f" stamp archived: on {rel(version.md, root)}")
|
|
290
307
|
print(f" move {rel(version.folder, root)} -> {rel(dest, root)}")
|
|
291
308
|
print(f"\n {removed} file(s)/dir(s) would be stripped. "
|
|
@@ -309,7 +326,7 @@ def cmd_archive(args) -> int:
|
|
|
309
326
|
# past 60 completed tasks to find the 6 that were actually moving. The
|
|
310
327
|
# record is preserved verbatim in `archive/versions/<v>/`, where `path` and
|
|
311
328
|
# `where` still resolve it — it is just no longer in the way.
|
|
312
|
-
dest = _contained(
|
|
329
|
+
dest = _contained(_home_for(version, root) / name, root)
|
|
313
330
|
if dest.exists():
|
|
314
331
|
die(f"{rel(dest, root)} already exists")
|
|
315
332
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -319,7 +336,12 @@ def cmd_archive(args) -> int:
|
|
|
319
336
|
|
|
320
337
|
where = f" — full docs in git history @ {sha}" if sha else ""
|
|
321
338
|
events.append(root, "archived", name, stripped=removed)
|
|
322
|
-
|
|
339
|
+
# SAY WHICH, and say it in the words of what happened rather than the verb's
|
|
340
|
+
# name: "archived" reads as binned, and a cut that shipped has just been filed
|
|
341
|
+
# under the opposite of that. The verb is one because the move is one; the
|
|
342
|
+
# sentence is two because the outcomes are.
|
|
343
|
+
did = "filed as complete — it shipped" if version.released else "archived"
|
|
344
|
+
print(f"{did}: '{name}' stripped {removed} file(s) to task.md, "
|
|
323
345
|
f"moved to {rel(dest, root)}{where}")
|
|
324
346
|
_sync(root)
|
|
325
347
|
return 0
|
package/harness/test_work.py
CHANGED
|
@@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
26
26
|
from harness.ids import LEDGER as L # noqa: E402 — fixtures render in the repo's dialect
|
|
27
27
|
from harness import (align, architecture, autonomy, branches, config, coverage, epic, # noqa: E402
|
|
28
28
|
events, extend, frontmatter, gate, generate, git, ids, kickoff,
|
|
29
|
-
lint, model, peers, registry, safety, scaffold,
|
|
29
|
+
lint, model, peers, registry, report, safety, scaffold,
|
|
30
30
|
shift, task, tree, version)
|
|
31
31
|
|
|
32
32
|
# `parse_argv` is the ENTRY's own concern, so it is loaded from work.py by path
|
|
@@ -967,14 +967,18 @@ def test_release_does_not_treat_a_flat_versions_completed_tasks_as_epics():
|
|
|
967
967
|
("flat-shipped-thing", "complete")]
|
|
968
968
|
|
|
969
969
|
|
|
970
|
-
def
|
|
970
|
+
def test_archive_moves_an_unreleased_cut_off_the_board_but_keeps_it_lookupable():
|
|
971
971
|
# Archived is OFF THE BOARD, not gone: `scan` (and therefore list, the
|
|
972
972
|
# README and every lint) stops seeing it, while `locate_version` still
|
|
973
973
|
# resolves it so `path` and `where` keep answering.
|
|
974
|
+
#
|
|
975
|
+
# NO `released:` HERE, and that is the point of the fixture rather than an
|
|
976
|
+
# omission: `versions/archive/` is where a cut goes when releasing it would be
|
|
977
|
+
# a lie. The released arm lands somewhere else and has its own test below.
|
|
974
978
|
import os
|
|
975
979
|
with tempfile.TemporaryDirectory() as tmp:
|
|
976
980
|
root = Path(tmp)
|
|
977
|
-
v = _tree(tmp, "26-cut"
|
|
981
|
+
v = _tree(tmp, "26-cut")
|
|
978
982
|
e = _epic(v, "an-epic")
|
|
979
983
|
_task(e / "complete", "shipped-thing")
|
|
980
984
|
(e / "complete" / "shipped-thing" / "plan.md").write_text("design\n")
|
|
@@ -995,6 +999,100 @@ def test_archive_moves_the_version_off_the_board_but_keeps_it_lookupable():
|
|
|
995
999
|
assert model.locate_version(root, "26-cut") is not None
|
|
996
1000
|
|
|
997
1001
|
|
|
1002
|
+
def test_archive_files_a_released_cut_under_complete_not_archive():
|
|
1003
|
+
# THE ARM THAT DID NOT EXIST. A cut carrying `released:` shipped — it met its
|
|
1004
|
+
# outcome and owes nothing — so filing it under "releasing would be a lie" is
|
|
1005
|
+
# the one description of it that is false. It goes to `versions/complete/`, and
|
|
1006
|
+
# `archive/` is not even created on this path.
|
|
1007
|
+
#
|
|
1008
|
+
# Same strip either way: what a task carries beyond its brief is the working
|
|
1009
|
+
# papers of a session, and `release` already tells you to lift anything still
|
|
1010
|
+
# load-bearing into the domain that owns it before this runs.
|
|
1011
|
+
import os
|
|
1012
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1013
|
+
root = Path(tmp)
|
|
1014
|
+
v = _tree(tmp, "26-cut", released="2026-08-01")
|
|
1015
|
+
e = _epic(v, "an-epic")
|
|
1016
|
+
_task(e / "complete", "shipped-thing")
|
|
1017
|
+
(e / "complete" / "shipped-thing" / "handoff.md").write_text("notes\n")
|
|
1018
|
+
os.environ["WORK_DIR"] = tmp
|
|
1019
|
+
try:
|
|
1020
|
+
version.cmd_archive({"name": "26-cut"})
|
|
1021
|
+
finally:
|
|
1022
|
+
os.environ.pop("WORK_DIR")
|
|
1023
|
+
|
|
1024
|
+
assert not (root / "versions" / "26-cut").exists()
|
|
1025
|
+
assert not (root / "versions" / "archive" / "26-cut").exists()
|
|
1026
|
+
dest = root / "versions" / "complete" / "26-cut"
|
|
1027
|
+
assert (dest / "version.md").is_file()
|
|
1028
|
+
assert (dest / "an-epic" / "complete" / "shipped-thing" / "task.md").is_file()
|
|
1029
|
+
assert not (dest / "an-epic" / "complete" / "shipped-thing" / "handoff.md").exists()
|
|
1030
|
+
|
|
1031
|
+
# Off the LIVE board, still resolvable by name — `path` keeps answering.
|
|
1032
|
+
assert model.scan(root)["versions"] == []
|
|
1033
|
+
found = model.locate_version(root, "26-cut")
|
|
1034
|
+
assert found is not None
|
|
1035
|
+
assert found.folder == dest
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def test_archive_dry_run_names_the_home_a_released_cut_is_going_to():
|
|
1039
|
+
# A dry run whose printed destination is not the real one is worse than no dry
|
|
1040
|
+
# run: it is a rehearsal of a different command. Both arms read the same stamp.
|
|
1041
|
+
import io, os, contextlib
|
|
1042
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1043
|
+
v = _tree(tmp, "26-cut", released="2026-08-01")
|
|
1044
|
+
e = _epic(v, "an-epic")
|
|
1045
|
+
_task(e / "complete", "shipped-thing")
|
|
1046
|
+
os.environ["WORK_DIR"] = tmp
|
|
1047
|
+
try:
|
|
1048
|
+
out = io.StringIO()
|
|
1049
|
+
with contextlib.redirect_stdout(out):
|
|
1050
|
+
version.cmd_archive({"name": "26-cut", "dry-run": "true"})
|
|
1051
|
+
finally:
|
|
1052
|
+
os.environ.pop("WORK_DIR")
|
|
1053
|
+
text = out.getvalue()
|
|
1054
|
+
assert "versions/complete/26-cut" in text, text
|
|
1055
|
+
assert "versions/archive/26-cut" not in text, text
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def test_the_board_accounts_for_a_shipped_cut_in_one_line():
|
|
1059
|
+
# A cut that shipped leaves the live board — that is what keeps `list`
|
|
1060
|
+
# readable — but leaving it out ENTIRELY made a repo with a release behind it
|
|
1061
|
+
# read exactly like one that has never shipped anything. So both readers keep
|
|
1062
|
+
# one line for it: what it was, when it shipped, what it delivered.
|
|
1063
|
+
import io, os, contextlib
|
|
1064
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1065
|
+
root = Path(tmp)
|
|
1066
|
+
v = _tree(tmp, "26-cut", outcome="a user can bounce a mix",
|
|
1067
|
+
released="2026-08-01")
|
|
1068
|
+
e = _epic(v, "an-epic")
|
|
1069
|
+
_task(e / "complete", "shipped-thing")
|
|
1070
|
+
os.environ["WORK_DIR"] = tmp
|
|
1071
|
+
try:
|
|
1072
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
1073
|
+
version.cmd_archive({"name": "26-cut"})
|
|
1074
|
+
out = io.StringIO()
|
|
1075
|
+
with contextlib.redirect_stdout(out):
|
|
1076
|
+
report.cmd_list({})
|
|
1077
|
+
finally:
|
|
1078
|
+
os.environ.pop("WORK_DIR")
|
|
1079
|
+
|
|
1080
|
+
listed = out.getvalue()
|
|
1081
|
+
assert "SHIPPED 26-cut" in listed, listed
|
|
1082
|
+
assert "released 2026-08-01" in listed, listed
|
|
1083
|
+
assert "a user can bounce a mix" in listed, listed
|
|
1084
|
+
assert "1/1" in listed, listed
|
|
1085
|
+
# ONE LINE, not the cut's task rows — the tasks stay in the record.
|
|
1086
|
+
assert "shipped-thing" not in listed, listed
|
|
1087
|
+
|
|
1088
|
+
readme = (root / "README.md").read_text()
|
|
1089
|
+
assert "### Shipped" in readme, readme
|
|
1090
|
+
assert "versions/complete/26-cut/version.md" in readme, readme
|
|
1091
|
+
assert "released 2026-08-01" in readme, readme
|
|
1092
|
+
assert "a user can bounce a mix" in readme, readme
|
|
1093
|
+
assert "shipped-thing" not in readme, readme
|
|
1094
|
+
|
|
1095
|
+
|
|
998
1096
|
def test_epic_covers_must_be_feature_qualified():
|
|
999
1097
|
# A bare AC-01 would name a different criterion in each feature an epic
|
|
1000
1098
|
# spans, so the format check is a refusal at write time.
|
|
@@ -3444,6 +3542,145 @@ def test_a_board_write_reaches_the_ORIGIN_not_just_the_disk():
|
|
|
3444
3542
|
config.apply(config.DEFAULTS)
|
|
3445
3543
|
|
|
3446
3544
|
|
|
3545
|
+
def _provable_task(repo, name="alpha"):
|
|
3546
|
+
"""A task in a git-mode repo with everything but the code committed: criteria
|
|
3547
|
+
ticked and a passing verify recorded, so the ONLY thing a gate can hold on is
|
|
3548
|
+
whether the implementation reached git."""
|
|
3549
|
+
e = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
3550
|
+
(e / "in-progress" / name).mkdir(parents=True)
|
|
3551
|
+
(e / "epic.md").write_text("---\ntype: epic\n---\n\n# E\n")
|
|
3552
|
+
(e.parent / "version.md").write_text(
|
|
3553
|
+
"---\ncreated: 2026-08-01\norder: 1\noutcome: x\n---\n\n# Cut\n")
|
|
3554
|
+
(e / "in-progress" / name / "task.md").write_text(
|
|
3555
|
+
f"---\npriority: P0\n---\n\n# {name}\n\n## Acceptance criteria\n\n- [x] it works\n")
|
|
3556
|
+
_git(repo, "add", "-A")
|
|
3557
|
+
_git(repo, "commit", "-qm", "the board so far")
|
|
3558
|
+
return e
|
|
3559
|
+
|
|
3560
|
+
|
|
3561
|
+
def test_completing_refuses_while_the_code_is_only_in_the_working_tree():
|
|
3562
|
+
# Every other check in the gate reads COMMITTED history, so all of them prove
|
|
3563
|
+
# the code moved FORWARD PAST the evidence and none proves it arrived. Three
|
|
3564
|
+
# tasks completed green with their whole implementation in one working tree —
|
|
3565
|
+
# one of them the task about the harness losing what it is trusted to keep,
|
|
3566
|
+
# with 7/7 recorded twice and none of its fix in HEAD.
|
|
3567
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3568
|
+
try:
|
|
3569
|
+
repo = _git_repo(tmp, push=False)
|
|
3570
|
+
e = _provable_task(repo)
|
|
3571
|
+
with _work_dir(str(repo / "work")) as root:
|
|
3572
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3573
|
+
try:
|
|
3574
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
3575
|
+
# Clean tree: nothing left to hold, so the gate is satisfied.
|
|
3576
|
+
assert gate.gate(root, model.locate(root, "alpha")) == []
|
|
3577
|
+
|
|
3578
|
+
# Now the failure this exists for — the implementation exists
|
|
3579
|
+
# and is in no commit. An UNTRACKED file, because that is the
|
|
3580
|
+
# lost work's own shape: new files git had never heard of.
|
|
3581
|
+
(repo / "src").mkdir()
|
|
3582
|
+
(repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
|
|
3583
|
+
reasons = gate.gate(root, model.locate(root, "alpha"))
|
|
3584
|
+
assert any("uncommitted" in r for r in reasons), reasons
|
|
3585
|
+
assert any("src/shipped.ts" in r for r in reasons), reasons
|
|
3586
|
+
|
|
3587
|
+
# And the move itself refuses, leaving the task where it was —
|
|
3588
|
+
# warning after moving is what made the board say done anyway.
|
|
3589
|
+
assert task.cmd_move({"name": "alpha", "status": "complete",
|
|
3590
|
+
"delivered": "it ships",
|
|
3591
|
+
"not-included": "nothing"}) == 1
|
|
3592
|
+
assert (e / "in-progress" / "alpha").is_dir()
|
|
3593
|
+
|
|
3594
|
+
# Committing satisfies THIS check and trips the older one, and
|
|
3595
|
+
# that is the flow rather than a snag: committing moves HEAD, so
|
|
3596
|
+
# evidence taken before it describes a different commit. The two
|
|
3597
|
+
# together mean commit, then verify, then complete — and the
|
|
3598
|
+
# refusal says so rather than leaving it to be discovered.
|
|
3599
|
+
_git(repo, "add", "-A")
|
|
3600
|
+
_git(repo, "commit", "-qm", "the code")
|
|
3601
|
+
reasons = gate.gate(root, model.locate(root, "alpha"))
|
|
3602
|
+
assert not any("uncommitted" in r for r in reasons), reasons
|
|
3603
|
+
assert any("code moved since verify" in r for r in reasons), reasons
|
|
3604
|
+
|
|
3605
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
3606
|
+
assert gate.gate(root, model.locate(root, "alpha")) == []
|
|
3607
|
+
finally:
|
|
3608
|
+
gate.VERIFY = old
|
|
3609
|
+
finally:
|
|
3610
|
+
config.apply(config.DEFAULTS)
|
|
3611
|
+
|
|
3612
|
+
|
|
3613
|
+
def test_partly_committed_code_is_still_uncommitted_code():
|
|
3614
|
+
# What `the-hub-says-why-a-write-failed` actually was: part of its work in HEAD
|
|
3615
|
+
# and part not. A check that asked "did anything land" would have passed it.
|
|
3616
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3617
|
+
try:
|
|
3618
|
+
repo = _git_repo(tmp, push=False)
|
|
3619
|
+
_provable_task(repo)
|
|
3620
|
+
(repo / "src").mkdir()
|
|
3621
|
+
(repo / "src" / "landed.ts").write_text("export const a = 1;\n")
|
|
3622
|
+
_git(repo, "add", "-A")
|
|
3623
|
+
_git(repo, "commit", "-qm", "half of it")
|
|
3624
|
+
(repo / "src" / "left-behind.ts").write_text("export const b = 2;\n")
|
|
3625
|
+
with _work_dir(str(repo / "work")) as root:
|
|
3626
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3627
|
+
try:
|
|
3628
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
3629
|
+
reasons = gate.gate(root, model.locate(root, "alpha"))
|
|
3630
|
+
assert any("src/left-behind.ts" in r for r in reasons), reasons
|
|
3631
|
+
finally:
|
|
3632
|
+
gate.VERIFY = old
|
|
3633
|
+
finally:
|
|
3634
|
+
config.apply(config.DEFAULTS)
|
|
3635
|
+
|
|
3636
|
+
|
|
3637
|
+
def test_a_repo_that_never_asked_for_git_completes_exactly_as_before():
|
|
3638
|
+
# The harness is shared with repos that opted out of all of this. Refusing on
|
|
3639
|
+
# their working tree would be a new claim on somebody who asked for none.
|
|
3640
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3641
|
+
try:
|
|
3642
|
+
repo = _git_repo(tmp, push=False)
|
|
3643
|
+
_provable_task(repo)
|
|
3644
|
+
(repo / "src").mkdir()
|
|
3645
|
+
(repo / "src" / "shipped.ts").write_text("export const it = 1;\n")
|
|
3646
|
+
config.apply({**config.DEFAULTS,
|
|
3647
|
+
"git": {"commit": False, "push": False,
|
|
3648
|
+
"remote": "origin", "paths": ["work"]}})
|
|
3649
|
+
with _work_dir(str(repo / "work")) as root:
|
|
3650
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3651
|
+
try:
|
|
3652
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
3653
|
+
assert gate.gate(root, model.locate(root, "alpha")) == []
|
|
3654
|
+
finally:
|
|
3655
|
+
gate.VERIFY = old
|
|
3656
|
+
finally:
|
|
3657
|
+
config.apply(config.DEFAULTS)
|
|
3658
|
+
|
|
3659
|
+
|
|
3660
|
+
def test_a_gitignored_scratch_file_is_not_this_gates_business():
|
|
3661
|
+
# The cost of refusing is that loose files block completion, and the escape is
|
|
3662
|
+
# the one people already use. A file git has been told to ignore never reaches
|
|
3663
|
+
# the check at all.
|
|
3664
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3665
|
+
try:
|
|
3666
|
+
repo = _git_repo(tmp, push=False)
|
|
3667
|
+
_provable_task(repo)
|
|
3668
|
+
(repo / ".gitignore").write_text("scratch/\n")
|
|
3669
|
+
_git(repo, "add", "-A")
|
|
3670
|
+
_git(repo, "commit", "-qm", "ignore scratch")
|
|
3671
|
+
(repo / "scratch").mkdir()
|
|
3672
|
+
(repo / "scratch" / "notes.md").write_text("thinking out loud\n")
|
|
3673
|
+
with _work_dir(str(repo / "work")) as root:
|
|
3674
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3675
|
+
try:
|
|
3676
|
+
assert gate.cmd_verify({"task": "alpha"}) == 0
|
|
3677
|
+
assert gate.gate(root, model.locate(root, "alpha")) == []
|
|
3678
|
+
finally:
|
|
3679
|
+
gate.VERIFY = old
|
|
3680
|
+
finally:
|
|
3681
|
+
config.apply(config.DEFAULTS)
|
|
3682
|
+
|
|
3683
|
+
|
|
3447
3684
|
def test_the_commit_trailer_names_the_item_the_event_and_the_machine():
|
|
3448
3685
|
# Four other pieces of work read this format, so it is asserted rather than
|
|
3449
3686
|
# assumed. Who, when and which branch stay OUT of it — git knows them, and a
|