@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,582 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import ids
|
|
5
|
+
from .tree import _skipped, rel
|
|
6
|
+
from .frontmatter import as_list, parse_frontmatter, unquote
|
|
7
|
+
from . import registry
|
|
8
|
+
from .registry import (definition_sites, definition_surfaces, hosts, is_judgement,
|
|
9
|
+
locate_system, scan_domains, scan_systems)
|
|
10
|
+
from .model import scan_features
|
|
11
|
+
from .shard import _load_run
|
|
12
|
+
from .lint import _feature_ac_ids, feature_ac_levels
|
|
13
|
+
|
|
14
|
+
#: Sibling repos whose ledger ids may legally appear here. Set from config
|
|
15
|
+
#: (`ids.foreign`); empty by default, because a fresh repo has no siblings and a
|
|
16
|
+
#: hardcoded list of somebody else's repo names is exactly the bias this plugin
|
|
17
|
+
#: exists to remove.
|
|
18
|
+
FOREIGN_REPOS: tuple = ()
|
|
19
|
+
|
|
20
|
+
#: Directories under `work/` that are NOT durable governance. `product/` and
|
|
21
|
+
#: `architecture/` are handled on their own (they are the two sides the
|
|
22
|
+
#: single-feature check compares); the rest here hold work-in-flight or history,
|
|
23
|
+
#: and an id cited in a task brief says nothing about whether a rule's blast
|
|
24
|
+
#: radius reaches past one feature.
|
|
25
|
+
_NOT_GOVERNANCE = {"product", "architecture", "versions", "backlog", "archive",
|
|
26
|
+
"research", "plans"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def live_surfaces(root: Path) -> list:
|
|
30
|
+
"""The governance a fresh session actually reads and can be MISLED by.
|
|
31
|
+
|
|
32
|
+
Deliberately excludes `versions/*/complete/**`: a finished task's plan.md
|
|
33
|
+
describing a since-deleted package is accurate HISTORY, not drift. Unscoped,
|
|
34
|
+
these checks return thousands of historical hits and nobody reads the report —
|
|
35
|
+
which is the failure mode that makes a lint worthless."""
|
|
36
|
+
repo = root.parent
|
|
37
|
+
out = []
|
|
38
|
+
for name in ("CLAUDE.md", "STANDARDS.md", "README.md", "Makefile", "package.json"):
|
|
39
|
+
if (repo / name).is_file():
|
|
40
|
+
out.append(repo / name)
|
|
41
|
+
for pattern in ("ROADMAP.md", "README.md", "DECISIONS.md",
|
|
42
|
+
"product/*.md", "*/README.md",
|
|
43
|
+
"architecture/*.md", "versions/*/version.md",
|
|
44
|
+
"versions/*/architecture.md", "versions/*/design.md",
|
|
45
|
+
"versions/*/queue/*/*.md", "versions/*/in-progress/*/*.md",
|
|
46
|
+
# …and the same, one level deeper, inside an epic. A
|
|
47
|
+
# `complete/` bucket stays excluded at BOTH depths: a finished
|
|
48
|
+
# task's plan describing a since-deleted package is accurate
|
|
49
|
+
# history, not drift.
|
|
50
|
+
"versions/*/*/epic.md", "versions/*/*/queue/*/*.md",
|
|
51
|
+
"versions/*/*/in-progress/*/*.md",
|
|
52
|
+
"backlog/*/*.md", "backlog/*/*/*.md"):
|
|
53
|
+
out += sorted(root.glob(pattern))
|
|
54
|
+
# The repo's OWN `.claude/` — hooks, skills and scripts a session reads, and a
|
|
55
|
+
# place ids genuinely get cited.
|
|
56
|
+
out += sorted((repo / ".claude").rglob("*.md"))
|
|
57
|
+
out += sorted((repo / ".claude").rglob("*.py"))
|
|
58
|
+
return [p for p in out if p.is_file() and not _is_harness_code(p)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_harness_code(p: Path) -> bool:
|
|
62
|
+
"""The harness's OWN modules and suite, wherever they sit.
|
|
63
|
+
|
|
64
|
+
Not governance, and not a citation surface: the suite's fixtures deliberately
|
|
65
|
+
render in the shipped dialect, so reading them means a consumer's `align`
|
|
66
|
+
reports the PLUGIN's test data as dangling citations against the consumer's
|
|
67
|
+
ledger — ids that repo never wrote and cannot resolve.
|
|
68
|
+
|
|
69
|
+
Excluded structurally rather than by location, because "the payload lives in the
|
|
70
|
+
plugin cache, so this cannot happen" is only true after cutover. Before it, both
|
|
71
|
+
consumer repos vendor this tree under `.claude/skills/work/`, which is exactly
|
|
72
|
+
where the check looks — and one of them already hit it, reporting an invented
|
|
73
|
+
`G-200` test fixture as a dangling citation.
|
|
74
|
+
"""
|
|
75
|
+
parts = p.parts
|
|
76
|
+
return ("harness" in parts and p.suffix == ".py") or p.name.startswith("test_")
|
|
77
|
+
def _align_definitions(root: Path) -> list:
|
|
78
|
+
"""Every definition surface must be able to resolve the ids it hosts. Both
|
|
79
|
+
findings here have actually happened: three ids were written at `##`, invisible
|
|
80
|
+
to any lookup while shipped code cited them; five had two `###` headings, so a
|
|
81
|
+
lookup returned two homes.
|
|
82
|
+
|
|
83
|
+
Scoped by the SURFACE LIST, not by filename. The `##`-form check used to run on
|
|
84
|
+
`DECISIONS.md` alone, so the same mistake in `architecture/graph.md` — and now
|
|
85
|
+
in `quality/README.md` — went unreported: the check was as narrow as the
|
|
86
|
+
hosting rule it grew up beside."""
|
|
87
|
+
out = []
|
|
88
|
+
for home, md in definition_surfaces(root):
|
|
89
|
+
if not md.is_file():
|
|
90
|
+
continue
|
|
91
|
+
for m in ids.heading(2).finditer(md.read_text()):
|
|
92
|
+
out.append(("error", "definition-form", f"{home}: {m.group(1)} is a "
|
|
93
|
+
f"`##` heading — the definition site is `### {m.group(1)}`, "
|
|
94
|
+
f"and a resolver cannot see it here"))
|
|
95
|
+
# One definition site per id, across every surface. Once a rule's home moves to
|
|
96
|
+
# its system or its domain, a leftover `### <id>` on the old surface is a SECOND
|
|
97
|
+
# home — exactly the drift the split exists to remove.
|
|
98
|
+
for id_, sites in sorted(definition_sites(root).items(),
|
|
99
|
+
key=lambda kv: ids.sort_key(kv[0])):
|
|
100
|
+
if len(sites) > 1:
|
|
101
|
+
homes = ", ".join(home for home, _ in sites)
|
|
102
|
+
out.append(("error", "definition-form", f"{id_} has {len(sites)} `###` "
|
|
103
|
+
f"definition sites ({homes}) — an id has exactly one (a "
|
|
104
|
+
f"refinement is `#### {id_} — Addendum`)"))
|
|
105
|
+
return out
|
|
106
|
+
def _align_ledger_index(root: Path) -> list:
|
|
107
|
+
"""A legacy ledger's Index vs its own `###` entries. Ledger-specific ON PURPOSE
|
|
108
|
+
— an Index is a property of that one file, not of hosting — and it disappears
|
|
109
|
+
with the file when the ledger dissolves."""
|
|
110
|
+
out = []
|
|
111
|
+
md = root / "DECISIONS.md"
|
|
112
|
+
if not md.is_file():
|
|
113
|
+
return out
|
|
114
|
+
text = md.read_text()
|
|
115
|
+
indexed = {m.group(1) for m in ids.index_entry().finditer(text)}
|
|
116
|
+
defined = {m.group(1) for m in ids.heading(3).finditer(text)}
|
|
117
|
+
for id_ in sorted(defined - indexed, key=ids.sort_key):
|
|
118
|
+
out.append(("warn", "ledger-index", f"DECISIONS.md: {id_} is defined but "
|
|
119
|
+
f"missing from the Index"))
|
|
120
|
+
for id_ in sorted(indexed - defined, key=ids.sort_key):
|
|
121
|
+
out.append(("error", "ledger-index", f"DECISIONS.md: {id_} is in the Index "
|
|
122
|
+
f"but has no `### {id_}` entry"))
|
|
123
|
+
return out
|
|
124
|
+
#: A line that INTRODUCES an id as an illustration is not citing it. Docs that
|
|
125
|
+
#: teach the id format ("pass an artifact id, e.g. `D87`") would otherwise report a
|
|
126
|
+
#: permanent dangling citation for an id nobody ever meant to exist — and a lint
|
|
127
|
+
#: with a permanent red line is one people learn to scroll past. Same shape and
|
|
128
|
+
#: same reasoning as `_RETIRED_MARKER`: suppress by what the line SAYS.
|
|
129
|
+
_EXAMPLE_MARKER = re.compile(
|
|
130
|
+
r"\be\.g\.|\bfor example\b|\bsuch as\b|\bfor instance\b|\bplaceholder\b"
|
|
131
|
+
r"|\billustrat|\bexamples?:", re.IGNORECASE)
|
|
132
|
+
_RETIRED_MARKER = re.compile(
|
|
133
|
+
r"retire|delet|\bis gone\b|\bare gone\b|no longer|there is no|\bno \b.{0,12}\b(target|table|package)\b"
|
|
134
|
+
r"|supersed|\bdead\b|never (landed|existed)|renamed|does not exist|doesn't exist|→|->|~~|\bwas\b"
|
|
135
|
+
r"|\bused to\b|\bstale\b",
|
|
136
|
+
re.IGNORECASE)
|
|
137
|
+
|
|
138
|
+
#: A dated `log:` entry — ` - 2026-07-24 defined (…)`. History is written in the
|
|
139
|
+
#: vocabulary of its own date, so a retired name inside one is the record working,
|
|
140
|
+
#: not drift: "on this date we settled on `@acme/access`" stays TRUE after the
|
|
141
|
+
#: package is split. Editing it to today's names would falsify the log, which is
|
|
142
|
+
#: the opposite of what this lint is for.
|
|
143
|
+
_DATED_LOG_ENTRY = re.compile(r"^\s*-\s\d{4}-\d{2}-\d{2}\s")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _foreign_qualifier() -> re.Pattern:
|
|
147
|
+
"""A sibling repo's ledger, named right before the id it belongs to — `acme
|
|
148
|
+
D147`, `acme:D172`, `acme **D-9**`. That is a cross-repo citation, not a local
|
|
149
|
+
dangling one: the id is not ours to define and never will be, so reporting it
|
|
150
|
+
forever trains people to scroll past the check.
|
|
151
|
+
|
|
152
|
+
Scoped tightly to the id's own LEFT CONTEXT rather than the whole line, so a
|
|
153
|
+
line that merely mentions another repo still has its own ids checked. Returns a
|
|
154
|
+
never-matching pattern when no siblings are configured."""
|
|
155
|
+
if not FOREIGN_REPOS:
|
|
156
|
+
return re.compile(r"(?!)")
|
|
157
|
+
alt = "|".join(re.escape(r) for r in FOREIGN_REPOS)
|
|
158
|
+
return re.compile(rf"(?:{alt})\s*[:=]?\s*[`*\[(\"']{{0,3}}$", re.IGNORECASE)
|
|
159
|
+
def _align_citations(root: Path) -> list:
|
|
160
|
+
"""A cited id that resolves to nothing."""
|
|
161
|
+
out = []
|
|
162
|
+
# Defined = every surface that can host a definition, plus the ids a host still
|
|
163
|
+
# declares only in its rules map. A citation must not go dangling merely
|
|
164
|
+
# because a rule's home moved.
|
|
165
|
+
defined = set(definition_sites(root))
|
|
166
|
+
for _, _, rules, _ in hosts(root):
|
|
167
|
+
defined |= set(rules)
|
|
168
|
+
foreign = _foreign_qualifier()
|
|
169
|
+
dangling: dict = {}
|
|
170
|
+
for p in live_surfaces(root):
|
|
171
|
+
try:
|
|
172
|
+
text = p.read_text(errors="ignore")
|
|
173
|
+
except Exception:
|
|
174
|
+
continue
|
|
175
|
+
label = rel(p, root)
|
|
176
|
+
# A line that names a deleted id IN ORDER TO SAY IT IS DELETED is a record,
|
|
177
|
+
# not drift — the same reasoning `_align_retired` uses for retired concepts.
|
|
178
|
+
# Without this, every de-cite pass would flag the note it just wrote, and
|
|
179
|
+
# the fix people reach for is deleting the note.
|
|
180
|
+
#
|
|
181
|
+
# Scoped to the FILE, not to the line and its neighbour. A doc that says an
|
|
182
|
+
# id is retired goes on to discuss it in the past tense, and those sentences
|
|
183
|
+
# carry no marker of their own — "`D-107` wanted a PSL because its entry was
|
|
184
|
+
# a bare domain" is narration, three paragraphs above the line that calls it
|
|
185
|
+
# retired. Matching per-line meant chasing that with ever more verbs in
|
|
186
|
+
# `_RETIRED_MARKER`, which is a losing regex; a document that has told us the
|
|
187
|
+
# id is dead cannot then be citing it live.
|
|
188
|
+
lines = text.splitlines()
|
|
189
|
+
excused = {ids.normalise(m.group(0))
|
|
190
|
+
for i, ln in enumerate(lines)
|
|
191
|
+
for m in ids.ID.finditer(ln)
|
|
192
|
+
if _RETIRED_MARKER.search(ln)
|
|
193
|
+
or _EXAMPLE_MARKER.search(ln)
|
|
194
|
+
# Both markers get the same one-line lookback. Prose wraps, so
|
|
195
|
+
# "e.g. a" and the id it introduces routinely land on separate
|
|
196
|
+
# lines — and without this a README that merely teaches the id
|
|
197
|
+
# format reports a permanent dangling citation for an id nobody
|
|
198
|
+
# ever meant to exist.
|
|
199
|
+
or (i and (_RETIRED_MARKER.search(lines[i - 1])
|
|
200
|
+
or _EXAMPLE_MARKER.search(lines[i - 1])))}
|
|
201
|
+
for ln in lines:
|
|
202
|
+
for m in ids.ID.finditer(ln):
|
|
203
|
+
if foreign.search(ln[:m.start()]):
|
|
204
|
+
continue
|
|
205
|
+
id_ = ids.normalise(m.group(0))
|
|
206
|
+
if id_ not in defined and id_ not in excused:
|
|
207
|
+
dangling.setdefault(id_, set()).add(label)
|
|
208
|
+
for id_, files in sorted(dangling.items(), key=lambda kv: ids.sort_key(kv[0])):
|
|
209
|
+
out.append(("error", "dangling-citation",
|
|
210
|
+
f"{id_} resolves to nothing — cited by {len(files)} live "
|
|
211
|
+
f"file(s): {', '.join(sorted(files)[:3])}"))
|
|
212
|
+
return out
|
|
213
|
+
def _align_hosts(root: Path) -> list:
|
|
214
|
+
"""A region owned twice has no single home; a rule with no `enforced_by` is a
|
|
215
|
+
wish; a rule hosted twice belongs to nobody.
|
|
216
|
+
|
|
217
|
+
Checked over SYSTEMS AND DOMAINS together. Split in two, each half would see
|
|
218
|
+
only its own kind and an id sitting in both `quality/README.md` and
|
|
219
|
+
`architecture/harness.md` would pass twice."""
|
|
220
|
+
out = []
|
|
221
|
+
owners: dict = {}
|
|
222
|
+
for s in scan_systems(root):
|
|
223
|
+
for c in s.code:
|
|
224
|
+
owners.setdefault(c, []).append(s.name)
|
|
225
|
+
for c, names in sorted(owners.items()):
|
|
226
|
+
if len(names) > 1:
|
|
227
|
+
out.append(("error", "region-owner", f"code region '{c}' is claimed by "
|
|
228
|
+
f"{len(names)} systems ({', '.join(names)}) — exactly one owns it"))
|
|
229
|
+
# A domain declaring `code:` would overlap every system that already owns those
|
|
230
|
+
# files, and one-region-one-owner is what the whole `code:` vocabulary rests on.
|
|
231
|
+
# A domain's rules key off `task.owner` instead — that is the seam, not regions.
|
|
232
|
+
for d in scan_domains(root):
|
|
233
|
+
if d.code:
|
|
234
|
+
out.append(("error", "region-owner", f"domain '{d.name}' declares "
|
|
235
|
+
f"code: [{', '.join(d.code)}] — a region belongs to a "
|
|
236
|
+
f"SYSTEM; a domain's rules derive from `task.owner`"))
|
|
237
|
+
hosted: dict = {}
|
|
238
|
+
judgement = []
|
|
239
|
+
for home, name, rules, _ in hosts(root):
|
|
240
|
+
for id_, evidence in rules.items():
|
|
241
|
+
hosted.setdefault(id_, []).append(home)
|
|
242
|
+
if is_judgement(evidence):
|
|
243
|
+
judgement.append(f"{id_} ({home})")
|
|
244
|
+
elif not evidence:
|
|
245
|
+
out.append(("warn", "no-enforced-by", f"{id_} ({name}) declares no "
|
|
246
|
+
f"enforced_by — a rule nothing can catch is a wish"))
|
|
247
|
+
for id_, homes in sorted(hosted.items(), key=lambda kv: ids.sort_key(kv[0])):
|
|
248
|
+
if len(homes) > 1:
|
|
249
|
+
out.append(("error", "rule-home", f"{id_} is hosted by "
|
|
250
|
+
f"{len(homes)} docs ({', '.join(homes)}) — an id has one home"))
|
|
251
|
+
# Reported, never silent. Some rules genuinely are judgement — `commercial` is
|
|
252
|
+
# made of them — so "a person decides" is a legal answer; but if it is never
|
|
253
|
+
# counted it becomes the cheap way to clear the warning above, and nobody can
|
|
254
|
+
# see which rules could really have been mechanised.
|
|
255
|
+
if judgement:
|
|
256
|
+
out.append(("info", "enforced-by-judgement", f"{len(judgement)} rule(s) are "
|
|
257
|
+
f"enforced by judgement, not a check: "
|
|
258
|
+
f"{', '.join(sorted(judgement, key=ids.sort_key)[:4])}"
|
|
259
|
+
+ (" …" if len(judgement) > 4 else "")))
|
|
260
|
+
return out
|
|
261
|
+
def _align_domains(root: Path) -> list:
|
|
262
|
+
"""A folder that looks like an org-domain but does not declare itself one.
|
|
263
|
+
|
|
264
|
+
`type: domain` is what makes a domain, deliberately — not the filename — so a
|
|
265
|
+
charter that loses its frontmatter becomes invisible to every lookup while
|
|
266
|
+
still sitting there full of `### <id>` headings. Nothing else reports it: the
|
|
267
|
+
rules simply stop resolving, and `where` says an id has no home while the file
|
|
268
|
+
defining it is right there.
|
|
269
|
+
|
|
270
|
+
Scoped to top-level folders that are NOT structural (`versions/`, `backlog/`,
|
|
271
|
+
`archive/` and friends carry READMEs of their own kind), so this asks only about
|
|
272
|
+
folders whose shape says "domain".
|
|
273
|
+
"""
|
|
274
|
+
out = []
|
|
275
|
+
declared = set(registry.DOMAIN_ORDER)
|
|
276
|
+
for d in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
277
|
+
if d.name in _NOT_GOVERNANCE or d.name.startswith("."):
|
|
278
|
+
continue
|
|
279
|
+
md = d / "README.md"
|
|
280
|
+
if not md.is_file():
|
|
281
|
+
continue
|
|
282
|
+
fm = parse_frontmatter(md.read_text())
|
|
283
|
+
if fm.get("type") == "domain":
|
|
284
|
+
continue
|
|
285
|
+
# A folder the CONFIG calls a domain is unambiguous — the repo said so.
|
|
286
|
+
# Anything else is a guess, so it is a warning rather than an error, and it
|
|
287
|
+
# names the one-line fix.
|
|
288
|
+
level = "error" if d.name in declared else "warn"
|
|
289
|
+
out.append((level, "undeclared-domain",
|
|
290
|
+
f"{d.name}/README.md has no `type: domain` frontmatter"
|
|
291
|
+
+ (" but `domains.order` lists it as one" if d.name in declared
|
|
292
|
+
else " — add it, or the rules it hosts resolve nowhere")))
|
|
293
|
+
return out
|
|
294
|
+
def _align_single_feature(root: Path) -> list:
|
|
295
|
+
"""A hosted rule that NOTHING outside one feature refers to — the routing test
|
|
296
|
+
says a rule whose blast radius is one feature's behaviour is that feature's
|
|
297
|
+
`B-nn`, not a system's rule id.
|
|
298
|
+
|
|
299
|
+
A WORKLIST, not a verdict, and deliberately worded as one. Whether a rule is
|
|
300
|
+
product behaviour or a build rule is a claim about its NATURE, and citation
|
|
301
|
+
topology cannot settle it: a build rule that happens to protect one feature's
|
|
302
|
+
surface is cited by exactly that feature and is still correctly a system rule.
|
|
303
|
+
The routing test is a question a person answers.
|
|
304
|
+
|
|
305
|
+
So this reports the one thing that IS checkable — nothing outside
|
|
306
|
+
`product/<feature>.md` depends on this id — and says "triage", never "misfiled".
|
|
307
|
+
Measured against three candidate signals, which is why it is the narrowest of
|
|
308
|
+
them: naming a feature in the PROSE was 0-for-4 (every hit was a table or field
|
|
309
|
+
that shares a feature's name); being cited by one feature alone flagged 22 of
|
|
310
|
+
55. This flags 13, and two of those are genuinely build rules — so expect
|
|
311
|
+
roughly a third to be dismissed, and dismissing one is a result, not a failure
|
|
312
|
+
of the check."""
|
|
313
|
+
out = []
|
|
314
|
+
def cited(md: Path) -> set:
|
|
315
|
+
if not md.is_file():
|
|
316
|
+
return set()
|
|
317
|
+
return {ids.normalise(m.group(0)) for m in ids.ID.finditer(md.read_text())}
|
|
318
|
+
|
|
319
|
+
features = {}
|
|
320
|
+
for md in sorted((root / "product").glob("*.md")):
|
|
321
|
+
if md.stem == "README":
|
|
322
|
+
continue
|
|
323
|
+
for id_ in cited(md):
|
|
324
|
+
features.setdefault(id_, set()).add(md.stem)
|
|
325
|
+
# Every OTHER governance surface that names an id. If any of them does, the rule
|
|
326
|
+
# already reaches past the one feature and there is nothing to triage.
|
|
327
|
+
#
|
|
328
|
+
# Derived from the TREE, not from `scan_domains`, and not from a hardcoded list
|
|
329
|
+
# of domain names. Both alternatives are wrong in opposite directions: a
|
|
330
|
+
# hardcoded tuple silently ignores a repo's tenth domain, and keying off
|
|
331
|
+
# `type: domain` makes the answer depend on whether a folder declared itself —
|
|
332
|
+
# a `quality/README.md` that cites the id still proves the rule reaches past the
|
|
333
|
+
# feature, frontmatter or not. What matters is that some governance doc names
|
|
334
|
+
# it, and that is a question about the file, not its metadata.
|
|
335
|
+
elsewhere = {}
|
|
336
|
+
for md in sorted((root / "architecture").glob("*.md")):
|
|
337
|
+
for id_ in cited(md):
|
|
338
|
+
elsewhere.setdefault(id_, set()).add(f"architecture/{md.stem}")
|
|
339
|
+
for d in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
340
|
+
if d.name in _NOT_GOVERNANCE:
|
|
341
|
+
continue
|
|
342
|
+
for md in sorted(d.rglob("*.md")):
|
|
343
|
+
for id_ in cited(md):
|
|
344
|
+
elsewhere.setdefault(id_, set()).add(f"{d.name}/{md.stem}")
|
|
345
|
+
legacy = cited(root / "DECISIONS.md")
|
|
346
|
+
|
|
347
|
+
for home, name, rules, _ in hosts(root):
|
|
348
|
+
for id_ in sorted(rules, key=ids.sort_key):
|
|
349
|
+
named = features.get(id_, set())
|
|
350
|
+
if len(named) != 1 or id_ in legacy:
|
|
351
|
+
continue
|
|
352
|
+
if elsewhere.get(id_, set()) - {home}:
|
|
353
|
+
continue
|
|
354
|
+
feature = sorted(named)[0]
|
|
355
|
+
out.append(("warn", "single-feature", f"{id_} ({name}) is referenced by "
|
|
356
|
+
f"product/{feature}.md and by nothing else — triage it: a "
|
|
357
|
+
f"rule that only constrains one feature's behaviour is that "
|
|
358
|
+
f"feature's B-nn, not a hosted rule id"))
|
|
359
|
+
return out
|
|
360
|
+
def _align_versions(root: Path, s: dict) -> list:
|
|
361
|
+
"""A version whose every task is complete but which was never released, and a
|
|
362
|
+
released version still carrying the briefs that should have collapsed."""
|
|
363
|
+
out = []
|
|
364
|
+
for v in s["versions"]:
|
|
365
|
+
tasks = v.all_tasks()
|
|
366
|
+
if tasks and not v.released and all(t.status == "complete" for t in tasks):
|
|
367
|
+
out.append(("warn", "version-unreleased", f"version {v.name}: all "
|
|
368
|
+
f"{len(tasks)} tasks are complete but it is not released — "
|
|
369
|
+
f"`jarvis work release {v.name}`"))
|
|
370
|
+
if v.released:
|
|
371
|
+
for brief in ("architecture.md", "design.md"):
|
|
372
|
+
if (v.folder / brief).is_file():
|
|
373
|
+
out.append(("warn", "released-brief", f"version {v.name}: "
|
|
374
|
+
f"released but still carries {brief} — a cut-local "
|
|
375
|
+
f"brief collapses at release"))
|
|
376
|
+
# Same rule, one tier down: an epic is the plan-it-together doc, and
|
|
377
|
+
# `release` removes it. One left standing means a release that did not
|
|
378
|
+
# go through `jarvis work release`.
|
|
379
|
+
for e in v.epics:
|
|
380
|
+
if e.planned:
|
|
381
|
+
out.append(("warn", "released-brief", f"version {v.name}: "
|
|
382
|
+
f"released but epic '{e.name}' still carries "
|
|
383
|
+
f"epic.md — an epic archives at release"))
|
|
384
|
+
return out
|
|
385
|
+
#: An agent-file reference in skill prose, in either of the two shapes an agent dir
|
|
386
|
+
#: takes: `.claude/agents/<name>.md` in a repo that keeps its own agents, and a bare
|
|
387
|
+
#: `agents/<name>.md` (however deep the relative prefix) once the same prose ships
|
|
388
|
+
#: inside a plugin, where the payload's agent dir sits beside its skills. Anchoring
|
|
389
|
+
#: on `.claude/` alone made the check silently vacuous the moment the prose moved.
|
|
390
|
+
_AGENT_REF = re.compile(r"(?:\.claude/|(?:\.\./)+)?agents/([a-z0-9-]+)\.md")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _align_agents(root: Path) -> list:
|
|
394
|
+
"""A skill that routes work to an agent definition that isn't where the Agent
|
|
395
|
+
tool loads from. The delegation simply cannot execute."""
|
|
396
|
+
out = []
|
|
397
|
+
repo = root.parent
|
|
398
|
+
for skills_dir in sorted(repo.rglob("skills")):
|
|
399
|
+
if not skills_dir.is_dir() or _skipped(skills_dir, repo):
|
|
400
|
+
continue
|
|
401
|
+
# Both dirs an agent file can legally live in, relative to this skills dir:
|
|
402
|
+
# a sibling `agents/` (the plugin payload's shape) and the repo's own
|
|
403
|
+
# `.claude/agents/`. A name found in either resolves.
|
|
404
|
+
available = set()
|
|
405
|
+
for d in (skills_dir.parent / "agents", repo / ".claude" / "agents"):
|
|
406
|
+
if d.is_dir():
|
|
407
|
+
available |= {p.stem for p in d.glob("*.md")}
|
|
408
|
+
for p in sorted(skills_dir.rglob("*.md")):
|
|
409
|
+
text = p.read_text(errors="ignore")
|
|
410
|
+
for m in set(_AGENT_REF.findall(text)):
|
|
411
|
+
if m not in available:
|
|
412
|
+
out.append(("error", "missing-agent", f"{rel(p, root)} cites "
|
|
413
|
+
f"agents/{m}.md, which does not exist "
|
|
414
|
+
f"(available: {', '.join(sorted(available)) or 'none'})"))
|
|
415
|
+
return out
|
|
416
|
+
def _align_retired(root: Path) -> list:
|
|
417
|
+
"""A concept that no longer exists, still named on a live governance surface.
|
|
418
|
+
This is the highest-frequency drift class and the most dangerous: a doc
|
|
419
|
+
describing a retired design reads exactly like current truth, so the next
|
|
420
|
+
session builds against it. The term list is DATA, declared in
|
|
421
|
+
`architecture/harness.md`'s `retired:` — not hardcoded here."""
|
|
422
|
+
out = []
|
|
423
|
+
md = locate_system(root, "harness")
|
|
424
|
+
if not md:
|
|
425
|
+
return out
|
|
426
|
+
entries = as_list(parse_frontmatter(md.read_text()).get("retired"))
|
|
427
|
+
terms = []
|
|
428
|
+
for e in entries:
|
|
429
|
+
e = unquote(e)
|
|
430
|
+
term, _, why = e.partition("=")
|
|
431
|
+
if term.strip():
|
|
432
|
+
terms.append((term.strip(), why.strip()))
|
|
433
|
+
if not terms:
|
|
434
|
+
return out
|
|
435
|
+
|
|
436
|
+
# A legacy ledger and the harness system are where retired terms are
|
|
437
|
+
# legitimately NAMED (as history and as this very list) — flagging them is pure
|
|
438
|
+
# noise.
|
|
439
|
+
exempt = {root / "DECISIONS.md", md}
|
|
440
|
+
for term, why in terms:
|
|
441
|
+
hits = []
|
|
442
|
+
rx = re.compile(re.escape(term))
|
|
443
|
+
for p in live_surfaces(root):
|
|
444
|
+
if p in exempt:
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
lines = p.read_text(errors="ignore").splitlines()
|
|
448
|
+
except Exception:
|
|
449
|
+
continue
|
|
450
|
+
# A doc that names a retired thing IN ORDER TO SAY IT IS RETIRED is
|
|
451
|
+
# correct, not drift — "`make db-diff` is dead", "@acme/db deleted". Only
|
|
452
|
+
# a line that uses the term as if it were live is a finding, and the
|
|
453
|
+
# marker is looked for on BOTH neighbours: prose wraps at ~100 columns,
|
|
454
|
+
# so the disclaimer lands either side of the term with equal ease —
|
|
455
|
+
# "version stories.md is\nretired" was reported as drift by a lint that
|
|
456
|
+
# only looked backwards.
|
|
457
|
+
if any(rx.search(ln)
|
|
458
|
+
and not _RETIRED_MARKER.search(ln)
|
|
459
|
+
and not _DATED_LOG_ENTRY.match(ln)
|
|
460
|
+
and not (i and _RETIRED_MARKER.search(lines[i - 1]))
|
|
461
|
+
and not _RETIRED_MARKER.search(lines[i + 1] if i + 1 < len(lines) else "")
|
|
462
|
+
for i, ln in enumerate(lines)):
|
|
463
|
+
hits.append(rel(p, root))
|
|
464
|
+
if hits:
|
|
465
|
+
out.append(("warn", "retired-concept",
|
|
466
|
+
f"'{term}' is retired ({why}) but still named in "
|
|
467
|
+
f"{len(hits)} live file(s): {', '.join(sorted(hits)[:3])}"
|
|
468
|
+
+ (" …" if len(hits) > 3 else "")))
|
|
469
|
+
return out
|
|
470
|
+
def _covers_bindings(repo: Path) -> dict:
|
|
471
|
+
"""`{feature: {AC-nn}}` claimed by a test — read from the binding the RUNNER
|
|
472
|
+
reads, never from a comment.
|
|
473
|
+
|
|
474
|
+
Three forms, one meaning, all feature-qualified because a bare `AC-01` names a
|
|
475
|
+
different criterion in every feature file:
|
|
476
|
+
|
|
477
|
+
covers("sharing/AC-01", "sharing/AC-04") a vitest file or suite
|
|
478
|
+
{ tag: ["@sharing/AC-01"] } the Playwright equivalent
|
|
479
|
+
COVERS("sharing/AC-01") a C++ doctest TEST_CASE
|
|
480
|
+
|
|
481
|
+
**Comments are deliberately not read.** A `// Covers:` header survives the test
|
|
482
|
+
being renamed, skipped or deleted, so the criterion goes on claiming evidence
|
|
483
|
+
that no run produces — the exact failure this lint exists to find. Reading the
|
|
484
|
+
call means a binding cannot outlive the test that carries it: delete the test
|
|
485
|
+
and the id stops appearing here and in the coverage shard together.
|
|
486
|
+
|
|
487
|
+
**The C++ patterns were added 2026-08-23, and their absence was not neutral.**
|
|
488
|
+
A repo whose criteria are proven by a compiled suite had every one of them
|
|
489
|
+
reported `checked-unbound` — a standing warning nobody could ever clear, while
|
|
490
|
+
`coverage` read the same repo's shards and correctly called them proven. Two
|
|
491
|
+
commands disagreeing about one fact is how a report stops being read. The match
|
|
492
|
+
itself needed nothing new: a quoted `"<feature>/AC-nn"` is the same shape in
|
|
493
|
+
every language, which is why the vocabulary was chosen that way."""
|
|
494
|
+
found: dict = {}
|
|
495
|
+
for pattern in ("*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js", "*.mjs",
|
|
496
|
+
"*_test.cpp", "*_tests.cpp", "*_test.cc", "*_test.cxx"):
|
|
497
|
+
for p in repo.rglob(pattern):
|
|
498
|
+
if _skipped(p, repo):
|
|
499
|
+
continue
|
|
500
|
+
text = p.read_text(errors="ignore")
|
|
501
|
+
# The leading `@` is Playwright's tag marker; `covers()` takes the bare id.
|
|
502
|
+
for feature, ac in re.findall(r"""["']@?([a-z][a-z0-9-]*)/(AC-\d+)["']""", text):
|
|
503
|
+
found.setdefault(feature, set()).add(ac)
|
|
504
|
+
return found
|
|
505
|
+
def _align_acceptance(root: Path) -> list:
|
|
506
|
+
"""The spec↔evidence gap: an AC claimed by a test that the feature never
|
|
507
|
+
declared, a `[x]` with no binding, a binding with no `[x]`, and a feature whose
|
|
508
|
+
`state:` runs ahead of its evidence."""
|
|
509
|
+
out = []
|
|
510
|
+
repo = root.parent
|
|
511
|
+
features = {p.stem: p for p in scan_features(root)}
|
|
512
|
+
tasks = {p.parent.name for p in root.glob("versions/*/*/*/task.md")}
|
|
513
|
+
bindings = _covers_bindings(repo)
|
|
514
|
+
|
|
515
|
+
for qualifier, acs in sorted(bindings.items()):
|
|
516
|
+
if qualifier in features:
|
|
517
|
+
declared = _feature_ac_ids(features[qualifier].read_text())
|
|
518
|
+
for ac in sorted(acs - declared):
|
|
519
|
+
out.append(("error", "orphan-ac", f"a test claims "
|
|
520
|
+
f"{qualifier}/{ac}, which that feature does not declare"))
|
|
521
|
+
elif qualifier in tasks:
|
|
522
|
+
out.append(("warn", "binding-qualifier", f"a test binds to "
|
|
523
|
+
f"'{qualifier}', which is a TASK name, not a feature — "
|
|
524
|
+
f"tasks are archived, so the binding dies with it"))
|
|
525
|
+
else:
|
|
526
|
+
# Neither a feature nor a task — so the binding names nothing at all and
|
|
527
|
+
# the run credits a promise that does not exist. Silently ignored until
|
|
528
|
+
# now, which is the false-green class this whole join exists to catch: a
|
|
529
|
+
# typo, a renamed feature, or a DOMAIN (`quality/AC-01` — domains host
|
|
530
|
+
# rule ids, never criteria) all landed here and reported clean.
|
|
531
|
+
out.append(("error", "binding-qualifier", f"a test binds to "
|
|
532
|
+
f"'{qualifier}/{sorted(acs)[0]}', and '{qualifier}' is "
|
|
533
|
+
f"neither a feature nor a task — the binding names nothing"))
|
|
534
|
+
|
|
535
|
+
# A binding read from source says only that a test NAMES the criterion — not
|
|
536
|
+
# that it proved anything. A `test.skip`/`todo` carries its tag exactly like a
|
|
537
|
+
# passing test does, so without the run this told authors to tick criteria whose
|
|
538
|
+
# only test never executed, which is the false green the whole join exists to
|
|
539
|
+
# catch. Only statuses the run explicitly reports as not-passed are subtracted,
|
|
540
|
+
# so `align` still works before anyone has produced a run.
|
|
541
|
+
run, _, _ = _load_run(repo)
|
|
542
|
+
unproven = {cid for cid, status in run.items() if status in ("todo", "failed")}
|
|
543
|
+
|
|
544
|
+
for name, md in sorted(features.items()):
|
|
545
|
+
text = md.read_text()
|
|
546
|
+
levels = feature_ac_levels(text)
|
|
547
|
+
declared = set(levels)
|
|
548
|
+
checked = {ac for ac, (_, is_built) in levels.items() if is_built}
|
|
549
|
+
bound = {ac for ac in bindings.get(name, set()) & declared
|
|
550
|
+
if f"{name}/{ac}" not in unproven}
|
|
551
|
+
state = parse_frontmatter(text).get("state")
|
|
552
|
+
# An `eyes-on` criterion is settled by a person looking, so it has no test to
|
|
553
|
+
# be missing — demanding a binding for one would be a warning nobody can ever
|
|
554
|
+
# clear, and `coverage` already keeps it out of the ratio for the same
|
|
555
|
+
# reason. Its evidence is the dated ✔ in the feature file.
|
|
556
|
+
eyes = {ac for ac, (level, _) in levels.items() if level == "eyes-on"}
|
|
557
|
+
if checked - bound - eyes:
|
|
558
|
+
out.append(("warn", "checked-unbound", f"feature {name}: "
|
|
559
|
+
f"{len(checked - bound - eyes)} AC(s) checked with no test binding "
|
|
560
|
+
f"— a [x] is a claim, a passing bound test is evidence"))
|
|
561
|
+
if bound - checked:
|
|
562
|
+
out.append(("warn", "bound-unchecked", f"feature {name}: "
|
|
563
|
+
f"{', '.join(sorted(bound - checked))} bound by a passing "
|
|
564
|
+
f"test but left unchecked — the spec understates what is built"))
|
|
565
|
+
if state == "shipped" and not bound:
|
|
566
|
+
out.append(("error", "shipped-unproven", f"feature {name}: state is "
|
|
567
|
+
f"'shipped' with zero test bindings"))
|
|
568
|
+
if state == "building" and declared and checked == declared:
|
|
569
|
+
# Ticked boxes are the ONLY half of `shipped` this lint can see, and they
|
|
570
|
+
# are the weakest half. It cannot see whether a run proves them — a
|
|
571
|
+
# criterion bound to a declared-but-unwritten placeholder looks identical
|
|
572
|
+
# here — and it cannot see a release reference at all. On the three
|
|
573
|
+
# features it first fired against, all three were right to be `building`:
|
|
574
|
+
# one had an open behaviour, one had two unwritten placeholders, and one
|
|
575
|
+
# was waiting on a release that had not happened. Each said so in its own
|
|
576
|
+
# log. So this names a candidate and the two things it did not check; it
|
|
577
|
+
# never says "flip it".
|
|
578
|
+
out.append(("warn", "state-lag", f"feature {name}: every AC is checked while state is "
|
|
579
|
+
f"still 'building' — a shipped candidate. Unchecked here: whether a run "
|
|
580
|
+
f"PROVES them (`jarvis work coverage --feature {name}`) and whether a release "
|
|
581
|
+
f"has shipped them. The feature's own log usually says which is missing"))
|
|
582
|
+
return out
|