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