@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,591 @@
1
+ """Completion is a run, not a claim.
2
+
3
+ Two holes closed here, and unattended they are the same hole. `verify.*` named this
4
+ repo's test and lint commands and was *printed, never executed* — a session was
5
+ **told** to run them, which under a person is a reasonable division of labour and
6
+ with nobody watching means "sometimes". And `move <name> complete` only **warned**
7
+ on unchecked acceptance criteria — a line of output in a run nobody reads until
8
+ morning, by which point the task is in `complete/` and the board says done.
9
+
10
+ So an unattended instance could mark work complete having run nothing and proven
11
+ nothing, and the tree would agree with it. That one fact makes every other number
12
+ the harness reports unusable, which is the thing an autonomous system cannot
13
+ afford.
14
+
15
+ `verify` now EXECUTES — and that changes `verify.*` from a printed string into a
16
+ value that reaches execution, so `P-7` applies to it: the command is split with
17
+ `shlex` and run with `shell=False`, never interpolated into a shell. A repo can
18
+ still put anything it likes in its own config; what it cannot do is get a `;` to
19
+ mean `;`.
20
+
21
+ The gate refuses rather than warns. `--accept` exists for the case a human
22
+ genuinely takes the risk, and it is an EVENT with an owner's name on it — never a
23
+ silent flag.
24
+ """
25
+ import json
26
+ import os
27
+ import re
28
+ import shlex
29
+ import subprocess
30
+ import sys
31
+ from datetime import date, datetime, timezone
32
+ from pathlib import Path
33
+
34
+ from .tree import die, find_work_root
35
+ from .frontmatter import as_list, parse_frontmatter, rewrite_file, split_frontmatter
36
+ from .model import locate
37
+ from .shard import _load_run
38
+ from . import events, git, peers
39
+ from .autonomy import tier_of
40
+
41
+ #: `verify.*` from config — bound by `config.apply`, same as everywhere else.
42
+ VERIFY: dict = {}
43
+
44
+ #: The run in flight, beside the board it is proving. Never enters git — it names a
45
+ #: pid on one machine at one moment, exactly like `.claim`, and `git.py` drops it by
46
+ #: filename for the same reason.
47
+ RUN = ".verify"
48
+
49
+
50
+ def _head(repo) -> str:
51
+ """The commit the evidence belongs to. Empty when this is not a git repo,
52
+ which is a supported mode: the gate then falls back to "verified today"."""
53
+ try:
54
+ out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo,
55
+ capture_output=True, text=True, timeout=10)
56
+ return out.stdout.strip() if out.returncode == 0 else ""
57
+ except (OSError, subprocess.SubprocessError):
58
+ return ""
59
+
60
+
61
+ def _now():
62
+ return datetime.now(timezone.utc).replace(microsecond=0)
63
+
64
+
65
+ def _run_file(root) -> Path:
66
+ return root / RUN
67
+
68
+
69
+ def _alive(pid) -> bool:
70
+ """Whether the process that started a run is still there.
71
+
72
+ Liveness is the PID and never a clock. A lease would have to guess how long a
73
+ verify takes, and this repo's own set spans 174 seconds warm and far more cold —
74
+ a guess short enough to be useful would strand a real run, and one long enough to
75
+ be safe would not guard anything.
76
+ """
77
+ try:
78
+ os.kill(int(pid), 0)
79
+ except (OSError, TypeError, ValueError):
80
+ return False
81
+ return True
82
+
83
+
84
+ def read_run(root):
85
+ """The verify run in flight, or None.
86
+
87
+ An unreadable or half-written file is treated as absent, for the reason a claim
88
+ is: a run nobody can parse must not be able to block every future verify in the
89
+ checkout. Failing closed here would turn one corrupt file into a repo that can
90
+ never prove anything again.
91
+ """
92
+ p = _run_file(root)
93
+ if not p.is_file():
94
+ return None
95
+ try:
96
+ data = json.loads(p.read_text())
97
+ except (OSError, ValueError, json.JSONDecodeError):
98
+ return None
99
+ return data if isinstance(data, dict) else None
100
+
101
+
102
+ def live_run(root):
103
+ """The run that is ACTUALLY executing right now, or None — the guard's one
104
+ question. A finished run stays on disk so its result can be read back; it is not
105
+ live, and it does not stop the next one."""
106
+ data = read_run(root)
107
+ if not data or data.get("finished"):
108
+ return None
109
+ return data if _alive(data.get("pid")) else None
110
+
111
+
112
+ def _write_run(root, data) -> None:
113
+ """Write the run file atomically.
114
+
115
+ A reader polls this while a writer updates it after every gate, so a torn read is
116
+ not a rare case — it is the normal case done at the wrong instant. `read_run`
117
+ tolerates one, and this makes them not happen.
118
+ """
119
+ p = _run_file(root)
120
+ tmp = p.with_suffix(".tmp")
121
+ try:
122
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
123
+ os.replace(tmp, p)
124
+ except OSError:
125
+ pass
126
+
127
+
128
+ def _elapsed(data) -> int:
129
+ try:
130
+ return max(0, int((_now() - datetime.fromisoformat(str(data["started"]))).total_seconds()))
131
+ except (KeyError, ValueError, TypeError):
132
+ return 0
133
+
134
+
135
+ def run_verify(root, names=None, progress=None) -> tuple:
136
+ """Run the configured verify commands. Returns (results, ok).
137
+
138
+ A SKIP is reported separately and never counts as a pass — "there was no lint
139
+ configured" and "lint passed" are different facts, and collapsing them is how a
140
+ gate quietly stops gating.
141
+
142
+ `progress` is called with (gate about to run, results so far) before each one, so
143
+ a detached run can say where it has got to. It is optional because the blocking
144
+ door has a terminal to print at and needs nothing recorded.
145
+ """
146
+ repo = root.parent
147
+ results = []
148
+ for name, cmd in sorted(VERIFY.items()):
149
+ if names and name not in names:
150
+ continue
151
+ if progress:
152
+ progress(name, results)
153
+ try:
154
+ argv = shlex.split(cmd)
155
+ except ValueError as e:
156
+ results.append((name, "SKIP", f"unparseable command: {e}"))
157
+ continue
158
+ if not argv:
159
+ results.append((name, "SKIP", "empty command"))
160
+ continue
161
+ try:
162
+ proc = subprocess.run(argv, cwd=repo, capture_output=True, text=True,
163
+ timeout=1800)
164
+ except FileNotFoundError:
165
+ results.append((name, "SKIP", f"{argv[0]}: not found"))
166
+ continue
167
+ except subprocess.TimeoutExpired:
168
+ results.append((name, "FAIL", "timed out after 30m"))
169
+ continue
170
+ except OSError as e:
171
+ results.append((name, "SKIP", str(e)))
172
+ continue
173
+ tail = (proc.stderr or proc.stdout or "").strip().splitlines()
174
+ results.append((name, "PASS" if proc.returncode == 0 else "FAIL",
175
+ tail[-1][:160] if tail else f"exit {proc.returncode}"))
176
+ ok = bool(results) and all(r[1] == "PASS" for r in results)
177
+ return results, ok
178
+
179
+
180
+ def _record(root, name, results, ok, sha) -> bool:
181
+ """Write the outcome onto the task. False when there is no such task."""
182
+ task = locate(root, name)
183
+ if not task:
184
+ return False
185
+ entry = (f"{date.today().isoformat()} {'pass' if ok else 'FAIL'} "
186
+ f"{sha[:12] or 'no-git'} "
187
+ + " ".join(f"{n}={s}" for n, s, _ in results))
188
+
189
+ def mutate(d):
190
+ d["verified"] = as_list(d.get("verified")) + [entry]
191
+ d["updated"] = date.today().isoformat()
192
+
193
+ rewrite_file(task.folder / "task.md", mutate)
194
+ events.append(root, "verified", name, ok=ok, sha=sha[:12] or None,
195
+ results={n: s for n, s, _ in results})
196
+ return True
197
+
198
+
199
+ def _execute(root, name) -> tuple:
200
+ """Run the gates, tracking them in the run file, and record the outcome.
201
+
202
+ **Both doors go through here**, which is what makes the guard mean anything: a
203
+ blocking run that wrote no run file would be invisible to the next caller, and
204
+ the overlap this exists to prevent is exactly the one nobody declared.
205
+ """
206
+ data = {
207
+ "task": name,
208
+ "pid": os.getpid(),
209
+ "machine": peers.here(),
210
+ "started": _now().isoformat(),
211
+ "gates": sorted(VERIFY),
212
+ "current": "",
213
+ "done": [],
214
+ "finished": None,
215
+ "passed": False,
216
+ "sha": "",
217
+ "results": [],
218
+ # Whether a finished result has already been handed to a caller. Reading one
219
+ # is what retires it: an agent that fixed something and asked again wants a
220
+ # fresh run, not the answer to the question it asked before the fix.
221
+ "read": False,
222
+ }
223
+ _write_run(root, data)
224
+
225
+ def progress(gate, so_far):
226
+ data["current"] = gate
227
+ data["done"] = [n for n, _, _ in so_far]
228
+ _write_run(root, data)
229
+
230
+ results, ok = run_verify(root, progress=progress)
231
+ sha = _head(root.parent)
232
+ data.update(current="", done=[n for n, _, _ in results],
233
+ finished=_now().isoformat(), passed=ok, sha=sha,
234
+ results=[{"name": n, "status": s, "said": d} for n, s, d in results])
235
+ _write_run(root, data)
236
+
237
+ if name and not _record(root, name, results, ok, sha):
238
+ die(f"no task named '{name}' found")
239
+ return results, ok, sha
240
+
241
+
242
+ def _print_report(results, sha) -> None:
243
+ for n, status, detail in results:
244
+ mark = {"PASS": "✓", "FAIL": "✗"}.get(status, "·")
245
+ print(f" {mark} {status:5} {n:14} {detail}")
246
+ print(f"\n {sum(1 for r in results if r[1] == 'PASS')}/{len(results)} passed"
247
+ + (f" · at {sha[:8]}" if sha else ""))
248
+
249
+
250
+ def _busy(running) -> str:
251
+ """Why a caller cannot start a verify, in the words the refused caller needs.
252
+
253
+ Never phrased as a failure. Two runs in one checkout corrupt each other's
254
+ result — one gate's build cleans the directory another's log check is about to
255
+ read — and the message a person meets has to say *that*, because the alternative
256
+ reads as a broken build and teaches them to re-run until it goes green.
257
+ """
258
+ return (f"a verify is already running here — '{running.get('task') or 'no task'}' "
259
+ f"on {running.get('machine') or 'this machine'}, "
260
+ f"{_elapsed(running)}s ago (pid {running.get('pid')}). "
261
+ f"Two at once corrupt each other's result, so this one did not start. "
262
+ f"Wait for it, or ask again.")
263
+
264
+
265
+ def _describe(data) -> str:
266
+ """A finished run, as one line a model can act on. The gate's OWN words, per
267
+ gate — a caller told `6/7` has to leave the surface to find out which."""
268
+ results = data.get("results") or []
269
+ failed = [r for r in results if r.get("status") != "PASS"]
270
+ sha = (data.get("sha") or "")[:8]
271
+ if data.get("passed"):
272
+ return f"All {len(results)} gates passed{f' at {sha}' if sha else ''}."
273
+ head = (f"{len(failed)} of {len(results)} gates did not pass"
274
+ f"{f' at {sha}' if sha else ''} — fix these, then ask again:")
275
+ return "\n".join([head] + [f" {r.get('name')}: {r.get('said') or 'no output'}"
276
+ for r in failed])
277
+
278
+
279
+ def _still_current(repo, sha: str) -> bool:
280
+ """Whether a result proven at `sha` still describes the code in front of us.
281
+
282
+ The same question the completion gate asks, answered the same way — a board
283
+ commit is not the code moving. Two answers to "is this evidence current" is
284
+ exactly the second opinion every write here exists to avoid.
285
+
286
+ Outside git there is no sha to pin anything to, and a result is current because
287
+ nothing can be shown to have changed. The gate's own day-old check still applies
288
+ when the evidence is actually used.
289
+ """
290
+ head = _head(repo)
291
+ if not head or not sha or sha == "no-git":
292
+ return True
293
+ return head.startswith(sha) or sha.startswith(head) or git.only_board_moved(repo, sha)
294
+
295
+
296
+ def _answer(state, message, data=None) -> int:
297
+ """One JSON object on stdout, and nothing else.
298
+
299
+ **`--async` speaks JSON because its caller is a program.** The blocking door
300
+ prints a table for the person reading it; this one is what a tool relays, and
301
+ handing prose to a parser would mean the surface's answer depended on a
302
+ sentence's wording. `message` rides inside it so the human line and the
303
+ structured payload are the same computation rather than two.
304
+ """
305
+ out = {"state": state, "gate": "", "elapsedSeconds": 0, "gates": [], "done": [],
306
+ "passed": False, "commit": "", "results": [], "message": message}
307
+ out.update(data or {})
308
+ print(json.dumps(out))
309
+ return 0
310
+
311
+
312
+ def _results_out(data) -> list:
313
+ return [{"name": r.get("name", ""), "passed": r.get("status") == "PASS",
314
+ "skipped": r.get("status") == "SKIP", "said": r.get("said") or ""}
315
+ for r in (data.get("results") or [])]
316
+
317
+
318
+ def _verify_async(root, name) -> int:
319
+ """Start the gates, or say where the run already in flight has got to.
320
+
321
+ One call with three answers, because a verify takes minutes and a relayed tool
322
+ call cannot. Which answer comes back is never the caller's to choose: it is
323
+ whatever is true about this checkout right now.
324
+ """
325
+ running = live_run(root)
326
+ if running:
327
+ gate = running.get("current") or "starting"
328
+ done, gates = running.get("done") or [], running.get("gates") or []
329
+ for_task = running.get("task") or ""
330
+ whose = "" if for_task == name else f" (started for '{for_task or 'no task'}')"
331
+ return _answer(
332
+ "running",
333
+ f"running {_elapsed(running)}s: {gate}. {len(done)} of {len(gates)} done.{whose}",
334
+ {"gate": gate, "elapsedSeconds": _elapsed(running), "gates": gates,
335
+ "done": done, "results": _results_out(running)})
336
+
337
+ done = read_run(root)
338
+ # A finished run answers for the commit it ran at and no other. Once the CODE
339
+ # moves, or once its answer has been read, it is history — and the next ask is a
340
+ # new run rather than yesterday's verdict handed over again.
341
+ #
342
+ # "Moved" is the completion gate's own definition, not string equality, and the
343
+ # difference is not academic: recording a result is itself a board commit, so a
344
+ # run in a repo with `git.commit` on always ends at a different HEAD than it
345
+ # started. Comparing shas directly meant every finished run looked stale to the
346
+ # very caller waiting for it, and `--async` answered "started" forever.
347
+ if (done and done.get("finished") and not done.get("read")
348
+ and done.get("task") == name
349
+ and _still_current(root.parent, done.get("sha") or "")):
350
+ done["read"] = True
351
+ _write_run(root, done)
352
+ return _answer(
353
+ "finished", _describe(done),
354
+ {"elapsedSeconds": _elapsed(done), "gates": done.get("gates") or [],
355
+ "done": done.get("done") or [], "passed": bool(done.get("passed")),
356
+ "commit": done.get("sha") or "", "results": _results_out(done)})
357
+
358
+ child = subprocess.Popen(
359
+ [sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
360
+ "--detached"],
361
+ cwd=root.parent, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
362
+ stderr=subprocess.DEVNULL, start_new_session=True)
363
+ return _answer(
364
+ "started",
365
+ f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
366
+ f"minutes, not seconds — ask again for progress, and again for the result.",
367
+ {"gates": sorted(VERIFY)})
368
+
369
+
370
+ def cmd_verify(args) -> int:
371
+ """Execute the verify commands and RECORD the result against the task.
372
+
373
+ Three doors onto one run. Blocking is the person's and is unchanged. `--async`
374
+ is the tool surface's, because a call that takes 174 seconds cannot be a relayed
375
+ round trip. `--detached` is the child the second one starts and is nobody's to
376
+ type.
377
+ """
378
+ root = find_work_root()
379
+ name = (args.get("task") or "").strip()
380
+ if not VERIFY:
381
+ print("no `verify` commands configured — set them in "
382
+ ".claude/work.config.json so completion has something to prove "
383
+ "against. The gate treats an unconfigured repo as unproven.")
384
+ return 1
385
+
386
+ if args.get("detached"):
387
+ _execute(root, name)
388
+ return 0
389
+
390
+ running = live_run(root)
391
+ if running:
392
+ if args.get("async"):
393
+ return _verify_async(root, name)
394
+ print(f"error: {_busy(running)}", file=sys.stderr)
395
+ return 1
396
+
397
+ if args.get("async"):
398
+ return _verify_async(root, name)
399
+
400
+ results, ok, sha = _execute(root, name)
401
+ _print_report(results, sha)
402
+ if name:
403
+ print(f" recorded on '{name}'")
404
+ return 0 if ok else 1
405
+
406
+
407
+ def cmd_observed(args) -> int:
408
+ """Record an eyes-on: what was actually SEEN, against one criterion.
409
+
410
+ Not a checkbox. The whole reason this exists is that a checkbox records that
411
+ someone claimed something, and a sentence records what they claimed — and only
412
+ the second one can be disagreed with later.
413
+ """
414
+ root = find_work_root()
415
+ name = args["name"]
416
+ ac = (args.get("ac") or "").strip()
417
+ saw = (args.get("saw") or "").strip()
418
+ if not ac or not saw:
419
+ die("usage: jarvis work observed <task> --ac AC-01 --saw \"what you actually saw\"")
420
+ if not re.match(r"^AC-\d+$", ac):
421
+ die(f"--ac must look like 'AC-01' (got '{ac}')")
422
+ task = locate(root, name)
423
+ if not task:
424
+ die(f"no task named '{name}' found")
425
+ entry = f"{date.today().isoformat()} {ac} {saw}"
426
+ md = task.folder / "task.md"
427
+
428
+ def mutate(d):
429
+ d["observed"] = as_list(d.get("observed")) + [entry]
430
+ d["updated"] = date.today().isoformat()
431
+
432
+ rewrite_file(md, mutate)
433
+ events.append(root, "observed", name, ac=ac, saw=saw)
434
+ print(f"recorded on '{name}': {ac} — {saw}")
435
+ return 0
436
+
437
+
438
+ def _observed_acs(task) -> set:
439
+ out = set()
440
+ for entry in as_list(task.fm.get("observed")):
441
+ m = re.search(r"\b(AC-\d+)\b", str(entry))
442
+ if m:
443
+ out.add(m.group(1))
444
+ return out
445
+
446
+
447
+ def _last_verify(task) -> tuple:
448
+ """(passed, sha, when) from the newest recorded verify run, or (False, "", "")."""
449
+ entries = as_list(task.fm.get("verified"))
450
+ if not entries:
451
+ return False, "", ""
452
+ parts = str(entries[-1]).split()
453
+ when = parts[0] if parts else ""
454
+ passed = len(parts) > 1 and parts[1] == "pass"
455
+ sha = parts[2] if len(parts) > 2 else ""
456
+ return passed, sha, when
457
+
458
+
459
+ def gate(root, task, accept: str = "", owner: str = "") -> list:
460
+ """Every reason this task may NOT be called complete. Empty means it may.
461
+
462
+ Returns strings rather than raising so the caller can print all of them at
463
+ once: a gate that reveals one failure per run is a gate an unattended shift
464
+ walks into four times.
465
+ """
466
+ reasons = []
467
+
468
+ body = split_frontmatter((task.folder / "task.md").read_text())[1]
469
+ unchecked = re.findall(r"^\s*-\s*\[ \]\s*(.+)$", body, re.MULTILINE)
470
+ # A scaffold's placeholder criterion is a template line, not an unmet promise —
471
+ # refusing on it would mean every task that never edited its brief is
472
+ # permanently incompletable, which trains people to pass `--accept`.
473
+ unchecked = [u for u in unchecked if not u.strip().startswith("<!--")]
474
+ if unchecked:
475
+ reasons.append(f"{len(unchecked)} unchecked acceptance criterion/criteria: "
476
+ + "; ".join(u[:60] for u in unchecked[:3]))
477
+
478
+ if VERIFY:
479
+ passed, sha, when = _last_verify(task)
480
+ if not when:
481
+ reasons.append("verify has never run for this task — `jarvis work verify "
482
+ f"--task {task.name}`")
483
+ elif not passed:
484
+ reasons.append(f"the last verify run FAILED ({when}) — fix it, then "
485
+ f"`jarvis work verify --task {task.name}`")
486
+ else:
487
+ head = _head(root.parent)
488
+ if (head and sha not in ("no-git", "") and not head.startswith(sha)
489
+ and not git.only_board_moved(root.parent, sha)):
490
+ reasons.append(f"code moved since verify passed (proven at {sha}, "
491
+ f"HEAD is {head[:12]}) — re-run `jarvis work verify "
492
+ f"--task {task.name}`")
493
+ elif not head and when != date.today().isoformat():
494
+ reasons.append(f"verify last passed on {when}, not today, and there "
495
+ f"is no git sha to pin it to — re-run it")
496
+ else:
497
+ reasons.append("no `verify` commands configured — an unconfigured repo "
498
+ "cannot prove anything, so nothing in it can complete. Set "
499
+ "`verify.*` in .claude/work.config.json")
500
+
501
+ # Every criterion this task promised to move needs evidence a RUN produced, or
502
+ # a recorded sentence saying what a human saw. A `covers:` with neither is the
503
+ # exact shape of "done on the model's say-so".
504
+ if task.covers:
505
+ proven, _, _ = _load_run(root.parent)
506
+ seen = _observed_acs(task)
507
+ feature = (task.owner or "").split("/")[-1]
508
+ for ac in task.covers:
509
+ cid = f"{feature}/{ac}" if feature else ac
510
+ if proven.get(cid) == "passed" or ac in seen:
511
+ continue
512
+ reasons.append(f"{ac} has no evidence — no passing coverage binding "
513
+ f"and no `jarvis work observed {task.name} --ac {ac} --saw \"…\"`")
514
+
515
+ if tier_of(task) >= 3 and not accept:
516
+ reasons.append("tier 3 (irreversible, or money/secrets/personal data/law) "
517
+ "— a human signs this off: `--accept \"<reason>\" --owner <name>`")
518
+
519
+ if accept and not owner:
520
+ reasons.append("--accept needs --owner: an override with nobody's name on "
521
+ "it is the silent completion this gate exists to prevent")
522
+ return reasons
523
+
524
+
525
+ def delivery_gate(delivered: str, not_included: str) -> list:
526
+ """The forwardable line. Separate from `gate` because it is about what a task
527
+ CLAIMS rather than what it proved — and it is the one thing the harness cannot
528
+ infer, which is exactly why it is required rather than warned about.
529
+
530
+ `not_included` is mandatory. Overpromising is not a proof failure — every gate
531
+ above can pass while the summary still reads as more than was built — and the
532
+ only reliable fix is a field somebody has to fill in.
533
+ """
534
+ out = []
535
+ if not delivered:
536
+ out.append('--delivered is required: one line on what a user can now DO. '
537
+ 'A completed task nobody can describe is one nobody can use.')
538
+ if not not_included:
539
+ out.append('--not-included is required: what this deliberately leaves out. '
540
+ 'Every gate above can pass while the summary still reads as more '
541
+ 'than was built, and this is the only field that stops it. '
542
+ 'Write "nothing" only if that is true.')
543
+ return out
544
+
545
+
546
+ def signer(repo) -> str:
547
+ """Whose name goes on a waiver when the caller did not type one.
548
+
549
+ **The tool surface supplies a reason and never a name.** `--owner` is fine from a
550
+ terminal, where the person typing it is the person accepting; through a tool it
551
+ would let a run waive every gate under any name it liked. So the name comes from
552
+ the checkout's git identity — the same one the board already reads off a commit to
553
+ say who is on an item, which means a waiver and a hold cannot name different
554
+ people on one machine.
555
+
556
+ Empty when git has no identity configured, and the gate then refuses for want of
557
+ an owner exactly as it always did. Guessing a name here would be inventing the
558
+ one fact this whole arrangement exists to keep honest.
559
+ """
560
+ try:
561
+ out = subprocess.run(["git", "config", "user.name"], cwd=repo,
562
+ capture_output=True, text=True, timeout=10)
563
+ return out.stdout.strip() if out.returncode == 0 else ""
564
+ except (OSError, subprocess.SubprocessError):
565
+ return ""
566
+
567
+
568
+ def report_gate(root, task, accept: str, owner: str) -> bool:
569
+ """Run the gate and print it. True when the task may complete."""
570
+ if accept and not owner:
571
+ owner = signer(root.parent)
572
+ reasons = gate(root, task, accept, owner)
573
+ if accept and owner and reasons:
574
+ # An override clears the gate but never the record — and it prints what it
575
+ # cleared, so a person reading the log sees exactly what was waived.
576
+ print(f" ACCEPTED by {owner}: {accept}", file=sys.stderr)
577
+ for r in reasons:
578
+ print(f" waived: {r}", file=sys.stderr)
579
+ events.append(root, "completed", task.name, accepted_by=owner,
580
+ reason=accept, waived=len(reasons))
581
+ return True
582
+ if reasons:
583
+ print(f"error: '{task.name}' is not complete — {len(reasons)} gate(s) held:",
584
+ file=sys.stderr)
585
+ for r in reasons:
586
+ print(f" · {r}", file=sys.stderr)
587
+ print(" Fix them, or record a human's decision with "
588
+ "`--accept \"<reason>\" --owner <name>`.", file=sys.stderr)
589
+ events.append(root, "gate-refused", task.name, why="; ".join(reasons)[:300])
590
+ return False
591
+ return True