@appchy/jarvis 0.1.36 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10104 -37174
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38718
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. package/dist/env.json +0 -1
@@ -0,0 +1,505 @@
1
+ import json
2
+ import re
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .tree import (
7
+ EPIC_TASK_FLOOR,
8
+ HANDOFF_CAP,
9
+ PRODUCT_STATES,
10
+ ACTIVE,
11
+ TASK_FILES_OK,
12
+ TASK_REGION_CAP,
13
+ )
14
+ from .frontmatter import as_list, parse_frontmatter, split_frontmatter
15
+ from .model import locate_feature, scan, scan_features
16
+ from .registry import code_vocabulary, locate_domain
17
+
18
+ #: The MCP server this repo names as its graph engine, bound by `config.apply`.
19
+ #: `None` is the shipped default and a fully supported mode — the planning loop
20
+ #: degrades to a wide grep sweep and the plan says so.
21
+ GRAPH_MCP = None
22
+
23
+
24
+ def _research_dir_warnings(d: Path, label: str) -> list:
25
+ # The summary is canonically 00-report.md, but any 00-*.md leading file
26
+ # (00-synthesis.md, 00-analysis.md, …) counts as the distilled entry point.
27
+ if d.is_dir() and not any(d.glob("00-*.md")):
28
+ return [f"{label}: research dir has no 00-*.md summary (≤30 lines)"]
29
+ return []
30
+ def _task_lint(t: "Task") -> list:
31
+ """Drift warnings for a single task folder. Completed tasks are frozen
32
+ (cleaned at archive) so they're skipped — the lint guards ACTIVE work."""
33
+ if t.status == "complete":
34
+ return []
35
+ warns = []
36
+ label = t.name
37
+ files = sorted(p.name for p in t.folder.glob("*.md"))
38
+ subdirs = [p.name for p in t.folder.iterdir() if p.is_dir()]
39
+
40
+ if t.status in (None, "queue"):
41
+ # A queued / backlog task is a brief and nothing else.
42
+ extra = [f for f in files if f != "task.md"] + subdirs
43
+ if extra:
44
+ warns.append(
45
+ f"{label}: queue/backlog task carries {', '.join(extra)} — "
46
+ f"plan at pickup; keep only task.md")
47
+ return warns
48
+
49
+ # in-progress: recognised spine + conditional extras only.
50
+ for f in files:
51
+ if f in TASK_FILES_OK:
52
+ continue
53
+ if f in ("state.md", "stories.md"):
54
+ warns.append(f"{label}: {f} is retired — fold into plan.md "
55
+ f"(## As-found) / task.md acceptance criteria")
56
+ elif f == "decisions.md":
57
+ warns.append(f"{label}: decisions.md is retired — small calls go in "
58
+ f"handoff.md, durable ones in the domain or system that "
59
+ f"owns them (`jarvis work where <id>` resolves it)")
60
+ else:
61
+ warns.append(f"{label}: unrecognised file {f} "
62
+ f"(suffix variants like plan-*.md are not allowed)")
63
+ for sub in subdirs:
64
+ if sub != "research":
65
+ warns.append(f"{label}: unexpected subdir {sub}/")
66
+ warns += _research_dir_warnings(t.folder / "research", label)
67
+
68
+ # Approach must hand over to plan.md once a plan exists.
69
+ if "plan.md" in files:
70
+ body = split_frontmatter((t.folder / "task.md").read_text())[1]
71
+ m = re.search(r"^##\s+Approach\s*\n(.*?)(?=^##\s|\Z)", body,
72
+ re.MULTILINE | re.DOTALL)
73
+ if m and len(m.group(1).strip()) > len("Approach → plan.md") + 4:
74
+ warns.append(f"{label}: task.md still has an ## Approach body but a "
75
+ f"plan.md exists — collapse it to 'Approach → plan.md'")
76
+
77
+ # Handoff is the CROSS-CONVERSATION artifact, not a per-task requirement: an absent or untouched
78
+ # scaffold mid-work is fine (a task can complete in one session without a filled handoff). Only lint
79
+ # the cap, and only if someone actually wrote one (the scaffold itself is short).
80
+ handoff = t.folder / "handoff.md"
81
+ if handoff.is_file():
82
+ n = len(handoff.read_text().splitlines())
83
+ if n > HANDOFF_CAP:
84
+ warns.append(f"{label}: handoff.md is {n} lines (>{HANDOFF_CAP}) — "
85
+ f"prune-on-absorb: durable facts to their owning domain "
86
+ f"or system, then drop the log tail")
87
+ return warns
88
+ def _shape_lint(root: Path, s: dict) -> list:
89
+ """The rules the version/epic/task reshape added. Each one names a measured
90
+ failure, not a preference — see `tree.py` for the numbers.
91
+
92
+ A fifth rule, the queued brief's 30-line budget, was DELETED with
93
+ `TASK_LINE_BUDGET` (founder call): length was never the failure, a brief that
94
+ becomes a competing plan is, and the artifact lint already catches that."""
95
+ warns = []
96
+ active = [t for v in s["versions"] for t in v.all_tasks()
97
+ if t.status in ACTIVE] + s["backlog"]
98
+
99
+ # There is deliberately NO length lint on a queued task.md. It used to warn
100
+ # over 30 lines; the founder removed it, and the reasoning holds: the cost a
101
+ # brief actually imposes is a session starting cold and re-deriving what
102
+ # somebody already knew. Context that survives the gap between sessions is
103
+ # worth more than a short file, and trimming to a number throws away exactly
104
+ # the findings, dead ends and warnings the next session needs. Length was
105
+ # never the failure — a brief that quietly becomes a competing PLAN is, and
106
+ # the artifact lint below already catches that by looking for the files.
107
+
108
+ # 2 — a task spanning too many code regions is a version wearing a task
109
+ # costume: it cannot land as one coherent end-to-end slice.
110
+ for t in active:
111
+ if len(t.code) >= TASK_REGION_CAP:
112
+ warns.append(f"{t.name}: spans {len(t.code)} code regions "
113
+ f"({', '.join(t.code)}) — a task is ONE goal; at "
114
+ f"{TASK_REGION_CAP}+ it is an epic, not a task")
115
+
116
+ # 3 — an epic with no `covers:` moves no stated acceptance criterion, so
117
+ # nobody can tell when it is finished. Released epics have no epic.md
118
+ # left to carry one, and are history — skip them.
119
+ #
120
+ # ONE class is exempt, and it is a real one rather than an escape hatch:
121
+ # an epic whose `owner:` is an ORG-DOMAIN. A domain hosts rules, not
122
+ # `AC-nn`, so there is no criterion for such an epic to point at and the
123
+ # warning could only ever be answered by naming criteria it does not
124
+ # move. Naming the domain IS the stated finish line, and it is checked —
125
+ # an `owner:` that resolves to nothing exempts nothing, so the field
126
+ # cannot be used to silence this by writing anything in it. An epic
127
+ # owned by a FEATURE is not exempt: it has criteria available.
128
+ for e in [e for v in s["versions"] for e in v.epics] + s["backlog_epics"]:
129
+ if not e.planned:
130
+ continue
131
+ if not e.covers:
132
+ if e.owner and locate_domain(root, e.owner):
133
+ continue
134
+ warns.append(f"epic {e.name}: no covers: — name the feature "
135
+ f"acceptance criteria it moves (`<feature>/AC-nn`), or "
136
+ f"set `owner:` to the org-domain it answers to if it "
137
+ f"moves no product criterion")
138
+ continue
139
+ for c in e.covers:
140
+ feature = c.split("/")[0]
141
+ if "/" not in c:
142
+ warns.append(f"epic {e.name}: covers '{c}' is not "
143
+ f"feature-qualified — an epic spans features")
144
+ elif not locate_feature(root, feature):
145
+ warns.append(f"epic {e.name}: covers '{c}' names no "
146
+ f"work/product/{feature}.md")
147
+
148
+ # 3a — a THIN epic: fewer than EPIC_TASK_FLOOR tasks. The epic tier exists so
149
+ # the design happens ONCE for a whole goal; at one or two tasks there is
150
+ # no "whole goal", and the board fills with topics wearing epic costumes.
151
+ # Skip a DONE epic (`<v>/complete/`) — it is history, and an epic that
152
+ # shipped two tasks was never the failure this catches.
153
+ for e in [e for v in s["versions"] for e in v.epics] + s["backlog_epics"]:
154
+ if not e.planned or e.done_tier:
155
+ continue
156
+ n = len(e.all_tasks())
157
+ if n < EPIC_TASK_FLOOR:
158
+ warns.append(
159
+ f"epic {e.name}: {n} task(s) — an epic is the goal you plan "
160
+ f"TOGETHER, and under {EPIC_TASK_FLOOR} there is no together. "
161
+ f"Fold {'it' if n == 1 else 'them'} into an existing epic that "
162
+ f"fits, unless §Plan names the slices not yet cut")
163
+
164
+ # 3b — a task inside an epic carrying its own plan.md. The design is settled
165
+ # ONCE in `epic.md` §Plan; a second design doc per task is the
166
+ # re-analyse-per-task cost the tier exists to remove. Warn, don't block:
167
+ # sometimes pickup genuinely surfaces a call the epic plan missed — but
168
+ # then the answer belongs UP in the epic, where the siblings can read it.
169
+ for e in [e for v in s["versions"] for e in v.epics] + s["backlog_epics"]:
170
+ if not e.planned:
171
+ continue
172
+ planned = [t.name for t in e.all_tasks()
173
+ if t.status != "complete" and (t.folder / "plan.md").is_file()]
174
+ if planned:
175
+ warns.append(f"epic {e.name}: {len(planned)} task(s) carry their own "
176
+ f"plan.md ({', '.join(planned)}) — the design belongs in "
177
+ f"epic.md §Plan, written once; fold it up unless it is "
178
+ f"genuinely task-local")
179
+
180
+ # 4 — a version with no release outcome is a container, not a release.
181
+ for v in s["versions"]:
182
+ if not v.outcome:
183
+ warns.append(f"version {v.name}: no outcome: — state in one line what "
184
+ f"a user can newly DO when it ships")
185
+
186
+ # 5 — a loose task in a version that HAS adopted epics. Scoped that way on
187
+ # purpose: a fully-flat version predates the tier and is grandfathered
188
+ # (v20/v24 held live work at the reshape), while a MIXED one is a real
189
+ # straggler. Derived from the tree, so there is no exemption flag to sync.
190
+ for v in s["versions"]:
191
+ if not v.epics:
192
+ continue
193
+ for t in v.loose_tasks():
194
+ if t.status in ACTIVE:
195
+ warns.append(f"{t.name}: loose in version {v.name}, which uses "
196
+ f"epics — every task belongs to one "
197
+ f"(`jarvis work place {t.name} --version {v.name} "
198
+ f"--epic <e>`)")
199
+ return warns
200
+ def _feature_id_warnings(feature_md: Path, flabel: str) -> list:
201
+ """A feature's B-/AC- ids are the trace unit (tasks + tests cite them), so
202
+ each must be DEFINED at most once. Definition lines look like `- AC-01:` or
203
+ `- [ ] AC-01:` (references like `→ AC-01` don't count). Also flag a
204
+ `shipped` feature that still has unchecked `- [ ] AC-` boxes — shipped means
205
+ every in-scope criterion has passing evidence."""
206
+ text = feature_md.read_text()
207
+ warns = []
208
+ seen = set()
209
+ # One pass, one regex. This used to run its own `re.findall` for duplicates and
210
+ # its own `re.search` for the shipped check — three copies of the line format in
211
+ # a file whose own docstring claims a single owner, which is the drift class it
212
+ # exists to catch.
213
+ lines = _id_lines(text)
214
+ for _, id_, _level in lines:
215
+ if id_ in seen:
216
+ warns.append(f"{flabel}: duplicate id {id_} — B-/AC- ids "
217
+ f"are defined once (the trace unit must stay stable)")
218
+ seen.add(id_)
219
+ state = parse_frontmatter(text).get("state")
220
+ if state == "shipped" and any(not checked and id_.startswith("AC-")
221
+ for checked, id_, _ in lines):
222
+ warns.append(f"{flabel}: state is 'shipped' but has unchecked "
223
+ f"AC- criteria — shipped needs every in-scope AC met")
224
+ # A criterion that declares no level cannot be checked against the test that
225
+ # claims it, and silently counts as run-provable — so an unannotated one is
226
+ # reported, not assumed.
227
+ missing, unknown = [], []
228
+ for ac, (level, built) in feature_ac_levels(text).items():
229
+ if level is None:
230
+ # Only a BUILT criterion owes a level. A level on behaviour nobody
231
+ # has written is a prediction, so the 124 unticked ones are
232
+ # deliberately unannotated — and warning about them made this fire
233
+ # 11 times a run against a decision the same phase took. Measured
234
+ # when it was narrowed: 0 built unlevelled, 124 unbuilt. It stays
235
+ # useful because it now catches the real gap — a box ticked with
236
+ # nothing saying what could ever settle it — and `coverage` still
237
+ # reports the unbuilt count, so nothing went invisible.
238
+ if built:
239
+ missing.append(ac)
240
+ elif level not in AC_LEVELS:
241
+ unknown.append(f"{ac} ({level})")
242
+ if unknown:
243
+ warns.append(f"{flabel}: unknown level on {', '.join(sorted(unknown))} — "
244
+ f"one of {', '.join(AC_LEVELS)}")
245
+ if missing:
246
+ warns.append(f"{flabel}: {len(missing)} criteria declare no level — "
247
+ f"{', '.join(sorted(missing)[:4])}"
248
+ f"{' …' if len(missing) > 4 else ''}")
249
+ return warns
250
+ def _product_lint(root: Path) -> list:
251
+ """Drift + format guards for work/product/: every `<feature>.md` declares
252
+ `type: feature` and a `state:` in PRODUCT_STATES. There is no app tier and
253
+ no folder tier — a repo is ONE product, so a feature is a FILE directly
254
+ under `product/` and its name is its whole address. A leftover DIRECTORY is
255
+ itself a finding: the flatten (`AC-20`) made the path permanent, so a folder
256
+ reappearing here means someone re-created the tier it removed. Feature
257
+ B-/AC- id bookkeeping is checked via `_feature_id_warnings`."""
258
+ warns = []
259
+ product_dir = root / "product"
260
+ if not product_dir.is_dir():
261
+ return warns
262
+ for stray in sorted(p for p in product_dir.iterdir()
263
+ if p.is_dir() and p.name != "wireframes"):
264
+ warns.append(f"product/{stray.name}/ is a DIRECTORY — a feature is "
265
+ f"`product/{stray.name}.md`; a sibling asset goes in "
266
+ f"`product/wireframes/{stray.name}/`, never beside the spec")
267
+ for feature_md in scan_features(root):
268
+ flabel = f"product/{feature_md.name}"
269
+ ffm = parse_frontmatter(feature_md.read_text())
270
+ if ffm.get("type") != "feature":
271
+ warns.append(f"{flabel}: frontmatter type must be 'feature'")
272
+ if ffm.get("state") not in PRODUCT_STATES:
273
+ warns.append(f"{flabel}: state must be one of "
274
+ f"{', '.join(PRODUCT_STATES)} (got '{ffm.get('state')}')")
275
+ for dep in as_list(ffm.get("depends_on")):
276
+ if "/" in dep:
277
+ warns.append(f"{flabel}: depends_on '{dep}' is "
278
+ f"app-qualified — there is no app tier, name the "
279
+ f"feature alone")
280
+ continue
281
+ if not locate_feature(root, dep):
282
+ warns.append(f"{flabel}: depends_on '{dep}' does not "
283
+ f"match any work/product/{dep}.md")
284
+ warns += _feature_id_warnings(feature_md, flabel)
285
+ return warns
286
+ def _owner_ref_lint(root: Path, s: dict) -> list:
287
+ """Warn (never block) when a task's `owner:` doesn't resolve. An owner is a
288
+ product reference (a bare `<feature>`) OR a bare org-domain
289
+ (`operations`, `quality`, …) — the latter is what retired the `product: infra`
290
+ sentinel: infrastructure is a domain, not a fake app. Existence is a lint
291
+ concern, not a write-time gate, so a task can name a feature before it is
292
+ scaffolded."""
293
+ warns = []
294
+ for t in [task for v in s["versions"] for task in v.all_tasks()] + s["backlog"]:
295
+ # The rename warning is ACTIVE work only — a completed task's frontmatter
296
+ # is frozen history, and 90 lines of migration noise makes `list` unreadable.
297
+ # `migrate-owner` rewrites every task regardless of bucket.
298
+ if t.legacy_product and t.status in (None,) + ACTIVE:
299
+ warns.append(f"{t.name}: `product:` is the old spelling — rename the "
300
+ f"key to `owner:` (`jarvis work migrate-owner` does it)")
301
+ value = t.owner
302
+ if not value:
303
+ continue # empty is _graph_lint's warning, and only for active work
304
+ if "/" in value:
305
+ warns.append(f"{t.name}: owner: '{value}' is app-qualified — there is "
306
+ f"no app tier, name the feature or domain alone")
307
+ continue
308
+ found = locate_feature(root, value) or locate_domain(root, value)
309
+ if not found:
310
+ warns.append(f"{t.name}: owner: '{value}' matches no "
311
+ f"work/product/{value}.md and no work/{value}/README.md")
312
+
313
+ # An EPIC's `owner:` is optional, but a dangling one has to say so — it is
314
+ # what exempts a covers-less epic from rule 3 above, so a typo would silently
315
+ # buy nothing and report only the confusing half ("no covers:") with no clue
316
+ # that the exemption was reaching for a domain that is not there.
317
+ for e in [e for v in s["versions"] for e in v.epics] + s["backlog_epics"]:
318
+ if not e.planned or not e.owner:
319
+ continue
320
+ if not (locate_feature(root, e.owner) or locate_domain(root, e.owner)):
321
+ warns.append(f"epic {e.name}: owner: '{e.owner}' matches no "
322
+ f"work/product/{e.owner}.md and no "
323
+ f"work/{e.owner}/README.md")
324
+ return warns
325
+ #: The four levels a criterion can declare — the evidence that settles it.
326
+ #: `unit` and `integration` are both a vitest run and no lint tells them apart:
327
+ #: the distinction is real to a reader and not derivable from a run, so it is
328
+ #: documented rather than checked.
329
+ AC_LEVELS = ("e2e", "integration", "unit", "eyes-on")
330
+
331
+ #: THE one owner of the criterion-line format — `- [x] AC-03 (unit): …`, with the
332
+ #: level optional so an unannotated feature file still parses. Everything that
333
+ #: reads criteria goes through this, so the graph, coverage and shipped-state
334
+ #: lints cannot drift apart on what a criterion line looks like.
335
+ #: THE one owner of the B-/AC- line format — `- [x] AC-03 (unit): …`. The checkbox
336
+ #: is optional (a `B-nn` never has one); the level is optional TO PARSE so a
337
+ #: missing one can be REPORTED rather than silently read as no criterion at all. A
338
+ #: reference like `→ AC-01` has no leading dash-id-colon and never matches.
339
+ #:
340
+ #: It covers `B-nn` as well as `AC-nn` because duplicate-id detection is about the
341
+ #: trace unit, and a `B-nn` is one. Four accessors below read this and nothing else
342
+ #: — the id lint, the AC-id set, the checked set and the level map — so a change to
343
+ #: what a criterion line looks like cannot land in three of the four.
344
+ _ID_LINE = re.compile(
345
+ r"^-\s+(?:\[(?P<box>[ xX])\]\s+)?(?P<id>(?:B|AC)-\d+)"
346
+ r"(?:\s+\((?P<level>[a-z0-9-]+)\))?:",
347
+ re.MULTILINE,
348
+ )
349
+
350
+
351
+ def _id_lines(text: str) -> list:
352
+ """`[(checked, id, level), …]` for one feature body — level is `None` when the
353
+ line declares none, so "unannotated" stays distinguishable from a level that
354
+ happens to be empty."""
355
+ return [((m["box"] or "").lower() == "x", m["id"], m["level"])
356
+ for m in _ID_LINE.finditer(split_frontmatter(text)[1])]
357
+
358
+
359
+ def _feature_ac_ids(text: str) -> set:
360
+ """The `AC-nn` ids a feature declares."""
361
+ return {id_ for _, id_, _ in _id_lines(text) if id_.startswith("AC-")}
362
+
363
+
364
+ def _feature_ac_checked(text: str) -> set:
365
+ """The `AC-nn` ids a feature has TICKED — a claim, which `align` then compares
366
+ against the bindings a run actually produced."""
367
+ return {id_ for checked, id_, _ in _id_lines(text)
368
+ if checked and id_.startswith("AC-")}
369
+
370
+
371
+ def feature_ac_levels(text: str) -> dict:
372
+ """`{'AC-03': ('unit', True)}` — each criterion's declared level and whether it
373
+ is ticked as BUILT. The level is `None` until the file is annotated.
374
+
375
+ Two axes, never one: the box says the app DOES this, the level says which
376
+ evidence could ever settle it. An `eyes-on` criterion is not a coverage hole —
377
+ no run will ever prove it — so the report has to be able to tell the two apart
378
+ before it divides anything.
379
+
380
+ Keyed on `AC-nn` only: a `B-nn` states behaviour and is proved through the
381
+ criteria beneath it, so it carries no level and belongs in no ratio."""
382
+ return {id_: (level, checked) for checked, id_, level in _id_lines(text)
383
+ if id_.startswith("AC-")}
384
+ def _graph_lint(root: Path, s: dict) -> list:
385
+ """Graph-integrity warnings beyond `_owner_ref_lint`'s owner format/
386
+ dangling check (the connected product→feature→task graph): every
387
+ task should link a feature, a task's `covers:` AC ids must exist in that
388
+ linked feature.md, and a hand-edited `code:` entry must name a region some
389
+ system owns (write-time enforcement is `cmd_new`'s block; this is the
390
+ read-time mirror for files touched by hand). Scoped to ACTIVE work only —
391
+ `complete` tasks (incl. archived, which stay in the `complete` bucket) are
392
+ frozen history; retrofitting their graph links is a separate task, not lint
393
+ noise on every `list`."""
394
+ warns = []
395
+ vocabulary = code_vocabulary(root)
396
+ for t in [task for v in s["versions"] for task in v.all_tasks()
397
+ if task.status in ACTIVE]:
398
+ value = t.owner
399
+ if not value:
400
+ warns.append(f"{t.name}: no owner link (a feature or a domain)")
401
+ elif t.covers:
402
+ # An owner is a BARE feature name — there is no app tier, so this
403
+ # resolves directly. (It read `locate_feature(root, parts[0],
404
+ # parts[1])` until 2026-08-01: three args into a two-arg function,
405
+ # left behind by the app-tier flatten and reachable by any task with
406
+ # both a slash-qualified owner and a covers: list.)
407
+ fmd = locate_feature(root, value)
408
+ if fmd:
409
+ ac_ids = _feature_ac_ids(fmd.read_text())
410
+ for ac in t.covers:
411
+ if ac not in ac_ids:
412
+ warns.append(f"{t.name}: covers {ac} not in feature {value}")
413
+ for c in t.code:
414
+ if c not in vocabulary:
415
+ warns.append(f"{t.name}: code '{c}' is owned by no system "
416
+ f"(regions come from work/architecture/*.md)")
417
+ return warns
418
+ def _graph_engine_lint(root: Path) -> list:
419
+ """`graph.mcp` names a server this repo's own MCP config does not serve.
420
+
421
+ The worst of the three states, and the only one that is silent. With no engine
422
+ the harness says so and the plan degrades honestly; with a working one it maps.
423
+ With a name nothing answers, every session is told it HAS a map, calls
424
+ `mcp__<name>__*` tools that do not exist, and nothing explains why — which is
425
+ precisely the "As-found implying a map you did not get" that `SKILL.md` forbids.
426
+ Found 2026-08-13 in a repo that had been pointing at a retired engine for days.
427
+
428
+ **Only `.mcp.json` is checked, and only when it exists.** A server can also come
429
+ from user-level config or `--mcp-config`, neither of which this can see — so an
430
+ absent file is silence, not a warning. Naming what the file DOES serve is what
431
+ makes the warning actionable rather than an accusation.
432
+ """
433
+ if not GRAPH_MCP:
434
+ return []
435
+ mcp_json = root.parent / ".mcp.json"
436
+ if not mcp_json.is_file():
437
+ return []
438
+ try:
439
+ servers = json.loads(mcp_json.read_text()).get("mcpServers", {})
440
+ except (json.JSONDecodeError, AttributeError):
441
+ return [".mcp.json is not readable JSON, so `graph.mcp` cannot be checked "
442
+ "against it"]
443
+ if not isinstance(servers, dict) or GRAPH_MCP in servers:
444
+ return []
445
+ served = ", ".join(sorted(servers)) or "nothing"
446
+ return [f"graph.mcp names '{GRAPH_MCP}', which .mcp.json does not serve — "
447
+ f"sessions will be told to call mcp__{GRAPH_MCP}__* tools that do not "
448
+ f"exist. It serves: {served}. Set graph.mcp to one of those, or to null"]
449
+
450
+
451
+ def _coverage_lint(root: Path, s: dict) -> list:
452
+ """Feature coverage rollup: union `covers:` across a feature's
453
+ COMPLETED tasks vs its declared `AC-nn` set. Extends `_feature_id_warnings`'
454
+ shipped-state check with evidence — a `shipped` feature with an AC no
455
+ completed task covers is a gap, not just an unchecked box."""
456
+ warns = []
457
+ product_dir = root / "product"
458
+ if not product_dir.is_dir():
459
+ return warns
460
+
461
+ covered: dict = {}
462
+ for t in [task for v in s["versions"] for task in v.all_tasks()] + s["backlog"]:
463
+ if t.status != "complete" or not t.covers:
464
+ continue
465
+ if t.owner:
466
+ covered.setdefault(t.owner, set()).update(t.covers)
467
+
468
+ # A feature IS a file directly under product/ — no app tier, no folder tier.
469
+ # (This walked `product/<app>/<feature>/` until 2026-08-01, left behind by
470
+ # the app-tier flatten: it descended one level too far, so it silently
471
+ # matched nothing and this whole rollup was dead code wearing a passing
472
+ # test. `scan_features` is now the single owner of that glob, so the next
473
+ # layout change cannot leave one caller behind.)
474
+ for feature_md in scan_features(root):
475
+ text = feature_md.read_text()
476
+ if parse_frontmatter(text).get("state") != "shipped":
477
+ continue
478
+ have = covered.get(feature_md.stem, set())
479
+ for ac in sorted(_feature_ac_ids(text)):
480
+ if ac not in have:
481
+ warns.append(f"feature {feature_md.stem} shipped but {ac} has "
482
+ f"no covering completed task")
483
+ return warns
484
+ def lint_warnings(root: Path) -> list:
485
+ s = scan(root)
486
+ warns = []
487
+ for v in s["versions"]:
488
+ for t in v.all_tasks():
489
+ warns += _task_lint(t)
490
+ for t in s["backlog"]:
491
+ warns += _task_lint(t)
492
+ research = root / "research"
493
+ if research.is_dir():
494
+ for d in sorted(research.iterdir()):
495
+ warns += _research_dir_warnings(d, f"research/{d.name}")
496
+ warns += _product_lint(root)
497
+ warns += _owner_ref_lint(root, s)
498
+ warns += _graph_lint(root, s)
499
+ warns += _graph_engine_lint(root)
500
+ warns += _coverage_lint(root, s)
501
+ warns += _shape_lint(root, s)
502
+ return warns
503
+ def print_lint(root: Path):
504
+ for w in lint_warnings(root):
505
+ print(f" WARN {w}", file=sys.stderr)