@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,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
|