@appchy/jarvis 0.1.36 → 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.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10104 -37174
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38718
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. 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