@appchy/jarvis 0.1.37 → 0.1.39
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/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10168 -37341
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38821
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""`jarvis work coverage` — join what the FEATURES promise against what a test RUN
|
|
2
|
+
actually proved.
|
|
3
|
+
|
|
4
|
+
The gap this closes: until now a criterion was "done" because a person typed an
|
|
5
|
+
`x`, and its evidence was a `// Covers:` comment nobody re-checked. A comment
|
|
6
|
+
survives its test being renamed, skipped or deleted, so the spec could go on
|
|
7
|
+
claiming a behaviour nothing exercised. That is the failure mode this whole task
|
|
8
|
+
exists to end — a spec that lies is worse than one that is silent.
|
|
9
|
+
|
|
10
|
+
So the binding is emitted by the RUNNER, never authored:
|
|
11
|
+
|
|
12
|
+
vitest `covers("sharing/AC-01")` → the reporter `work` ships
|
|
13
|
+
playwright `{ tag: ["@sharing/AC-01"] }` → e2e/support/covers-reporter.ts
|
|
14
|
+
|
|
15
|
+
Both write `.work/coverage/*.json` with the same shape, carrying the file's REAL
|
|
16
|
+
result. Delete the test and the id stops appearing; let it fail and the id is
|
|
17
|
+
reported `failed`, never as evidence.
|
|
18
|
+
|
|
19
|
+
`.work/` is gitignored on purpose: evidence is a fresh run, not a file somebody
|
|
20
|
+
checked in.
|
|
21
|
+
|
|
22
|
+
**The denominator is what is BUILT, never what is promised.** A feature file is a
|
|
23
|
+
roadmap as much as a spec: most of its criteria describe behaviour nobody has
|
|
24
|
+
written yet, and dividing by all of them measures ambition instead of honesty —
|
|
25
|
+
it also cannot move except by someone deleting a promise. So the headline is
|
|
26
|
+
`proven / built`, and the unbuilt remainder is reported beside it rather than
|
|
27
|
+
underneath it.
|
|
28
|
+
"""
|
|
29
|
+
import json
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .lint import feature_ac_levels
|
|
33
|
+
from .model import scan_features
|
|
34
|
+
from .shard import _load_run
|
|
35
|
+
from .tree import find_work_root
|
|
36
|
+
|
|
37
|
+
#: `{name: command}` from the repo's `verify` config, set by `config.apply()`.
|
|
38
|
+
#: Empty by default: the harness must never suggest a command it invented.
|
|
39
|
+
VERIFY: dict = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _wrong_level(level: str, runners: set):
|
|
45
|
+
"""The complaint when a criterion's declared level and the level it is
|
|
46
|
+
actually claimed at disagree — `None` when they agree.
|
|
47
|
+
|
|
48
|
+
This is the suite standard's rule 5, made checkable: a test may only claim
|
|
49
|
+
what it ASSERTS, never the layer below it. A test aimed one layer away still
|
|
50
|
+
passes, so nothing about a green run reveals it, and the binding makes the
|
|
51
|
+
mistake permanent.
|
|
52
|
+
"""
|
|
53
|
+
if not runners:
|
|
54
|
+
return None
|
|
55
|
+
browser = "playwright" in runners
|
|
56
|
+
value = bool(runners - {"playwright"})
|
|
57
|
+
if level == "eyes-on":
|
|
58
|
+
return ("says only a person can settle it, but a test claims it — either "
|
|
59
|
+
"it is provable after all, or the test claims more than it asserts")
|
|
60
|
+
if level == "e2e" and not browser:
|
|
61
|
+
return "says it is about what is on screen, but only a vitest test claims it"
|
|
62
|
+
if level in ("unit", "integration") and browser and not value:
|
|
63
|
+
return f"says it is a {level} fact, but only a browser test claims it"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_coverage(args) -> int:
|
|
68
|
+
"""Report each feature's promises against a real run. REPORT-ONLY, exit 0 —
|
|
69
|
+
same reasoning as `align`: with coverage partial, a blocking gate is a lint
|
|
70
|
+
nobody reads. The flip is its own task."""
|
|
71
|
+
root = find_work_root()
|
|
72
|
+
repo = root.parent
|
|
73
|
+
run, runners, shards = _load_run(repo)
|
|
74
|
+
|
|
75
|
+
if not shards:
|
|
76
|
+
# The commands come from `verify.*` in the repo's config. They used to be
|
|
77
|
+
# one repo's own package-manager invocations, hardcoded — which told every
|
|
78
|
+
# other consumer to run a command it does not have. A name-only literal
|
|
79
|
+
# scan misses this, because a toolchain command carries no repo name;
|
|
80
|
+
# that is why the scan in the suite checks command names too.
|
|
81
|
+
lines = [f" {cmd}" + (f" ({name})" if name else "")
|
|
82
|
+
for name, cmd in sorted(VERIFY.items())]
|
|
83
|
+
print("\n no run found — `.work/coverage/` is empty.\n"
|
|
84
|
+
" Evidence is a fresh run, so produce one first:\n"
|
|
85
|
+
+ ("\n".join(lines) if lines
|
|
86
|
+
else " (no `verify` commands configured — add them to "
|
|
87
|
+
".claude/work.config.json)") + "\n")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
only = args.get("feature")
|
|
91
|
+
rows, totals = [], {"declared": 0, "built": 0, "proven": 0, "failed": 0,
|
|
92
|
+
"todo": 0, "gap": 0, "ahead": 0, "eyes": 0, "unlevelled": 0}
|
|
93
|
+
mismatched = []
|
|
94
|
+
|
|
95
|
+
for md in sorted(scan_features(root)):
|
|
96
|
+
name = md.stem
|
|
97
|
+
if only and name != only:
|
|
98
|
+
continue
|
|
99
|
+
levels = feature_ac_levels(md.read_text())
|
|
100
|
+
declared = set(levels)
|
|
101
|
+
built = {ac for ac, (_, is_built) in levels.items() if is_built}
|
|
102
|
+
if not declared:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
passed = {ac for ac in declared if run.get(f"{name}/{ac}") == "passed"}
|
|
106
|
+
failed = {ac for ac in declared if run.get(f"{name}/{ac}") == "failed"}
|
|
107
|
+
# A criterion someone has DECLARED a test for and not yet written — an
|
|
108
|
+
# `it.todo()` / `test.fixme()` carrying the tag. Its own state on purpose:
|
|
109
|
+
# counting it as proven would be a green check over an empty body, and
|
|
110
|
+
# counting it as failing would bury a real regression under scaffolding.
|
|
111
|
+
todo = {ac for ac in declared if run.get(f"{name}/{ac}") == "todo"}
|
|
112
|
+
# A criterion whose evidence is a PERSON LOOKING is not a coverage hole:
|
|
113
|
+
# no run will ever prove it, so leaving it in the denominator makes a
|
|
114
|
+
# number that can only go up by deleting the promise. It comes out of the
|
|
115
|
+
# ratio and is reported beside it, exactly as the unbuilt roadmap is.
|
|
116
|
+
eyes = {ac for ac in built if levels[ac][0] == "eyes-on"}
|
|
117
|
+
# The two ways the spec and the evidence disagree, and they point opposite
|
|
118
|
+
# ways. `gap` is a promise claimed with nothing to show. `ahead` is a
|
|
119
|
+
# promise a passing test already keeps while the spec still says nobody
|
|
120
|
+
# built it — the doc trailing the code, which no report used to count.
|
|
121
|
+
gap = built - passed - failed - todo - eyes
|
|
122
|
+
ahead = passed - built
|
|
123
|
+
provable = built - eyes
|
|
124
|
+
proven = (built & passed) - eyes
|
|
125
|
+
|
|
126
|
+
for ac in sorted(declared):
|
|
127
|
+
level = levels[ac][0]
|
|
128
|
+
complaint = _wrong_level(level, runners.get(f"{name}/{ac}", set())) if level else None
|
|
129
|
+
if complaint:
|
|
130
|
+
mismatched.append(f"{name}/{ac}: {complaint}")
|
|
131
|
+
|
|
132
|
+
totals["declared"] += len(declared)
|
|
133
|
+
totals["built"] += len(provable)
|
|
134
|
+
totals["proven"] += len(proven)
|
|
135
|
+
totals["failed"] += len(failed)
|
|
136
|
+
totals["todo"] += len(built & todo)
|
|
137
|
+
totals["gap"] += len(gap)
|
|
138
|
+
totals["ahead"] += len(ahead)
|
|
139
|
+
totals["eyes"] += len(eyes)
|
|
140
|
+
totals["unlevelled"] += sum(1 for ac in declared if levels[ac][0] is None)
|
|
141
|
+
rows.append((name, len(built), len(proven), len(eyes),
|
|
142
|
+
len(built & todo), len(declared - built),
|
|
143
|
+
sorted(failed), sorted(gap), sorted(ahead)))
|
|
144
|
+
|
|
145
|
+
print(f"\n read {len(shards)} shard(s): {', '.join(shards)}\n")
|
|
146
|
+
print(f" {'feature':<18} {'built':>6} {'PROVEN':>7} {'eyes-on':>8} {'todo':>5} "
|
|
147
|
+
f"{'gap':>4} {'unbuilt':>8} status")
|
|
148
|
+
print(f" {'-' * 18} {'-' * 6} {'-' * 7} {'-' * 8} {'-' * 5} {'-' * 4} "
|
|
149
|
+
f"{'-' * 8} {'-' * 28}")
|
|
150
|
+
for name, blt, prv, eye, td, unbuilt, failed, gap, ahead in rows:
|
|
151
|
+
note = ""
|
|
152
|
+
if failed:
|
|
153
|
+
note = f"FAILING: {', '.join(failed)}"
|
|
154
|
+
elif gap:
|
|
155
|
+
shown = ", ".join(gap[:4]) + (" …" if len(gap) > 4 else "")
|
|
156
|
+
note = f"claimed, unproven: {shown}"
|
|
157
|
+
elif ahead:
|
|
158
|
+
shown = ", ".join(ahead[:4]) + (" …" if len(ahead) > 4 else "")
|
|
159
|
+
note = f"proven, unticked: {shown}"
|
|
160
|
+
# Both disagreements can hold at once, and the second must not vanish
|
|
161
|
+
# behind the first — it is the one a reader would otherwise never see.
|
|
162
|
+
if gap and ahead:
|
|
163
|
+
note += f" (+{len(ahead)} proven, unticked)"
|
|
164
|
+
print(f" {name:<18} {blt:>6} {prv:>7} {eye:>8} {td:>5} {len(gap):>4} "
|
|
165
|
+
f"{unbuilt:>8} {note}")
|
|
166
|
+
|
|
167
|
+
t = totals
|
|
168
|
+
rate = f"{100 * t['proven'] // t['built']}%" if t["built"] else "—"
|
|
169
|
+
print(f"\n BUILT COVERAGE {t['proven']}/{t['built']} = {rate} "
|
|
170
|
+
f"— of the RUN-PROVABLE promises this app keeps, how many a run proves\n"
|
|
171
|
+
f" {t['gap']} claimed with nothing to show · {t['todo']} declared-but-unwritten · "
|
|
172
|
+
f"{t['failed']} failing\n")
|
|
173
|
+
print(f" Settled by eyes, not by a run: {t['eyes']}. A look is a level, not an\n"
|
|
174
|
+
f" excuse — no assertion is evidence about weight, colour or rhythm — so\n"
|
|
175
|
+
f" these sit beside the ratio with the dated ✔ in the feature file as their\n"
|
|
176
|
+
f" evidence, never inside it.\n")
|
|
177
|
+
print(f" Not built yet: {t['declared'] - t['built']} of {t['declared']} promises. "
|
|
178
|
+
f"That is a roadmap, NOT a coverage hole —\n"
|
|
179
|
+
f" an unticked criterion is behaviour nobody has written, so counting it\n"
|
|
180
|
+
f" against coverage measures ambition rather than honesty.\n")
|
|
181
|
+
if t["gap"]:
|
|
182
|
+
print(" A built promise with no passing bound test is a CLAIM, not evidence.\n"
|
|
183
|
+
" Either bind it with `covers()` / a Playwright tag, or untick it and\n"
|
|
184
|
+
" queue the gap as work.\n")
|
|
185
|
+
if t["ahead"]:
|
|
186
|
+
print(f" {t['ahead']} promise(s) are PROVEN but not ticked — a passing test keeps\n"
|
|
187
|
+
" a promise the spec still says nobody built. Read each against the test\n"
|
|
188
|
+
" that claims it, and tick the ones it genuinely asserts.\n")
|
|
189
|
+
if mismatched:
|
|
190
|
+
print(f" {len(mismatched)} criterion(s) are proven at the WRONG LEVEL — the level\n"
|
|
191
|
+
" each declares, and the level it is actually claimed at, differ:\n")
|
|
192
|
+
for complaint in mismatched:
|
|
193
|
+
print(f" {complaint}")
|
|
194
|
+
print()
|
|
195
|
+
if t["unlevelled"]:
|
|
196
|
+
print(f" {t['unlevelled']} criteria declare no level, so nothing can check the test that\n"
|
|
197
|
+
" claims them — and they count as run-provable by default, which for a\n"
|
|
198
|
+
" look is the wrong default.\n")
|
|
199
|
+
return 0
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from .tree import BUCKETS, EPIC_FM_ORDER, die, find_work_root, rel
|
|
5
|
+
from .frontmatter import rewrite_file
|
|
6
|
+
from .model import epic_home, locate_epic, locate_version
|
|
7
|
+
from .scaffold import _check_covers_ref, _check_kebab, _check_unused, _scaffold_epic
|
|
8
|
+
from .generate import _sync
|
|
9
|
+
from . import events
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_epic_new(args) -> int:
|
|
13
|
+
"""Scaffold an epic — in a version with `--version`, else in the backlog.
|
|
14
|
+
|
|
15
|
+
Backlog is the DEFAULT home on purpose. A version is now a release and is
|
|
16
|
+
expensive to open, so an epic is planned first and pulled into a cut when
|
|
17
|
+
its cut opens (`jarvis work place <epic> --version <v>`)."""
|
|
18
|
+
root = find_work_root()
|
|
19
|
+
name = args["name"]
|
|
20
|
+
_check_kebab("epic", name)
|
|
21
|
+
_check_unused(root, name)
|
|
22
|
+
|
|
23
|
+
covers = [c.strip() for c in (args.get("covers") or "").split(",") if c.strip()]
|
|
24
|
+
for c in covers:
|
|
25
|
+
_check_covers_ref(c)
|
|
26
|
+
owner = (args.get("owner") or "").strip()
|
|
27
|
+
title = args.get("title") or name.replace("-", " ").capitalize()
|
|
28
|
+
|
|
29
|
+
# Lineage. An epic name is globally unique, so work carried into a later cut
|
|
30
|
+
# cannot reuse the name it had — and without this it would arrive looking like
|
|
31
|
+
# a brand new goal that happens to resemble an old one. `continues:` is the one
|
|
32
|
+
# owner of the link; the earlier epic is NOT edited to point forward, because a
|
|
33
|
+
# fact with two writers is a fact that drifts, and this direction is the one
|
|
34
|
+
# that can be written at the moment the split actually happens.
|
|
35
|
+
continues = (args.get("continues") or "").strip()
|
|
36
|
+
if continues:
|
|
37
|
+
earlier = locate_epic(root, continues)
|
|
38
|
+
if not earlier:
|
|
39
|
+
die(f"--continues '{continues}': no epic by that name. It names the "
|
|
40
|
+
f"epic this one carries on from, so it has to be one that exists.")
|
|
41
|
+
if earlier.name == name:
|
|
42
|
+
die("--continues names this epic itself")
|
|
43
|
+
|
|
44
|
+
version_name = args.get("version")
|
|
45
|
+
if version_name:
|
|
46
|
+
version = locate_version(root, version_name)
|
|
47
|
+
if not version:
|
|
48
|
+
die(f"no version named '{version_name}' — create it with: "
|
|
49
|
+
f"jarvis work version-new {version_name} --outcome '…'")
|
|
50
|
+
if version.released:
|
|
51
|
+
die(f"version '{version_name}' is released — an epic is temporary "
|
|
52
|
+
f"and is removed at release; pick a planned version")
|
|
53
|
+
folder = version.folder / name
|
|
54
|
+
home = f"version '{version_name}'"
|
|
55
|
+
else:
|
|
56
|
+
folder = root / "backlog" / name
|
|
57
|
+
home = "backlog"
|
|
58
|
+
|
|
59
|
+
if folder.exists():
|
|
60
|
+
die(f"{rel(folder, root)} already exists")
|
|
61
|
+
_scaffold_epic(folder, title, covers, owner, continues)
|
|
62
|
+
events.append(root, "created", name, tier="epic", home=home)
|
|
63
|
+
print(f"created {rel(folder / 'epic.md', root)} ({home})")
|
|
64
|
+
if continues:
|
|
65
|
+
print(f" continues '{continues}' ({epic_home(root, earlier)}) — "
|
|
66
|
+
f"read its plan before cutting work here; what it settled still "
|
|
67
|
+
f"holds unless this one says otherwise")
|
|
68
|
+
if not covers and not owner:
|
|
69
|
+
print(" NOTE no --covers yet — name the feature AC ids this epic "
|
|
70
|
+
"moves (`<feature>/AC-nn`), or the lint will say so. If it moves "
|
|
71
|
+
"no product criterion at all, --owner <org-domain> is the answer")
|
|
72
|
+
_sync(root)
|
|
73
|
+
return 0
|
|
74
|
+
def _bucketed(epic, name: str):
|
|
75
|
+
"""Where a task named `name` belongs inside `epic` — under a `queue/` bucket
|
|
76
|
+
in a version, directly under the epic in the backlog (a backlog task has no
|
|
77
|
+
status until it is pulled)."""
|
|
78
|
+
return (epic.folder / name) if epic.in_backlog else (epic.folder / "queue" / name)
|
|
79
|
+
def _epic_to_backlog(root, name: str) -> int:
|
|
80
|
+
"""A whole epic back out of a cut. The buckets come off on the way: status
|
|
81
|
+
exists inside a version and does not in the backlog, which is the same reason
|
|
82
|
+
`place` puts them on going the other way."""
|
|
83
|
+
epic = locate_epic(root, name)
|
|
84
|
+
if not epic:
|
|
85
|
+
die(f"no epic named '{name}' found")
|
|
86
|
+
if epic.in_backlog:
|
|
87
|
+
die(f"epic '{name}' is already in the backlog")
|
|
88
|
+
done = [t for t in epic.all_tasks() if t.status == "complete"]
|
|
89
|
+
if done:
|
|
90
|
+
die(f"epic '{name}' has {len(done)} completed task(s) — they shipped in "
|
|
91
|
+
f"'{epic.version}' and stay there, so the epic cannot move whole. "
|
|
92
|
+
f"Move its unfinished tasks out one at a time instead:\n"
|
|
93
|
+
f" jarvis work place <task> --backlog --epic <a backlog epic>")
|
|
94
|
+
|
|
95
|
+
dest = root / "backlog" / name
|
|
96
|
+
if dest.exists():
|
|
97
|
+
die(f"{rel(dest, root)} already exists")
|
|
98
|
+
was = epic_home(root, epic)
|
|
99
|
+
moved = epic.all_tasks()
|
|
100
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
shutil.move(str(epic.folder), str(dest))
|
|
102
|
+
for t in moved:
|
|
103
|
+
bucketed = dest / t.status / t.name
|
|
104
|
+
if bucketed.exists():
|
|
105
|
+
shutil.move(str(bucketed), str(dest / t.name))
|
|
106
|
+
for bucket in BUCKETS:
|
|
107
|
+
empty = dest / bucket
|
|
108
|
+
if empty.is_dir() and not any(empty.iterdir()):
|
|
109
|
+
empty.rmdir()
|
|
110
|
+
events.append(root, "moved", name, tier="epic", **{"from": was, "to": "backlog"})
|
|
111
|
+
print(f"took epic '{name}' out of the cut -> {rel(dest, root)}"
|
|
112
|
+
+ (f" ({len(moved)} task(s))" if moved else ""))
|
|
113
|
+
_sync(root)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_epic_move(args) -> int:
|
|
118
|
+
"""Move a whole epic into a version — the promotion path a backlog epic
|
|
119
|
+
takes when its cut opens. This is `place`'s epic form, dispatched to once
|
|
120
|
+
`place` sees the name is an epic.
|
|
121
|
+
|
|
122
|
+
Tasks are RESTRUCTURED as they go: a backlog epic's tasks sit directly under
|
|
123
|
+
it and gain a `queue/` bucket on arrival, because status exists in a version
|
|
124
|
+
and does not in the backlog."""
|
|
125
|
+
root = find_work_root()
|
|
126
|
+
name = args["name"]
|
|
127
|
+
to_backlog = str(args.get("backlog", "")).lower() == "true"
|
|
128
|
+
version_name = args.get("version")
|
|
129
|
+
if to_backlog:
|
|
130
|
+
return _epic_to_backlog(root, name)
|
|
131
|
+
if not version_name:
|
|
132
|
+
die(f"usage: jarvis work place {name} --version <v> (or --backlog)")
|
|
133
|
+
version = locate_version(root, version_name)
|
|
134
|
+
if not version:
|
|
135
|
+
die(f"no version named '{version_name}'")
|
|
136
|
+
if version.released:
|
|
137
|
+
die(f"version '{version_name}' is released — pick a planned version")
|
|
138
|
+
|
|
139
|
+
epic = locate_epic(root, name)
|
|
140
|
+
if not epic:
|
|
141
|
+
die(f"no epic named '{name}' found")
|
|
142
|
+
if not epic.in_backlog and epic.version == version_name:
|
|
143
|
+
die(f"epic '{name}' is already in version '{version_name}'")
|
|
144
|
+
|
|
145
|
+
# An epic that has already shipped work cannot move whole: the completed tasks
|
|
146
|
+
# are the record of what THAT release delivered, and carrying them into another
|
|
147
|
+
# cut would make both versions describe work neither of them contained. Its
|
|
148
|
+
# unfinished half moves task by task into a continuation epic instead, which is
|
|
149
|
+
# what `--continues` is for.
|
|
150
|
+
done = [t for t in epic.all_tasks() if t.status == "complete"]
|
|
151
|
+
if done and not epic.in_backlog:
|
|
152
|
+
movable = [t for t in epic.all_tasks() if t.status != "complete"]
|
|
153
|
+
die(f"epic '{name}' has {len(done)} completed task(s) — they shipped in "
|
|
154
|
+
f"'{epic.version}' and stay there, so the epic cannot move whole. Make "
|
|
155
|
+
f"a continuation in the new cut and move the rest across:\n"
|
|
156
|
+
f" jarvis work epic-new {name}-next --version {version_name} "
|
|
157
|
+
f"--continues {name}\n"
|
|
158
|
+
f" jarvis work place <task> --version {version_name} "
|
|
159
|
+
f"--epic {name}-next\n"
|
|
160
|
+
f" ({len(movable)} task(s) would move: "
|
|
161
|
+
f"{', '.join(t.name for t in movable[:4])}"
|
|
162
|
+
f"{' …' if len(movable) > 4 else ''})")
|
|
163
|
+
|
|
164
|
+
dest = version.folder / name
|
|
165
|
+
if dest.exists():
|
|
166
|
+
die(f"{rel(dest, root)} already exists")
|
|
167
|
+
was = epic_home(root, epic)
|
|
168
|
+
moved = epic.all_tasks()
|
|
169
|
+
shutil.move(str(epic.folder), str(dest))
|
|
170
|
+
|
|
171
|
+
# A backlog epic's tasks were loose under it; give them the queue bucket.
|
|
172
|
+
if epic.in_backlog:
|
|
173
|
+
queue = dest / "queue"
|
|
174
|
+
queue.mkdir(exist_ok=True)
|
|
175
|
+
for t in moved:
|
|
176
|
+
shutil.move(str(dest / t.name), str(queue / t.name))
|
|
177
|
+
|
|
178
|
+
md = dest / "epic.md"
|
|
179
|
+
rewrite_file(
|
|
180
|
+
md,
|
|
181
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
182
|
+
EPIC_FM_ORDER,
|
|
183
|
+
)
|
|
184
|
+
print(f"pulled epic '{name}' ({was}) -> {rel(dest, root)} "
|
|
185
|
+
f"with {len(moved)} task(s)")
|
|
186
|
+
_sync(root)
|
|
187
|
+
return 0
|
|
188
|
+
def cmd_epic_release(root, version) -> int:
|
|
189
|
+
"""Remove every `epic.md` in a released version, and report it.
|
|
190
|
+
|
|
191
|
+
An epic is TEMPORARY by definition: it is the plan-it-together doc, and how
|
|
192
|
+
the work was planned stops being true the moment it ships. The folder stays
|
|
193
|
+
as the grouping of what shipped — that record is worth keeping — and git
|
|
194
|
+
holds the plan. Called from `cmd_release`, never on its own."""
|
|
195
|
+
removed = []
|
|
196
|
+
for e in version.epics:
|
|
197
|
+
e.md.unlink()
|
|
198
|
+
removed.append(e.name)
|
|
199
|
+
if removed:
|
|
200
|
+
print(f" archived {len(removed)} epic(s) — epic.md removed, "
|
|
201
|
+
f"folders kept as the grouping: {', '.join(removed)}")
|
|
202
|
+
return len(removed)
|
|
203
|
+
def epic_for_task(root, args, version_name):
|
|
204
|
+
"""Resolve the `--epic` a task command names, and check it agrees with any
|
|
205
|
+
`--version` also given. Returns (epic, version_name) — the version is
|
|
206
|
+
DERIVED from the epic's home, so the two can never disagree silently."""
|
|
207
|
+
epic_name = (args.get("epic") or "").strip()
|
|
208
|
+
if not epic_name:
|
|
209
|
+
return None, version_name
|
|
210
|
+
epic = locate_epic(root, epic_name)
|
|
211
|
+
if not epic:
|
|
212
|
+
die(f"no epic named '{epic_name}' — create it with: "
|
|
213
|
+
f"jarvis work epic-new {epic_name}"
|
|
214
|
+
+ (f" --version {version_name}" if version_name else ""))
|
|
215
|
+
home = None if epic.in_backlog else epic.version
|
|
216
|
+
if version_name and version_name != home:
|
|
217
|
+
die(f"--epic {epic_name} lives in {epic_home(root, epic)}, but you asked "
|
|
218
|
+
f"for version '{version_name}' — drop --version, or "
|
|
219
|
+
f"`jarvis work place {epic_name} --version {version_name}` first")
|
|
220
|
+
return epic, home
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The record of what the harness DID — a file, or the commits themselves.
|
|
2
|
+
|
|
3
|
+
**History, never state.** Nothing in the harness reads this record to decide what is
|
|
4
|
+
true now: status is the bucket, a version's status is derived from its tasks, a
|
|
5
|
+
rule lives in the domain that owns it. That line is the whole design, and blurring
|
|
6
|
+
it would rebuild the central ledger `P-1` dissolved — one file that answers
|
|
7
|
+
everything, which is one file to disagree with the tree.
|
|
8
|
+
|
|
9
|
+
**Two backends, and which one is the repo's call.** With `git.commit` off — the
|
|
10
|
+
default, and what every consumer that has not asked for anything else gets — the
|
|
11
|
+
record is `work/.events.jsonl`, exactly as described below. With it on, a board
|
|
12
|
+
write commits, and the commit's own trailers ARE the record: `append` hands the
|
|
13
|
+
event to that commit instead of a file, and `read` asks `git log`. A repo that
|
|
14
|
+
writes to git therefore has no `.events.jsonl` at all, which is the point — that
|
|
15
|
+
file records a session id rather than a machine or a branch, and it appends at the
|
|
16
|
+
end, so two active branches conflict on it every time.
|
|
17
|
+
|
|
18
|
+
Two kinds of event never reach git. A **hold** (`claimed`, `released-claim`) is
|
|
19
|
+
coordination, and coordination that replicates is two machines holding one item with
|
|
20
|
+
neither knowing. A **refusal** changed nothing, so there is no commit for it to ride
|
|
21
|
+
on and no board history for it to be part of; `status` loses its "refused twice"
|
|
22
|
+
warning under git, which is a deliberate trade rather than an oversight.
|
|
23
|
+
|
|
24
|
+
What it buys is the thing a tree cannot hold: *time*. A tree remembers only now, so
|
|
25
|
+
it cannot say what shipped overnight, how long something sat blocked, or which
|
|
26
|
+
questions a shift asked. Under a person those answers lived in the conversation.
|
|
27
|
+
With nobody in the chair the conversation is gone by morning, and a board that looks
|
|
28
|
+
identical whether the night shift did six tasks or crashed after one is a board
|
|
29
|
+
nobody can trust.
|
|
30
|
+
|
|
31
|
+
Committed, not gitignored: an audit trail nobody can diff is not an audit trail. It
|
|
32
|
+
stays append-only in practice because a rewrite shows up as a diff that deletes
|
|
33
|
+
lines — which is exactly the review this file is for.
|
|
34
|
+
|
|
35
|
+
A corrupt line is skipped, never fatal. This file must not be able to break the
|
|
36
|
+
board — it is a record of work, not a dependency of it.
|
|
37
|
+
"""
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from . import git
|
|
44
|
+
|
|
45
|
+
LOG = ".events.jsonl"
|
|
46
|
+
|
|
47
|
+
#: Events about a hold rather than about the board. They never enter git — not as a
|
|
48
|
+
#: file, not as a trailer — because a claim belongs to a machine and a moment, and a
|
|
49
|
+
#: replicated one is a claim two endpoints both believe they have.
|
|
50
|
+
HOLDS = ("claimed", "released-claim")
|
|
51
|
+
|
|
52
|
+
#: What this command has done so far, waiting for the commit that carries it. Only
|
|
53
|
+
#: used under git; a process runs one command, so one buffer is one commit.
|
|
54
|
+
_PENDING: list = []
|
|
55
|
+
|
|
56
|
+
#: Every event the harness appends. Closed on purpose: a typo'd event name is a
|
|
57
|
+
#: line that never shows up in a digest, which is the silent failure this list
|
|
58
|
+
#: exists to prevent.
|
|
59
|
+
KINDS = (
|
|
60
|
+
"created", # a task, epic, feature or version was scaffolded
|
|
61
|
+
"moved", # bucket -> bucket
|
|
62
|
+
"blocked", # parked, waiting on a human
|
|
63
|
+
"asked", # a question was filed against a task
|
|
64
|
+
"answered", # and resolved
|
|
65
|
+
"claimed", # an instance leased a task
|
|
66
|
+
"released-claim", # ...and gave it back, or the lease expired
|
|
67
|
+
"verified", # verify.* ran — with its exit codes
|
|
68
|
+
"observed", # an eyes-on record against an AC
|
|
69
|
+
"gate-refused", # completion was refused, and why
|
|
70
|
+
"completed", # a task landed
|
|
71
|
+
"released", # a version shipped
|
|
72
|
+
"archived", # ...and left the board
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _now() -> str:
|
|
77
|
+
"""UTC, second precision. Instances may run on different machines, so a local
|
|
78
|
+
timestamp would make the ordering of a shift unreadable."""
|
|
79
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def instance_id() -> str:
|
|
83
|
+
"""Who is acting. `WORK_INSTANCE` names a scheduled shift; otherwise the Claude
|
|
84
|
+
Code session. Empty is fine and means a human at a terminal — recording that
|
|
85
|
+
honestly is better than inventing an id for it."""
|
|
86
|
+
return (os.environ.get("WORK_INSTANCE")
|
|
87
|
+
or os.environ.get("CLAUDE_CODE_SESSION_ID", "")).strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def append(root: Path, kind: str, name: str, **fields) -> None:
|
|
91
|
+
"""Append one line. Never raises: a failure to record must not fail the work
|
|
92
|
+
that was recorded — the mutation already happened, and dying here would leave
|
|
93
|
+
the tree changed and the caller told it failed."""
|
|
94
|
+
if kind not in KINDS:
|
|
95
|
+
raise ValueError(f"unknown event kind '{kind}' — one of {', '.join(KINDS)}")
|
|
96
|
+
entry = {"ts": _now(), "event": kind, "name": name}
|
|
97
|
+
who = instance_id()
|
|
98
|
+
if who:
|
|
99
|
+
entry["by"] = who
|
|
100
|
+
# Fields the caller passes are the event's own payload — never a copy of
|
|
101
|
+
# something the tree owns. A `status` here would be a second writer for a fact
|
|
102
|
+
# the bucket already states.
|
|
103
|
+
entry.update({k: v for k, v in fields.items() if v not in (None, "", [], {})})
|
|
104
|
+
if git.enabled():
|
|
105
|
+
if kind not in HOLDS:
|
|
106
|
+
_PENDING.append(entry)
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
with (root / LOG).open("a", encoding="utf-8") as fh:
|
|
110
|
+
fh.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def pending() -> list:
|
|
116
|
+
"""The events this command produced, for the commit that is about to carry them.
|
|
117
|
+
Draining is the caller's business — it runs once, at the end."""
|
|
118
|
+
return list(_PENDING)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read(root: Path, since: str = "", name: str = "") -> list:
|
|
122
|
+
"""Every event, oldest first. A malformed line is skipped silently — a
|
|
123
|
+
half-written record from a killed process must not break `digest`."""
|
|
124
|
+
if git.enabled():
|
|
125
|
+
return git.read(root.parent, since=since, name=name)
|
|
126
|
+
p = root / LOG
|
|
127
|
+
if not p.is_file():
|
|
128
|
+
return []
|
|
129
|
+
out = []
|
|
130
|
+
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if not line:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
e = json.loads(line)
|
|
136
|
+
except (json.JSONDecodeError, ValueError):
|
|
137
|
+
continue
|
|
138
|
+
if not isinstance(e, dict) or "event" not in e:
|
|
139
|
+
continue
|
|
140
|
+
if since and str(e.get("ts", "")) < since:
|
|
141
|
+
continue
|
|
142
|
+
if name and e.get("name") != name:
|
|
143
|
+
continue
|
|
144
|
+
out.append(e)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def last(root: Path, kind: str, name: str):
|
|
149
|
+
"""The most recent event of one kind for one name, or None. Used by the
|
|
150
|
+
completion gate to ask 'did verify actually run, and when' — a question about
|
|
151
|
+
history, which is what this file is for."""
|
|
152
|
+
hits = [e for e in read(root, name=name) if e.get("event") == kind]
|
|
153
|
+
return hits[-1] if hits else None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Hook extension — how a repo says something at a point the harness owns.
|
|
2
|
+
|
|
3
|
+
The plugin owns two hook points: what a fresh session is told (`SessionStart`)
|
|
4
|
+
and when a session is told to finish (`Stop`). Both are declared in the plugin's
|
|
5
|
+
own `hooks.json`, so a consumer cannot change the declaration — and should not
|
|
6
|
+
have to. What a consumer needs is to change what is SAID there, and that is what
|
|
7
|
+
this is: a repo names commands, the harness runs them, and their lines are folded
|
|
8
|
+
into its own message.
|
|
9
|
+
|
|
10
|
+
**Extend, replace and off are one mechanic, not three.** `enabled: false` silences
|
|
11
|
+
the harness's own lines; `extend` adds the repo's. Together they are a replacement,
|
|
12
|
+
`extend` alone is an addition, and `enabled: false` alone is off. A separate
|
|
13
|
+
`replace:` key would be a third spelling of the same two facts.
|
|
14
|
+
|
|
15
|
+
**These commands are EXECUTED, so this file is a security surface.** The rules are
|
|
16
|
+
`verify.*`'s, for the same reason: split with `shlex`, run with `shell=False`, and
|
|
17
|
+
never interpolated — a repo may run anything it likes, but what it may not do is
|
|
18
|
+
get a `;` to mean `;`. The config loader refuses shell metacharacters at load,
|
|
19
|
+
where it is one message, rather than here at the end of somebody's turn.
|
|
20
|
+
|
|
21
|
+
**A broken extension is reported, never silent.** A hook that swallows a failure
|
|
22
|
+
teaches its owner that it is working. One line saying which command failed costs
|
|
23
|
+
nothing and is the only way anybody finds out.
|
|
24
|
+
"""
|
|
25
|
+
import shlex
|
|
26
|
+
import subprocess
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
#: How long an extension may take. A `SessionStart` hook runs BEFORE the user can
|
|
30
|
+
#: type, so this is a budget for somebody's first impression of the repo, not for
|
|
31
|
+
#: the command's convenience. Short, fixed, and not configurable: a repo that
|
|
32
|
+
#: needs longer than this at session start needs a different design, and a config
|
|
33
|
+
#: key would only let one land in the tree and make every session pay for it.
|
|
34
|
+
TIMEOUT_SECONDS = 5
|
|
35
|
+
|
|
36
|
+
#: A runaway extension must not become the session's whole context. Anything past
|
|
37
|
+
#: this is dropped, and the drop is announced — a truncated list that says it is
|
|
38
|
+
#: truncated is usable, one that pretends to be complete is not.
|
|
39
|
+
MAX_LINES = 40
|
|
40
|
+
MAX_CHARS = 4000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(commands, repo) -> list:
|
|
44
|
+
"""Run each command in `repo` and return the lines they printed.
|
|
45
|
+
|
|
46
|
+
Every failure path yields a line SAYING what failed rather than raising: this
|
|
47
|
+
is called from hooks, and a hook that raises greets somebody with a traceback
|
|
48
|
+
at the moment they were trying to start work.
|
|
49
|
+
"""
|
|
50
|
+
out = []
|
|
51
|
+
for command in commands or []:
|
|
52
|
+
try:
|
|
53
|
+
argv = shlex.split(command)
|
|
54
|
+
except ValueError:
|
|
55
|
+
out.append(f"extension {command!r} is not a runnable command line — "
|
|
56
|
+
f"unbalanced quotes; nothing was run.")
|
|
57
|
+
continue
|
|
58
|
+
if not argv:
|
|
59
|
+
continue
|
|
60
|
+
if not Path(repo).is_dir():
|
|
61
|
+
out.append(f"extension `{argv[0]}` was not run — {repo} is not a "
|
|
62
|
+
f"directory to run it in.")
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
done = subprocess.run(argv, cwd=str(repo), capture_output=True,
|
|
66
|
+
text=True, timeout=TIMEOUT_SECONDS)
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
out.append(f"extension `{argv[0]}` is not installed here — its lines "
|
|
69
|
+
f"are missing from this list.")
|
|
70
|
+
continue
|
|
71
|
+
except subprocess.TimeoutExpired:
|
|
72
|
+
out.append(f"extension `{argv[0]}` took longer than "
|
|
73
|
+
f"{TIMEOUT_SECONDS}s and was stopped — its lines are "
|
|
74
|
+
f"missing from this list.")
|
|
75
|
+
continue
|
|
76
|
+
except OSError as e:
|
|
77
|
+
out.append(f"extension `{argv[0]}` could not run ({e.strerror}) — its "
|
|
78
|
+
f"lines are missing from this list.")
|
|
79
|
+
continue
|
|
80
|
+
if done.returncode != 0:
|
|
81
|
+
# The command's own stderr, first line only. It knows why it failed
|
|
82
|
+
# and this hook does not, so quoting it beats paraphrasing it — and a
|
|
83
|
+
# whole stack trace at session start helps nobody.
|
|
84
|
+
why = (done.stderr or "").strip().splitlines()
|
|
85
|
+
out.append(f"extension `{argv[0]}` failed (exit {done.returncode})"
|
|
86
|
+
+ (f": {why[0]}" if why else "")
|
|
87
|
+
+ " — its lines are missing from this list.")
|
|
88
|
+
continue
|
|
89
|
+
lines = [ln.strip() for ln in (done.stdout or "").splitlines() if ln.strip()]
|
|
90
|
+
kept, chars = [], 0
|
|
91
|
+
for line in lines:
|
|
92
|
+
if len(kept) >= MAX_LINES or chars + len(line) > MAX_CHARS:
|
|
93
|
+
kept.append(f"… `{argv[0]}` printed more and was cut off here "
|
|
94
|
+
f"({len(lines)} lines).")
|
|
95
|
+
break
|
|
96
|
+
kept.append(line)
|
|
97
|
+
chars += len(line)
|
|
98
|
+
out += kept
|
|
99
|
+
return out
|