@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,387 @@
|
|
|
1
|
+
"""Two commands that only matter once the harness is somebody else's dependency:
|
|
2
|
+
an id allocator that two sessions cannot race, and `doctor`.
|
|
3
|
+
|
|
4
|
+
Both exist because centralising the harness changes who pays for its mistakes. A
|
|
5
|
+
bug in one repo's copy was that repo's problem; the same bug in a plugin is every
|
|
6
|
+
installer's, and neither a duplicated id nor a silently-misconfigured install
|
|
7
|
+
announces itself.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import date
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import config, events, git, ids
|
|
18
|
+
from .tree import cli, die, find_work_root, rel
|
|
19
|
+
from .registry import definition_sites, hosts, locate_domain, locate_system
|
|
20
|
+
from .generate import _sync
|
|
21
|
+
|
|
22
|
+
#: The lowest Python the harness is tested on. 3.9 is the floor for the builtin
|
|
23
|
+
#: generic annotations and dict-ordering guarantees the modules rely on; below it
|
|
24
|
+
#: the failure is an ImportError deep in a module, which reads as a broken plugin
|
|
25
|
+
#: rather than an unsupported runtime.
|
|
26
|
+
MIN_PYTHON = (3, 9)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _next_free(root: Path) -> int:
|
|
30
|
+
"""One past the highest id anywhere in the tree.
|
|
31
|
+
|
|
32
|
+
Reads the tree rather than a counter, because the tree is the truth and a
|
|
33
|
+
counter is a second copy of it that can be wrong. That is also exactly why this
|
|
34
|
+
races: the read is cheap and two sessions doing it get the same answer.
|
|
35
|
+
"""
|
|
36
|
+
seen = {ids.sort_key(i) for i in definition_sites(root)}
|
|
37
|
+
for _, _, rules, _ in hosts(root):
|
|
38
|
+
seen |= {ids.sort_key(i) for i in rules}
|
|
39
|
+
return (max(seen) + 1) if seen else 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _claim(root: Path, n: int) -> bool:
|
|
43
|
+
"""Try to reserve number `n`. True if this process got it.
|
|
44
|
+
|
|
45
|
+
`O_CREAT | O_EXCL` is the whole mechanism: the filesystem decides, atomically,
|
|
46
|
+
which of two racing sessions created the file. Everything else here is
|
|
47
|
+
bookkeeping.
|
|
48
|
+
|
|
49
|
+
The lock is held for microseconds and removed by the caller, because the
|
|
50
|
+
DURABLE claim is the `### <id>` heading written into the host doc. That split is
|
|
51
|
+
deliberate: a lock file is the clean way to serialise and the leaky way to
|
|
52
|
+
remember — leave one behind on a crash and the number is lost forever. Leave a
|
|
53
|
+
heading behind and the number is visibly taken by something a person can see,
|
|
54
|
+
fill in, or delete.
|
|
55
|
+
"""
|
|
56
|
+
lockdir = root.parent / ".work" / "ids"
|
|
57
|
+
lockdir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
try:
|
|
59
|
+
fd = os.open(str(lockdir / f"{ids.LEDGER}-{n}"),
|
|
60
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
61
|
+
except FileExistsError:
|
|
62
|
+
return False
|
|
63
|
+
os.close(fd)
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _release(root: Path, n: int) -> None:
|
|
68
|
+
(root.parent / ".work" / "ids" / f"{ids.LEDGER}-{n}").unlink(missing_ok=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _host_doc(root: Path, name: str) -> Path:
|
|
72
|
+
"""The file that hosts rules for a domain or a system, named either way.
|
|
73
|
+
|
|
74
|
+
A caller should not have to know that a domain's rules live in its `README.md`
|
|
75
|
+
while a system's live in `architecture/<name>.md` — that is the hosting model's
|
|
76
|
+
business, and `where` already hides it on the read side.
|
|
77
|
+
"""
|
|
78
|
+
md = locate_system(root, name)
|
|
79
|
+
if md:
|
|
80
|
+
return md
|
|
81
|
+
folder = locate_domain(root, name)
|
|
82
|
+
if folder:
|
|
83
|
+
return folder / "README.md"
|
|
84
|
+
die(f"no domain or system named '{name}' — `jarvis work list` shows the domains, "
|
|
85
|
+
f"`jarvis work code <region>` the systems")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_id_new(args) -> int:
|
|
89
|
+
"""Claim the next free id and write its heading into its host, atomically.
|
|
90
|
+
|
|
91
|
+
The race is ordinary, not exotic: allocation means reading "the next free
|
|
92
|
+
number" off a tree that is now spread across nine domains plus every system
|
|
93
|
+
file, and both consumer repos demonstrably run concurrent sessions — one had 56
|
|
94
|
+
uncommitted files and the other 28 during the extraction that produced this.
|
|
95
|
+
"""
|
|
96
|
+
root = find_work_root()
|
|
97
|
+
host = args["host"]
|
|
98
|
+
md = _host_doc(root, host)
|
|
99
|
+
title = (args.get("title") or "").strip()
|
|
100
|
+
if not title or title == "true":
|
|
101
|
+
die("--title is required: a rule with no title is not a definition, and "
|
|
102
|
+
"`align` reports it rather than indexing a blank")
|
|
103
|
+
|
|
104
|
+
n = _next_free(root)
|
|
105
|
+
# Walk forward rather than retrying the same number: a loser in the race wants
|
|
106
|
+
# the NEXT number, and a leftover lock from a crashed session must not wedge the
|
|
107
|
+
# allocator forever.
|
|
108
|
+
for candidate in range(n, n + 1000):
|
|
109
|
+
if _claim(root, candidate):
|
|
110
|
+
n = candidate
|
|
111
|
+
break
|
|
112
|
+
else: # pragma: no cover — defensive
|
|
113
|
+
die("could not claim an id after 1000 attempts — check .work/ids/")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
id_ = f"{ids.LEDGER}-{n}"
|
|
117
|
+
text = md.read_text()
|
|
118
|
+
entry = (f"\n### {id_} — {title}\n"
|
|
119
|
+
f"enforced_by: _the check that catches a violation_\n\n"
|
|
120
|
+
f"_Why this is true, and what it costs to break._ "
|
|
121
|
+
f"({date.today().isoformat()})\n")
|
|
122
|
+
if re.search(r"^##\s+Rules\s*$", text, re.MULTILINE):
|
|
123
|
+
text = text.rstrip() + "\n" + entry
|
|
124
|
+
else:
|
|
125
|
+
# A host with no `## Rules` gets one. The alternative is refusing, which
|
|
126
|
+
# sends the author to hand-edit the file the tool is meant to own.
|
|
127
|
+
text = text.rstrip() + "\n\n## Rules\n" + entry
|
|
128
|
+
md.write_text(text)
|
|
129
|
+
finally:
|
|
130
|
+
# The heading is now the claim, so the lock has done its job. Released even
|
|
131
|
+
# on failure: holding it would burn the number for good.
|
|
132
|
+
_release(root, n)
|
|
133
|
+
|
|
134
|
+
print(f"claimed {id_} in {rel(md, root)}")
|
|
135
|
+
print(f" fill in `enforced_by:` — a rule nothing can catch is a wish")
|
|
136
|
+
_sync(root)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _mcp_declared(repo: Path, server: str) -> bool:
|
|
141
|
+
"""Whether any `.mcp.json` in the project declares `server`."""
|
|
142
|
+
for p in (repo / ".mcp.json", repo / ".claude" / ".mcp.json"):
|
|
143
|
+
if not p.is_file():
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(p.read_text())
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
continue
|
|
149
|
+
if server in (data.get("mcpServers") or {}):
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_doctor(args) -> int:
|
|
155
|
+
"""Self-diagnosis, because a plugin failing in someone else's repo has no other
|
|
156
|
+
support surface.
|
|
157
|
+
|
|
158
|
+
Exits non-zero only on a GENUINE break — something that stops the harness
|
|
159
|
+
working. A degraded-but-legal install (no graph engine, no verify commands) is
|
|
160
|
+
reported and passes, because `P-3` makes those modes legal and a doctor that
|
|
161
|
+
fails on them teaches people to ignore it.
|
|
162
|
+
"""
|
|
163
|
+
repo = Path(args["project_root"]).resolve()
|
|
164
|
+
checks, broken = [], 0
|
|
165
|
+
|
|
166
|
+
def ok(label, detail=""):
|
|
167
|
+
checks.append(("ok", label, detail))
|
|
168
|
+
|
|
169
|
+
def warn(label, detail=""):
|
|
170
|
+
checks.append(("warn", label, detail))
|
|
171
|
+
|
|
172
|
+
def bad(label, detail=""):
|
|
173
|
+
nonlocal broken
|
|
174
|
+
broken += 1
|
|
175
|
+
checks.append(("BROKEN", label, detail))
|
|
176
|
+
|
|
177
|
+
v = sys.version_info
|
|
178
|
+
running = f"{v.major}.{v.minor}.{v.micro} ({platform.python_implementation()})"
|
|
179
|
+
if (v.major, v.minor) >= MIN_PYTHON:
|
|
180
|
+
ok("python", running)
|
|
181
|
+
else:
|
|
182
|
+
bad("python", f"{running} — the harness needs "
|
|
183
|
+
f">= {MIN_PYTHON[0]}.{MIN_PYTHON[1]}")
|
|
184
|
+
|
|
185
|
+
cfg = None
|
|
186
|
+
cfg_path = repo / config.CONFIG_PATH
|
|
187
|
+
try:
|
|
188
|
+
cfg = config.load(repo)
|
|
189
|
+
if cfg_path.is_file():
|
|
190
|
+
ok("config", f"{config.CONFIG_PATH} valid; ids.prefix "
|
|
191
|
+
f"{cfg['ids']['prefix']!r}")
|
|
192
|
+
else:
|
|
193
|
+
warn("config", f"no {config.CONFIG_PATH} — running on shipped defaults "
|
|
194
|
+
f"(ids.prefix {cfg['ids']['prefix']!r}). Fine for a new "
|
|
195
|
+
f"repo; set one before writing rules.")
|
|
196
|
+
except config.ConfigError as e:
|
|
197
|
+
bad("config", f"{e}")
|
|
198
|
+
|
|
199
|
+
root = repo / "work"
|
|
200
|
+
if not root.is_dir():
|
|
201
|
+
bad("work tree", f"no {rel(root, root)} — run `{cli()} init`")
|
|
202
|
+
else:
|
|
203
|
+
parts = [d for d in ("versions", "backlog") if (root / d).is_dir()]
|
|
204
|
+
if (root / "README.md").is_file() and parts:
|
|
205
|
+
ok("work tree", f"{root} ({', '.join(parts)})")
|
|
206
|
+
else:
|
|
207
|
+
warn("work tree", f"{root} is incomplete — `jarvis work init` backfills "
|
|
208
|
+
f"what is missing without touching what is there "
|
|
209
|
+
f"(`{cli()} init`)")
|
|
210
|
+
|
|
211
|
+
if cfg is not None:
|
|
212
|
+
# The one setting here that fails SILENTLY when it cannot hold: a repo that
|
|
213
|
+
# asked for its board writes to land in git, with no repository under the
|
|
214
|
+
# tree, gets the write on disk, no complaint, and none of the guarantee it
|
|
215
|
+
# asked for. So the row says whether it holds, rather than whether it is set.
|
|
216
|
+
gc = cfg["git"]
|
|
217
|
+
# Asked once and reused: `doctor` is a diagnostic, and a diagnostic that
|
|
218
|
+
# shells out twice for one answer is one that can give two.
|
|
219
|
+
unseen = git.ignored(repo) if gc["commit"] and git.is_repo(repo) else []
|
|
220
|
+
if not gc["commit"]:
|
|
221
|
+
ok("git", f"board writes stay on disk — the record is work/{events.LOG}")
|
|
222
|
+
elif not git.is_repo(repo):
|
|
223
|
+
bad("git", f"git.commit is set but {repo} is not a git repository, so "
|
|
224
|
+
f"every board write lands on disk only — which is the exact "
|
|
225
|
+
f"failure that setting exists to close")
|
|
226
|
+
elif unseen:
|
|
227
|
+
# The green-but-broken case. Every command exits 0, the board looks
|
|
228
|
+
# written, and the item is in no git history anywhere — because git
|
|
229
|
+
# cannot report a change to a file it has been told to ignore.
|
|
230
|
+
bad("git", f"git.commit is set but git IGNORES "
|
|
231
|
+
f"{', '.join(unseen)} — every board write lands on "
|
|
232
|
+
f"disk only, silently. Un-ignore the board, or point git.paths "
|
|
233
|
+
f"at a tree that is tracked")
|
|
234
|
+
elif not gc["push"]:
|
|
235
|
+
ok("git", f"every board write commits {', '.join(gc['paths'])} here; "
|
|
236
|
+
f"never sent (git.push is off)")
|
|
237
|
+
elif git.has_remote(repo, gc["remote"]):
|
|
238
|
+
ok("git", f"every board write commits {', '.join(gc['paths'])} and "
|
|
239
|
+
f"pushes to {gc['remote']}")
|
|
240
|
+
else:
|
|
241
|
+
warn("git", f"git.push is on but this repository has no remote named "
|
|
242
|
+
f"{gc['remote']!r} — a board write will commit, then fail "
|
|
243
|
+
f"to send, every time")
|
|
244
|
+
|
|
245
|
+
# An id matching NO configured prefix is the single highest-value thing this
|
|
246
|
+
# can report: it is exactly what an unconfigured install looks like from the
|
|
247
|
+
# inside, and it is silent otherwise — every check simply reports against a
|
|
248
|
+
# dialect the repo never chose.
|
|
249
|
+
if root.is_dir():
|
|
250
|
+
# This is the one check `align` CANNOT do: align compiles its pattern
|
|
251
|
+
# from the configured prefix, so ids in some other dialect are invisible
|
|
252
|
+
# to it by construction. That is precisely the unconfigured-install
|
|
253
|
+
# signature — a repo whose whole ledger the harness cannot see.
|
|
254
|
+
#
|
|
255
|
+
# But "is this a cross-repo citation" must have ONE answer, so the
|
|
256
|
+
# sibling-repo excuse is align's own, applied to the same left context.
|
|
257
|
+
# Otherwise doctor warns about `acme D-63` while align correctly
|
|
258
|
+
# excuses it, and the two tools disagree in front of the user.
|
|
259
|
+
from .align import _foreign_qualifier
|
|
260
|
+
foreign = _foreign_qualifier()
|
|
261
|
+
other = re.compile(r"\b([A-Z][A-Z0-9]{0,3})-\d+\b")
|
|
262
|
+
allowed = {cfg["ids"]["prefix"], *cfg["ids"]["recognised"], "AC", "B"}
|
|
263
|
+
stray = {}
|
|
264
|
+
for md in root.rglob("*.md"):
|
|
265
|
+
for line in md.read_text(errors="ignore").splitlines():
|
|
266
|
+
for m in other.finditer(line):
|
|
267
|
+
if m.group(1) in allowed or foreign.search(line[:m.start()]):
|
|
268
|
+
continue
|
|
269
|
+
stray.setdefault(m.group(1), 0)
|
|
270
|
+
stray[m.group(1)] += 1
|
|
271
|
+
if stray:
|
|
272
|
+
top = ", ".join(f"{k}-nn x{v}" for k, v in
|
|
273
|
+
sorted(stray.items(), key=lambda kv: -kv[1])[:4])
|
|
274
|
+
warn("ids", f"{sum(stray.values())} id(s) match no configured prefix "
|
|
275
|
+
f"({top}). Two readings, and they are far apart: if one "
|
|
276
|
+
f"of those IS this repo's ledger letter, set ids.prefix "
|
|
277
|
+
f"— every check is reading the wrong dialect until you "
|
|
278
|
+
f"do. If they are a sibling repo's ids discussed in "
|
|
279
|
+
f"prose, add that repo to ids.foreign and they stop "
|
|
280
|
+
f"counting here.")
|
|
281
|
+
else:
|
|
282
|
+
ok("ids", f"every id matches a configured prefix")
|
|
283
|
+
|
|
284
|
+
mcp = cfg["graph"]["mcp"]
|
|
285
|
+
if not mcp:
|
|
286
|
+
ok("graph", "none configured — the blast-radius step is grep-wide and "
|
|
287
|
+
"says so. A fully supported mode, not a broken install.")
|
|
288
|
+
elif _mcp_declared(repo, mcp):
|
|
289
|
+
ok("graph", f"{mcp!r} declared in this project's .mcp.json")
|
|
290
|
+
else:
|
|
291
|
+
# Warn, never fail: `P-3` says degraded is legal. But a silently
|
|
292
|
+
# degraded planning loop is the thing that rule exists to prevent, so
|
|
293
|
+
# the gap between "configured" and "present" gets said out loud.
|
|
294
|
+
warn("graph", f"{mcp!r} is configured but no .mcp.json in this project "
|
|
295
|
+
f"declares it. It may still be supplied by an installed "
|
|
296
|
+
f"plugin — this checks declarations, it cannot probe a "
|
|
297
|
+
f"live server from here.")
|
|
298
|
+
|
|
299
|
+
if cfg["verify"]:
|
|
300
|
+
ok("verify", ", ".join(sorted(cfg["verify"])))
|
|
301
|
+
else:
|
|
302
|
+
warn("verify", "no commands configured — `coverage` cannot tell a "
|
|
303
|
+
"session how to produce evidence")
|
|
304
|
+
|
|
305
|
+
# A wrongly-sized window is INVISIBLE while it is wrong: the reminder simply
|
|
306
|
+
# arrives too early, every session, and reads as an over-eager feature rather
|
|
307
|
+
# than a mis-set number. It shipped that way — a 200000 default against
|
|
308
|
+
# sessions that run past 600k fired at what the config called 190%. The
|
|
309
|
+
# transcripts already know the answer, so `doctor` compares the two rather
|
|
310
|
+
# than leaving it to be inferred from irritation.
|
|
311
|
+
at = cfg["wrap"]["at_percent"]
|
|
312
|
+
if not at:
|
|
313
|
+
ok("wrap", "no reminder configured — sessions end when you end them")
|
|
314
|
+
else:
|
|
315
|
+
window = cfg["wrap"]["context_tokens"]
|
|
316
|
+
peak = _observed_peak(repo)
|
|
317
|
+
if peak and peak > window:
|
|
318
|
+
warn("wrap", f"sessions here have reached {peak:,} tokens, which is "
|
|
319
|
+
f"MORE than wrap.context_tokens ({window:,}) — so the "
|
|
320
|
+
f"reminder fires at {peak / window * 100:.0f}% of the "
|
|
321
|
+
f"window it was told about, which is far too early. Set "
|
|
322
|
+
f"context_tokens to the window you actually run.")
|
|
323
|
+
elif peak:
|
|
324
|
+
ok("wrap", f"reminds at {at}% of {window:,} tokens "
|
|
325
|
+
f"(= {window * at // 100:,}); observed peak {peak:,}")
|
|
326
|
+
else:
|
|
327
|
+
ok("wrap", f"reminds at {at}% of {window:,} tokens "
|
|
328
|
+
f"(= {window * at // 100:,})")
|
|
329
|
+
|
|
330
|
+
width = max(len(c[1]) for c in checks)
|
|
331
|
+
print()
|
|
332
|
+
for level, label, detail in checks:
|
|
333
|
+
mark = {"ok": " ✓", "warn": " ·", "BROKEN": " ✗"}[level]
|
|
334
|
+
print(f"{mark} {label:<{width}} {detail}")
|
|
335
|
+
print(f"\n {broken} broken · "
|
|
336
|
+
f"{sum(1 for c in checks if c[0] == 'warn')} to look at · "
|
|
337
|
+
f"exit {1 if broken else 0}\n")
|
|
338
|
+
return 1 if broken else 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _observed_peak(repo: Path):
|
|
342
|
+
"""Largest context this repo's recent sessions actually reached, or None.
|
|
343
|
+
|
|
344
|
+
Read from Claude Code's own transcripts — the same `usage` record the wrap hook
|
|
345
|
+
measures — so `doctor` compares the configured window against what this machine
|
|
346
|
+
really does, rather than against an assumption. Best-effort by design: no
|
|
347
|
+
transcripts, a different client, or an unreadable file all mean "no opinion",
|
|
348
|
+
never a warning about a number it could not check.
|
|
349
|
+
"""
|
|
350
|
+
home = Path.home() / ".claude" / "projects"
|
|
351
|
+
slug = str(repo).replace("/", "-")
|
|
352
|
+
d = home / slug
|
|
353
|
+
if not d.is_dir():
|
|
354
|
+
return None
|
|
355
|
+
peak = 0
|
|
356
|
+
# Newest few only: this runs inside `doctor`, and a repo can accumulate hundreds
|
|
357
|
+
# of transcripts. The window question is answered by recent behaviour.
|
|
358
|
+
try:
|
|
359
|
+
files = sorted(d.glob("*.jsonl"), key=lambda p: -p.stat().st_mtime)[:5]
|
|
360
|
+
except OSError:
|
|
361
|
+
return None
|
|
362
|
+
for f in files:
|
|
363
|
+
try:
|
|
364
|
+
# Tail only — same reasoning as the hook: the last records settle it, and
|
|
365
|
+
# these files reach tens of megabytes.
|
|
366
|
+
size = f.stat().st_size
|
|
367
|
+
with f.open("rb") as fh:
|
|
368
|
+
fh.seek(max(0, size - 256 * 1024))
|
|
369
|
+
chunk = fh.read()
|
|
370
|
+
except OSError:
|
|
371
|
+
continue
|
|
372
|
+
for raw in chunk.split(b"\n"):
|
|
373
|
+
if not raw.strip():
|
|
374
|
+
continue
|
|
375
|
+
try:
|
|
376
|
+
rec = json.loads(raw)
|
|
377
|
+
except Exception:
|
|
378
|
+
continue
|
|
379
|
+
if rec.get("isSidechain"):
|
|
380
|
+
continue
|
|
381
|
+
u = (rec.get("message") or {}).get("usage")
|
|
382
|
+
if not isinstance(u, dict):
|
|
383
|
+
continue
|
|
384
|
+
peak = max(peak, sum(u.get(k, 0) or 0 for k in
|
|
385
|
+
("input_tokens", "cache_creation_input_tokens",
|
|
386
|
+
"cache_read_input_tokens")))
|
|
387
|
+
return peak or None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import date
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .tree import PRIORITIES, PRODUCT_STATES, assets_dir, die
|
|
6
|
+
from .model import locate, locate_epic, locate_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _check_kebab(kind: str, name: str):
|
|
10
|
+
if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name):
|
|
11
|
+
die(f"{kind} name must be kebab-case (got '{name}')")
|
|
12
|
+
def _check_version_name(name: str):
|
|
13
|
+
"""Version names allow dots and dashes (semver-style): v0.3, v1.0, 0.2.1-rc1."""
|
|
14
|
+
if not re.match(r"^[a-z0-9]+([._-][a-z0-9]+)*$", name):
|
|
15
|
+
die(f"version name must be kebab-case or dotted (got '{name}')")
|
|
16
|
+
def _check_priority(p: str):
|
|
17
|
+
if p not in PRIORITIES:
|
|
18
|
+
die(f"priority must be one of {', '.join(PRIORITIES)}")
|
|
19
|
+
def _check_state(s: str):
|
|
20
|
+
if s not in PRODUCT_STATES:
|
|
21
|
+
die(f"state must be one of {', '.join(PRODUCT_STATES)}")
|
|
22
|
+
def _check_owner_ref(value: str):
|
|
23
|
+
"""Format-check an `owner:` value: a bare `<domain>`, an `<app>`, or an
|
|
24
|
+
`<app>/<feature>` — each segment kebab-case. Existence is NOT checked
|
|
25
|
+
here; that's `_owner_ref_lint`'s job, non-blocking, so a task can name a
|
|
26
|
+
feature before it is scaffolded without being refused."""
|
|
27
|
+
parts = value.split("/")
|
|
28
|
+
if len(parts) not in (1, 2):
|
|
29
|
+
die(f"--owner must be '<domain>', '<app>' or '<app>/<feature>' "
|
|
30
|
+
f"(got '{value}')")
|
|
31
|
+
for p in parts:
|
|
32
|
+
_check_kebab("owner path segment", p)
|
|
33
|
+
def _check_unused(root: Path, name: str):
|
|
34
|
+
"""Names are globally unique across tasks, epics and versions — which is
|
|
35
|
+
what lets every command resolve a bare name without being told the tier."""
|
|
36
|
+
if locate(root, name):
|
|
37
|
+
die(f"a task named '{name}' already exists")
|
|
38
|
+
if locate_epic(root, name):
|
|
39
|
+
die(f"an epic named '{name}' already exists")
|
|
40
|
+
if locate_version(root, name):
|
|
41
|
+
die(f"a version named '{name}' already exists")
|
|
42
|
+
def _check_covers_ref(value: str):
|
|
43
|
+
"""Format-check an epic's `covers:` entry: `<feature>/AC-nn`.
|
|
44
|
+
|
|
45
|
+
Feature-qualified because an epic SPANS features by design — the org tier
|
|
46
|
+
touches profiles, sharing, content-trust and accounts at once — so a
|
|
47
|
+
bare `AC-01` would name four different criteria at the same time. A task's
|
|
48
|
+
`covers:` stays bare: it has exactly one `owner:` feature to resolve against."""
|
|
49
|
+
parts = value.split("/")
|
|
50
|
+
if len(parts) != 2 or not re.match(r"^AC-\d+$", parts[1]):
|
|
51
|
+
die(f"--covers entries must look like '<feature>/AC-01' (got '{value}') "
|
|
52
|
+
f"— an epic spans features, so its AC ids are feature-qualified")
|
|
53
|
+
_check_kebab("covers feature", parts[0])
|
|
54
|
+
def _render_template(filename: str, **subs) -> str:
|
|
55
|
+
text = (assets_dir() / filename).read_text()
|
|
56
|
+
for k, v in subs.items():
|
|
57
|
+
text = text.replace("{{" + k + "}}", v)
|
|
58
|
+
return text
|
|
59
|
+
def _scaffold_task(folder: Path, title: str, priority: str, depends: list):
|
|
60
|
+
"""A queued task is a brief and nothing else — just task.md. Its plan is
|
|
61
|
+
written at pickup (not in the queue, where it'd rot); its handoff only when
|
|
62
|
+
handed to a new conversation."""
|
|
63
|
+
folder.mkdir(parents=True)
|
|
64
|
+
depends_yaml = "[" + ", ".join(depends) + "]"
|
|
65
|
+
today = date.today().isoformat()
|
|
66
|
+
(folder / "task.md").write_text(_render_template(
|
|
67
|
+
"task-template.md",
|
|
68
|
+
PRIORITY=priority, DEPENDS_ON=depends_yaml, CREATED=today, TITLE=title,
|
|
69
|
+
))
|
|
70
|
+
def _scaffold_epic(folder: Path, title: str, covers: list, owner: str = "",
|
|
71
|
+
continues: str = ""):
|
|
72
|
+
folder.mkdir(parents=True)
|
|
73
|
+
today = date.today().isoformat()
|
|
74
|
+
(folder / "epic.md").write_text(_render_template(
|
|
75
|
+
"epic-template.md",
|
|
76
|
+
TITLE=title, CREATED=today, COVERS="[" + ", ".join(covers) + "]",
|
|
77
|
+
OWNER=owner, CONTINUES=continues,
|
|
78
|
+
))
|
|
79
|
+
def _scaffold_feature(md: Path, state: str):
|
|
80
|
+
"""A feature is ONE file — `product/<feature>.md`. No directory is made:
|
|
81
|
+
that is the whole point of `AC-20`, and a `mkdir` here would quietly rebuild
|
|
82
|
+
the tier it removed the next time someone scaffolds."""
|
|
83
|
+
md.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
md.write_text(_render_template("feature-template.md", STATE=state,
|
|
85
|
+
DATE=date.today().isoformat()))
|
|
86
|
+
def _scaffold_handoff(folder: Path, title: str) -> bool:
|
|
87
|
+
"""Write handoff.md from the template if absent. Returns True when created."""
|
|
88
|
+
handoff = folder / "handoff.md"
|
|
89
|
+
if handoff.is_file():
|
|
90
|
+
return False
|
|
91
|
+
handoff.write_text(_render_template(
|
|
92
|
+
"handoff-template.md", TITLE=title, CREATED=date.today().isoformat(),
|
|
93
|
+
))
|
|
94
|
+
return True
|
|
95
|
+
def _scaffold_version(folder: Path, title: str, target: str, with_design: bool,
|
|
96
|
+
with_architecture: bool = False, order: str = "",
|
|
97
|
+
start: str = "", end: str = "", outcome: str = ""):
|
|
98
|
+
folder.mkdir(parents=True)
|
|
99
|
+
today = date.today().isoformat()
|
|
100
|
+
text = _render_template(
|
|
101
|
+
"version-template.md",
|
|
102
|
+
CREATED=today, TITLE=title, TARGET=target, OUTCOME=outcome,
|
|
103
|
+
ORDER=order, START=start, END=end,
|
|
104
|
+
)
|
|
105
|
+
# The template carries `order/start/end/target: {{...}}` placeholders;
|
|
106
|
+
# strip each line entirely when its value wasn't given, so the
|
|
107
|
+
# frontmatter stays clean (mirrors `target`'s existing strip).
|
|
108
|
+
# `outcome` is never stripped — `cmd_version_new` refuses without it.
|
|
109
|
+
for key, value in (("order", order), ("start", start), ("end", end),
|
|
110
|
+
("target", target)):
|
|
111
|
+
if not value:
|
|
112
|
+
text = re.sub(rf"^{key}: *\n", "", text, flags=re.MULTILINE)
|
|
113
|
+
(folder / "version.md").write_text(text)
|
|
114
|
+
# No stories.md — RETIRED 2026-07-17. Stories live per-feature in
|
|
115
|
+
# work/product/**.md (the durable owner of behaviour); version.md's
|
|
116
|
+
# Scope links the feature stories in the cut. Existing files keep a dated
|
|
117
|
+
# deprecation pointer (git holds history) and are tolerated, never scaffolded.
|
|
118
|
+
# design.md is conditional — only UI-bearing versions need a claude.ai/design
|
|
119
|
+
# UI brief; an engine-only cut never decomposes from wireframes.
|
|
120
|
+
if with_design:
|
|
121
|
+
(folder / "design.md").write_text(_render_template(
|
|
122
|
+
"design-template.md", TITLE=title,
|
|
123
|
+
))
|
|
124
|
+
# architecture.md is conditional — the version-level TECHNICAL brief (the
|
|
125
|
+
# cross-task "plan FROM this" doc); only versions that need one scaffold it.
|
|
126
|
+
if with_architecture:
|
|
127
|
+
(folder / "architecture.md").write_text(_render_template(
|
|
128
|
+
"architecture-template.md", TITLE=title,
|
|
129
|
+
))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Reading the coverage shards — the ONE reader, as `sdk/testing/shard.ts` is the one writer.
|
|
2
|
+
|
|
3
|
+
Every runner (vitest, Playwright, the `examples/*.mjs` smokes) drops a
|
|
4
|
+
`.work/coverage/<runner>-<workspace>.json` carrying what that run actually proved. Three
|
|
5
|
+
checks need to read them — `coverage` reports them, `align` refuses to call a skipped
|
|
6
|
+
binding evidence, and `lint`'s rollup asks whether a shipped feature has any. Each growing
|
|
7
|
+
its own reader is the drift the shard format exists to prevent, and it also produced a real
|
|
8
|
+
import cycle the moment the second one wanted it.
|
|
9
|
+
|
|
10
|
+
`.work/` is gitignored on purpose: evidence is a fresh run, not a file somebody checked in.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#: Precedence when several sites claim one criterion — the SAME rank both
|
|
17
|
+
#: reporters already apply within a single runner. Anything unrecognised ranks
|
|
18
|
+
#: below `passed`, so a shard that learns a new status can never silently
|
|
19
|
+
#: promote a criterion.
|
|
20
|
+
_RANK = {"failed": 3, "todo": 2, "passed": 1}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_run(repo: Path) -> tuple[dict, dict, list]:
|
|
24
|
+
"""`{'feature/AC-nn': status}` from every shard, the RUNNERS that claimed each
|
|
25
|
+
criterion, and the shard names read.
|
|
26
|
+
|
|
27
|
+
The merge applies `failed > todo > passed` ACROSS shards, because a promise
|
|
28
|
+
is one behaviour however many runners touch it: proven here and failing
|
|
29
|
+
there is not proven, and a passing vitest suite beside a Playwright
|
|
30
|
+
`test.fixme` is half-covered, which is not proven either.
|
|
31
|
+
|
|
32
|
+
It used to be last-write-wins with only `failed` sticking — and shards are
|
|
33
|
+
read in alphabetical order, so `vitest-*.json` silently overwrote
|
|
34
|
+
`playwright.json`. A criterion bound at both levels reported whichever
|
|
35
|
+
runner sorted last, which is the one thing this file exists not to do.
|
|
36
|
+
|
|
37
|
+
The runners are kept because they say what KIND of test claimed a criterion:
|
|
38
|
+
a browser suite proves what is on screen, a unit runner proves a value. Which
|
|
39
|
+
vitest suite is `unit` and which is `integration` is not derivable here, and is
|
|
40
|
+
not guessed at — see `AC_LEVELS`. `_wrong_level` is the only caller that needs
|
|
41
|
+
them, and it is why this returns three values rather than two: a second reader
|
|
42
|
+
that tracked runners separately is exactly the drift this module ended.
|
|
43
|
+
"""
|
|
44
|
+
out: dict = {}
|
|
45
|
+
runners: dict = {}
|
|
46
|
+
shards: list = []
|
|
47
|
+
d = repo / ".work" / "coverage"
|
|
48
|
+
if not d.is_dir():
|
|
49
|
+
return out, runners, shards
|
|
50
|
+
for p in sorted(d.glob("*.json")):
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(p.read_text())
|
|
53
|
+
except Exception:
|
|
54
|
+
continue
|
|
55
|
+
shards.append(p.name)
|
|
56
|
+
runner = data.get("runner") or "unknown"
|
|
57
|
+
for cid, rec in (data.get("covered") or {}).items():
|
|
58
|
+
status = rec.get("status") if isinstance(rec, dict) else str(rec)
|
|
59
|
+
runners.setdefault(cid, set()).add(runner)
|
|
60
|
+
prior = out.get(cid)
|
|
61
|
+
if prior is None or _RANK.get(status, 0) > _RANK.get(prior, 0):
|
|
62
|
+
out[cid] = status
|
|
63
|
+
return out, runners, shards
|