@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.
- 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 +10104 -37174
- 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 -38718
- 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,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)
|