@appchy/jarvis 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10168 -37341
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38821
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import ids
|
|
5
|
+
from .tree import RESERVED_MD, _repo_files
|
|
6
|
+
from .frontmatter import (as_list, parse_frontmatter, split_frontmatter, title_of,
|
|
7
|
+
unquote)
|
|
8
|
+
|
|
9
|
+
# NOTE: this module imports `ids` as a MODULE and reads `ids.ID` / `ids.LEDGER`
|
|
10
|
+
# at call time, never `from .ids import ID`. A name bound at import time would
|
|
11
|
+
# capture the shipped default and survive `ids.configure()`, so a repo whose
|
|
12
|
+
# prefix is not the default would have its entire ledger reported as dangling.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Domain:
|
|
16
|
+
"""A `work/<name>/README.md` with `type: domain` — the doc you land on when
|
|
17
|
+
you open the domain's folder, and a surface that may HOST rules.
|
|
18
|
+
|
|
19
|
+
All domains host, not just architecture. A rule about *how we know it's good*
|
|
20
|
+
or *how we reach users* previously had nowhere to live and got mis-filed under
|
|
21
|
+
`architecture/<system>`; a domain that owns a kind of call must be able to own
|
|
22
|
+
that call's id. The shape is identical to a system's — `### <id>` +
|
|
23
|
+
`enforced_by:` — so `_parse_rules` serves both and no check knows a filename.
|
|
24
|
+
|
|
25
|
+
`code` is read only to REFUSE it: regions belong to systems, one region to
|
|
26
|
+
exactly one owner. A domain inventing a `tests`/`ci` region would overlap every
|
|
27
|
+
system and break the vocabulary the whole `code:` field rests on, so `align`
|
|
28
|
+
reports it rather than the parser quietly accepting it."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, folder: Path):
|
|
31
|
+
self.folder = folder
|
|
32
|
+
self.name = folder.name
|
|
33
|
+
self.md = folder / "README.md"
|
|
34
|
+
text = self.md.read_text()
|
|
35
|
+
self.fm = parse_frontmatter(text)
|
|
36
|
+
self.title = title_of(self.md)
|
|
37
|
+
self.code = as_list(self.fm.get("code"))
|
|
38
|
+
self.rules = _parse_rules(split_frontmatter(text)[1])
|
|
39
|
+
self.titles = rule_titles(split_frontmatter(text)[1])
|
|
40
|
+
class System:
|
|
41
|
+
"""A `work/architecture/<name>.md`. `code` is the region vocabulary it owns;
|
|
42
|
+
`rules` maps each hosted ledger id to its `enforced_by`."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, md: Path):
|
|
45
|
+
self.md = md
|
|
46
|
+
self.name = md.stem
|
|
47
|
+
text = md.read_text()
|
|
48
|
+
self.fm = parse_frontmatter(text)
|
|
49
|
+
self.title = title_of(self.md)
|
|
50
|
+
self.code = as_list(self.fm.get("code"))
|
|
51
|
+
self.paths = [unquote(p) for p in as_list(self.fm.get("paths"))]
|
|
52
|
+
self.depends_on = as_list(self.fm.get("depends_on"))
|
|
53
|
+
self.rules = _parse_rules(split_frontmatter(text)[1])
|
|
54
|
+
self.titles = rule_titles(split_frontmatter(text)[1])
|
|
55
|
+
#: A value that is ENTIRELY italic — `_…_` or `*…*` — is a placeholder, not evidence.
|
|
56
|
+
#: Every scaffold in the harness writes its blanks in italics, so this is the shape a
|
|
57
|
+
#: rule wears before anybody has said what catches it. Found by dogfooding `id-new`:
|
|
58
|
+
#: a freshly allocated rule shipped `enforced_by: _the check…_` and `where` reported
|
|
59
|
+
#: it as ENFORCED, which is a false green in exactly the check that exists to catch
|
|
60
|
+
#: wishes. `*(…)*` was already handled; `_…_` is the same convention.
|
|
61
|
+
_ITALIC_ONLY = re.compile(r"^[_*].*[_*]$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_rules(body: str) -> dict:
|
|
65
|
+
"""`{id: enforced_by}` from a hosting doc's `### <id>` entries.
|
|
66
|
+
|
|
67
|
+
The PROSE IS THE DEFINITION: the id is its `###` heading and its evidence is
|
|
68
|
+
the `enforced_by:` line directly under it. The hand-kept summary table this
|
|
69
|
+
used to parse is DELETED, not generated — a table three lines above the entry
|
|
70
|
+
it summarises is one more thing to keep in sync, which is the bug class this
|
|
71
|
+
system exists to remove.
|
|
72
|
+
|
|
73
|
+
The word is RULE, not "invariant". A domain README and a system doc host the
|
|
74
|
+
same shape, so one parse serves both.
|
|
75
|
+
|
|
76
|
+
An `enforced_by:` that is missing, an italic `*(…)*` note, or a value that
|
|
77
|
+
OPENS with a dash counts as NOT enforced — that is what `align` reports as "a
|
|
78
|
+
rule nothing can catch is a wish", so the parse must not paper over it.
|
|
79
|
+
|
|
80
|
+
The dash is a PREFIX test, not equality. The honest-debt convention is
|
|
81
|
+
`enforced_by: — not yet`, and matching only a bare `—` let every one of those
|
|
82
|
+
through as real evidence: the triage rule that exists to admit debt was
|
|
83
|
+
silently hiding it."""
|
|
84
|
+
found = {}
|
|
85
|
+
for m in re.finditer(rf"^###\s+{ids.pattern()}\b.*?$\n(.*?)(?=^###?\s|\Z)",
|
|
86
|
+
body, re.MULTILINE | re.DOTALL):
|
|
87
|
+
id_, rest = ids.normalise(m.group(1)), m.group(2)
|
|
88
|
+
ev = re.match(r"\s*enforced_by:\s*(.*?)\s*$", rest, re.MULTILINE)
|
|
89
|
+
evidence = ev.group(1).strip() if ev else ""
|
|
90
|
+
if evidence.startswith(("—", "-", "*(")) or _ITALIC_ONLY.match(evidence):
|
|
91
|
+
evidence = ""
|
|
92
|
+
found[id_] = evidence
|
|
93
|
+
return found
|
|
94
|
+
def rule_titles(body: str) -> dict:
|
|
95
|
+
"""`{id: title}` from the same `### <id> — title` headings `_parse_rules` reads.
|
|
96
|
+
|
|
97
|
+
A second READER of one heading, never a second definition of what a rule is:
|
|
98
|
+
`_parse_rules` answers *what catches this*, and this answers *what is it called*.
|
|
99
|
+
Both come off the line the author wrote, so neither can describe a rule the other
|
|
100
|
+
does not see.
|
|
101
|
+
|
|
102
|
+
Titles are what a session start injects. Names alone are what make a rule
|
|
103
|
+
reachable — a session that has been told `J-33` exists and is about which machine
|
|
104
|
+
answers goes and reads it before routing something; one that has been told
|
|
105
|
+
nothing does not know there is anything to look up.
|
|
106
|
+
"""
|
|
107
|
+
found = {}
|
|
108
|
+
for m in re.finditer(rf"^###\s+{ids.pattern()}\s*[—–-]*\s*(.*?)\s*$",
|
|
109
|
+
body, re.MULTILINE):
|
|
110
|
+
found[ids.normalise(m.group(1))] = m.group(2).strip()
|
|
111
|
+
return found
|
|
112
|
+
#: A rule whose only enforcement is a person deciding. LEGAL — `commercial` is made
|
|
113
|
+
#: of them, and the routing test already says strategy is enforced by judgement —
|
|
114
|
+
#: but never silent: `align` tallies these on their own line so "a person decides"
|
|
115
|
+
#: cannot quietly become the way to make the no-enforced-by warning go away.
|
|
116
|
+
_JUDGEMENT = re.compile(r"^judgement\b", re.IGNORECASE)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_judgement(evidence: str) -> bool:
|
|
120
|
+
return bool(_JUDGEMENT.match(evidence.strip()))
|
|
121
|
+
def _is_domain(md: Path) -> bool:
|
|
122
|
+
"""A domain is declared by its README's `type: domain`, not by the filename.
|
|
123
|
+
`README.md` is the universal landing name, so it is the FRONTMATTER that
|
|
124
|
+
separates an org-domain from any other folder that grows one."""
|
|
125
|
+
return md.is_file() and parse_frontmatter(md.read_text()).get("type") == "domain"
|
|
126
|
+
#: The reading order of the org-domain map, not alphabetical: what it does → how
|
|
127
|
+
#: it's built → how it looks → how we know it's good → what keeps it safe → how we
|
|
128
|
+
#: run it → how users get unblocked → how we reach and earn → what binds us.
|
|
129
|
+
#: A repo overrides this through `domains.order` in its config; a domain not on the
|
|
130
|
+
#: list still scans, it just sorts last.
|
|
131
|
+
DOMAIN_ORDER = ("product", "architecture", "design", "quality", "security",
|
|
132
|
+
"operations", "support", "commercial", "legal")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def scan_domains(root: Path) -> list:
|
|
136
|
+
"""Every `work/<dir>/README.md` that declares `type: domain`, in reading
|
|
137
|
+
order."""
|
|
138
|
+
found = [Domain(p) for p in sorted(root.iterdir())
|
|
139
|
+
if p.is_dir() and _is_domain(p / "README.md")]
|
|
140
|
+
return sorted(found, key=lambda d: (DOMAIN_ORDER.index(d.name)
|
|
141
|
+
if d.name in DOMAIN_ORDER else len(DOMAIN_ORDER),
|
|
142
|
+
d.name))
|
|
143
|
+
def scan_systems(root: Path) -> list:
|
|
144
|
+
arch = root / "architecture"
|
|
145
|
+
if not arch.is_dir():
|
|
146
|
+
return []
|
|
147
|
+
return [System(p) for p in sorted(arch.glob("*.md"))
|
|
148
|
+
if p.name not in RESERVED_MD]
|
|
149
|
+
def code_vocabulary(root: Path) -> tuple:
|
|
150
|
+
"""The `code:` regions a task may draw from — the UNION of what the systems
|
|
151
|
+
declare. Replaces the hand-curated tuple this used to be: a region exists
|
|
152
|
+
because a system owns it, so the two can no longer disagree."""
|
|
153
|
+
regions = set()
|
|
154
|
+
for s in scan_systems(root):
|
|
155
|
+
regions.update(s.code)
|
|
156
|
+
return tuple(sorted(regions))
|
|
157
|
+
def systems_for_code(root: Path, code: list) -> list:
|
|
158
|
+
"""The systems a task's `code:` regions land in — `task.code ∩ system.code`.
|
|
159
|
+
This is how a task's constraining rules are derived rather than remembered."""
|
|
160
|
+
wanted = set(code)
|
|
161
|
+
return [s for s in scan_systems(root) if wanted & set(s.code)]
|
|
162
|
+
def _glob_re(pattern: str):
|
|
163
|
+
"""A `paths:` glob as a regex.
|
|
164
|
+
|
|
165
|
+
Hand-rolled rather than `fnmatch`, whose `*` happily crosses a `/` — under it
|
|
166
|
+
`packages/*/src` claims `packages/a/b/src` and a system swallows its neighbours.
|
|
167
|
+
Here `*` stops at a separator and only `**` spans them, which is what the globs
|
|
168
|
+
in these files are written to mean.
|
|
169
|
+
|
|
170
|
+
The mirror of this lives in `apps/cli/src/hooks/applies.ts`, which replays the same
|
|
171
|
+
globs without paying for a Python start. Two hand-written matchers is a real cost,
|
|
172
|
+
and it is paid deliberately: the tests on both sides assert the same cases, and the
|
|
173
|
+
alternative on the TypeScript side was a 53ms module load before every tool call.
|
|
174
|
+
"""
|
|
175
|
+
out, i = [], 0
|
|
176
|
+
while i < len(pattern):
|
|
177
|
+
ch = pattern[i]
|
|
178
|
+
if ch == "*":
|
|
179
|
+
if pattern[i + 1:i + 2] == "*":
|
|
180
|
+
out.append(".*")
|
|
181
|
+
i += 2
|
|
182
|
+
continue
|
|
183
|
+
out.append("[^/]*")
|
|
184
|
+
elif ch == "?":
|
|
185
|
+
out.append("[^/]")
|
|
186
|
+
else:
|
|
187
|
+
out.append(re.escape(ch))
|
|
188
|
+
i += 1
|
|
189
|
+
return re.compile("".join(out) + r"\Z")
|
|
190
|
+
def _claims(declared: str, path: str) -> bool:
|
|
191
|
+
"""Whether a system's declaration covers this file.
|
|
192
|
+
|
|
193
|
+
**Two forms are in use across these repos and both are meant.** A PATTERN —
|
|
194
|
+
anything carrying `*` or `?` — is a glob and is matched as one. A bare PATH is
|
|
195
|
+
what a document writes when it names which part of a repo it is about, and
|
|
196
|
+
`packages/mcp` there means that directory's contents, not a file literally called
|
|
197
|
+
`mcp`, which is the only thing a glob matcher would hand back.
|
|
198
|
+
|
|
199
|
+
Found by running this against a sibling: jarvis writes `packages/data/src/**` and
|
|
200
|
+
gotcha writes `packages/mcp`, so a glob-only reader is correct in one repo and
|
|
201
|
+
silently says nothing in the other — the worst shape available, because nothing
|
|
202
|
+
fails and the rules simply never arrive.
|
|
203
|
+
"""
|
|
204
|
+
declared = declared.rstrip("/")
|
|
205
|
+
if "*" in declared or "?" in declared:
|
|
206
|
+
return bool(_glob_re(declared).match(path))
|
|
207
|
+
return path == declared or path.startswith(f"{declared}/")
|
|
208
|
+
def system_paths(root: Path, systems=None) -> list:
|
|
209
|
+
"""Every system that declares where its code lives, as `(name, globs)`.
|
|
210
|
+
|
|
211
|
+
The pairs a caller needs to answer "which system is this file in?" without
|
|
212
|
+
re-reading the tree for each question — and the whole of what a client outside
|
|
213
|
+
Python has to be handed to ask the same thing.
|
|
214
|
+
|
|
215
|
+
`systems` takes an already-scanned list, for a caller asking this and
|
|
216
|
+
`system_for_path` in one breath: the scan reads and parses every architecture
|
|
217
|
+
doc, and doing it twice for two questions about one tree is pure waste.
|
|
218
|
+
"""
|
|
219
|
+
scanned = scan_systems(root) if systems is None else systems
|
|
220
|
+
return [(s.name, list(s.paths)) for s in scanned if s.paths]
|
|
221
|
+
def system_for_path(root: Path, path: str, systems=None):
|
|
222
|
+
"""The system whose declared globs claim this repo-relative path, or None.
|
|
223
|
+
|
|
224
|
+
A file under no system, and a repo whose systems declare nothing, are the same
|
|
225
|
+
answer: nothing to say. Both are silence rather than a guess, because a wrong
|
|
226
|
+
system names the wrong rules and that is worse than naming none.
|
|
227
|
+
|
|
228
|
+
Two systems claiming one path is not resolved here — the first in filename order
|
|
229
|
+
wins, deterministically. It means the declarations overlap, which the graph build
|
|
230
|
+
is the right place to complain about.
|
|
231
|
+
|
|
232
|
+
`systems` takes an already-scanned list, so a caller with two questions about one
|
|
233
|
+
tree pays for reading it once.
|
|
234
|
+
"""
|
|
235
|
+
clean = str(path).replace("\\", "/")
|
|
236
|
+
if clean.startswith("./"):
|
|
237
|
+
clean = clean[2:]
|
|
238
|
+
for s in (scan_systems(root) if systems is None else systems):
|
|
239
|
+
if any(_claims(declared, clean) for declared in s.paths):
|
|
240
|
+
return s
|
|
241
|
+
return None
|
|
242
|
+
def locate_system(root: Path, name: str):
|
|
243
|
+
"""The system's spec FILE, or None — the mirror of `locate_feature`. A system
|
|
244
|
+
has no directory: `architecture/<system>.md` is its whole address."""
|
|
245
|
+
md = root / "architecture" / f"{name}.md"
|
|
246
|
+
return md if md.is_file() and md.name not in RESERVED_MD else None
|
|
247
|
+
def locate_domain(root: Path, name: str):
|
|
248
|
+
folder = root / name
|
|
249
|
+
return folder if _is_domain(folder / "README.md") else None
|
|
250
|
+
def _heading_list(md: Path) -> list:
|
|
251
|
+
"""Every `### <id> — …` in one file, as `[(id, title), …]` IN ORDER and with
|
|
252
|
+
repeats kept. Deduping here would hide a second definition site, which is the
|
|
253
|
+
exact defect `align` exists to catch."""
|
|
254
|
+
if not md.is_file():
|
|
255
|
+
return []
|
|
256
|
+
return [(ids.normalise(m.group(1)), m.group(2).strip())
|
|
257
|
+
for m in ids.titled_heading(3).finditer(md.read_text())]
|
|
258
|
+
def _headings(md: Path) -> dict:
|
|
259
|
+
"""`{id: title}` for one file — first occurrence wins."""
|
|
260
|
+
found = {}
|
|
261
|
+
for id_, title in _heading_list(md):
|
|
262
|
+
found.setdefault(id_, title)
|
|
263
|
+
return found
|
|
264
|
+
def definition_surfaces(root: Path) -> list:
|
|
265
|
+
"""Every `(home, path)` that may DEFINE an id — the one list, computed once.
|
|
266
|
+
|
|
267
|
+
Each `architecture/<system>.md` ∪ each org-domain's `README.md`. Nothing
|
|
268
|
+
downstream names a file: `definition_sites`, `resolve_id`, `where` and
|
|
269
|
+
`align`'s heading-form check all iterate this, so adding a hosting surface is a
|
|
270
|
+
change here and nowhere else.
|
|
271
|
+
|
|
272
|
+
That generality is the whole point. Only two surfaces could host before, which
|
|
273
|
+
is why a quality or commercial rule had to be mis-filed under
|
|
274
|
+
`architecture/<system>` — the doc that owns the *kind* of call could not own
|
|
275
|
+
its *id*. `align` special-casing a ledger by filename was the same bug one
|
|
276
|
+
layer up.
|
|
277
|
+
|
|
278
|
+
`DECISIONS.md` is read if present, but is NOT the model: the ledger dissolves,
|
|
279
|
+
and a rule lives in the domain that owns it. It stays on the list only so a repo
|
|
280
|
+
mid-migration reads correctly rather than reporting its whole ledger dangling.
|
|
281
|
+
"""
|
|
282
|
+
surfaces = []
|
|
283
|
+
legacy = root / "DECISIONS.md"
|
|
284
|
+
if legacy.is_file():
|
|
285
|
+
surfaces.append(("DECISIONS.md", legacy))
|
|
286
|
+
surfaces += [(f"architecture/{s.name}", s.md) for s in scan_systems(root)]
|
|
287
|
+
# `architecture/README.md` is the architecture DOMAIN's landing doc, and
|
|
288
|
+
# `scan_systems` skips RESERVED_MD — so it arrives here exactly once, through
|
|
289
|
+
# `scan_domains`, and never double-counts as both a system and a domain.
|
|
290
|
+
surfaces += [(f"{d.name}/README", d.md) for d in scan_domains(root)]
|
|
291
|
+
return surfaces
|
|
292
|
+
def definition_sites(root: Path) -> dict:
|
|
293
|
+
"""`{id: [(home, title), …]}` across every definition surface.
|
|
294
|
+
|
|
295
|
+
Re-homing moves a rule without moving its id, so "is this id defined?" stops
|
|
296
|
+
being a question about one file. A list, not a single home, because a second
|
|
297
|
+
entry is a finding `align` must report rather than a state the resolver
|
|
298
|
+
silently picks a winner from."""
|
|
299
|
+
sites: dict = {}
|
|
300
|
+
for home, md in definition_surfaces(root):
|
|
301
|
+
for id_, title in _heading_list(md):
|
|
302
|
+
sites.setdefault(id_, []).append((home, title))
|
|
303
|
+
return sites
|
|
304
|
+
def hosts(root: Path) -> list:
|
|
305
|
+
"""Every doc that HOSTS rules — systems and domains alike — as
|
|
306
|
+
`(home, name, rules, code)`. The union `align` checks one-home-per-id over, so
|
|
307
|
+
an id cannot sit in `quality/README.md` and `architecture/harness.md` at once
|
|
308
|
+
and have each half of the check believe the other owns it."""
|
|
309
|
+
out = [(f"architecture/{s.name}", s.name, s.rules, s.code)
|
|
310
|
+
for s in scan_systems(root)]
|
|
311
|
+
out += [(f"{d.name}/README", d.name, d.rules, d.code)
|
|
312
|
+
for d in scan_domains(root)]
|
|
313
|
+
return out
|
|
314
|
+
def hosted_rules(root: Path) -> list:
|
|
315
|
+
"""`[(home, id, title)]` for every rule this repo hosts, systems then domains.
|
|
316
|
+
|
|
317
|
+
The same walk `hosts` makes, carrying the titles instead of the evidence —
|
|
318
|
+
because what a session start needs is the NAME of every decision already taken
|
|
319
|
+
here, and what `align` needs is what catches each one.
|
|
320
|
+
"""
|
|
321
|
+
out = [(f"architecture/{s.name}", i, t) for s in scan_systems(root)
|
|
322
|
+
for i, t in s.titles.items()]
|
|
323
|
+
out += [(d.name, i, t) for d in scan_domains(root) for i, t in d.titles.items()]
|
|
324
|
+
return out
|
|
325
|
+
def rules_by_domain(root: Path) -> dict:
|
|
326
|
+
"""`{domain: count}` — every rule hosted ANYWHERE under a domain's folder.
|
|
327
|
+
|
|
328
|
+
Not the same as `Domain.rules`, and the difference is the whole point.
|
|
329
|
+
`Domain.rules` reads that domain's own `README.md`, which is correct for
|
|
330
|
+
resolution: a rule in `architecture/harness.md` belongs to the *harness system*,
|
|
331
|
+
not to the architecture README. But it is wrong for a MAP — `architecture` hosted
|
|
332
|
+
four rules through its systems and printed as hosting none, which reads as an
|
|
333
|
+
empty domain rather than a busy one.
|
|
334
|
+
|
|
335
|
+
So the map counts by folder and resolution counts by host, and each is right for
|
|
336
|
+
its question.
|
|
337
|
+
"""
|
|
338
|
+
counts = {d.name: len(d.rules) for d in scan_domains(root)}
|
|
339
|
+
if "architecture" in counts:
|
|
340
|
+
counts["architecture"] += sum(len(s.rules) for s in scan_systems(root))
|
|
341
|
+
return counts
|
|
342
|
+
def resolve_id(root: Path, id_: str):
|
|
343
|
+
"""`(home, title)` for an id, or `None`. The one lookup `where` and every
|
|
344
|
+
dangling-citation check share, so they can never disagree about what
|
|
345
|
+
resolves."""
|
|
346
|
+
sites = definition_sites(root).get(ids.normalise(id_))
|
|
347
|
+
if sites:
|
|
348
|
+
return sites[0]
|
|
349
|
+
for home, _, rules, _ in hosts(root):
|
|
350
|
+
if ids.normalise(id_) in rules:
|
|
351
|
+
return (home, "")
|
|
352
|
+
return None
|
|
353
|
+
def domains_for_owner(root: Path, owner: str) -> list:
|
|
354
|
+
"""The domains whose rules constrain a task, resolved from `task.owner`.
|
|
355
|
+
|
|
356
|
+
Domains declare no `code:`, so they cannot be derived the way systems are
|
|
357
|
+
(`task.code ∩ system.code`) — the owner link is what keys them. An owner is
|
|
358
|
+
either a bare domain name or a bare FEATURE name, and a feature lives in
|
|
359
|
+
`product/`, so a feature owner resolves to the **product** domain. That is
|
|
360
|
+
derived from where the spec sits, not remembered, which is why there is no
|
|
361
|
+
feature→domain table to keep in sync."""
|
|
362
|
+
if not owner:
|
|
363
|
+
return []
|
|
364
|
+
by_name = {d.name: d for d in scan_domains(root)}
|
|
365
|
+
if owner in by_name:
|
|
366
|
+
return [by_name[owner]]
|
|
367
|
+
if (root / "product" / f"{owner}.md").is_file() and "product" in by_name:
|
|
368
|
+
return [by_name["product"]]
|
|
369
|
+
return []
|
|
370
|
+
def _citation_counts(root: Path) -> dict:
|
|
371
|
+
"""How many files cite each ledger id, repo-wide excluding generated caches.
|
|
372
|
+
This is what makes "an id is permanent" a measurable claim rather than a
|
|
373
|
+
slogan — `where` prints it so nobody renumbers casually."""
|
|
374
|
+
counts: dict = {}
|
|
375
|
+
for p in _repo_files(root.parent):
|
|
376
|
+
try:
|
|
377
|
+
text = p.read_text(errors="ignore")
|
|
378
|
+
except Exception:
|
|
379
|
+
continue
|
|
380
|
+
for id_ in {ids.normalise(m.group(0)) for m in ids.ID.finditer(text)}:
|
|
381
|
+
counts[id_] = counts.get(id_, 0) + 1
|
|
382
|
+
return counts
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .tree import find_work_root, rel
|
|
7
|
+
from .frontmatter import _eol, read_item, split_frontmatter, write_item
|
|
8
|
+
from .model import Task, _ordered, scan
|
|
9
|
+
from .lint import lint_warnings
|
|
10
|
+
from .generate import _regen_readme, _sync, settle_epic_tier
|
|
11
|
+
from .align import (_align_acceptance, _align_agents, _align_citations,
|
|
12
|
+
_align_definitions, _align_domains, _align_hosts,
|
|
13
|
+
_align_ledger_index, _align_retired, _align_single_feature,
|
|
14
|
+
_align_versions)
|
|
15
|
+
from .registry import rules_by_domain, scan_domains
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _task_line(t: Task) -> str:
|
|
19
|
+
extra = []
|
|
20
|
+
if t.depends_on:
|
|
21
|
+
extra.append("depends on: " + ", ".join(t.depends_on))
|
|
22
|
+
if t.artifacts:
|
|
23
|
+
extra.append(f"{len(t.artifacts)} plan/analysis file(s)")
|
|
24
|
+
if t.sessions:
|
|
25
|
+
extra.append(f"{len(t.sessions)} session(s)")
|
|
26
|
+
if t.completed:
|
|
27
|
+
extra.append(f"completed {t.completed}")
|
|
28
|
+
tail = (" [" + " · ".join(extra) + "]") if extra else ""
|
|
29
|
+
status = t.display_status()
|
|
30
|
+
return f"{status:>11} {t.priority} {t.name} — {t.title}{tail}"
|
|
31
|
+
def _say_staleness(root) -> None:
|
|
32
|
+
"""Print how far behind the origin this checkout is, or nothing when it is level.
|
|
33
|
+
|
|
34
|
+
The board and the map answer from the same checkout and must never disagree about
|
|
35
|
+
how current they are; each derives it the same way — ask the origin, report a
|
|
36
|
+
count only when the origin answered — rather than one asking the other.
|
|
37
|
+
"""
|
|
38
|
+
from . import git
|
|
39
|
+
|
|
40
|
+
note = git.staleness(root.parent)
|
|
41
|
+
if note:
|
|
42
|
+
print(f" STALE {note}\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_list(args) -> int:
|
|
46
|
+
# Another branch's board is read from that ref's tree and printed on its own,
|
|
47
|
+
# never merged into this one's. Two branches are allowed to disagree — that is
|
|
48
|
+
# not the bug, and averaging them would hide the thing you asked to see.
|
|
49
|
+
if args.get("branch"):
|
|
50
|
+
from harness.branches import cmd_at
|
|
51
|
+
return cmd_at({"ref": args["branch"], **args})
|
|
52
|
+
root = find_work_root()
|
|
53
|
+
s = scan(root)
|
|
54
|
+
|
|
55
|
+
# A read does not pull, so the board on screen can be an older world than the
|
|
56
|
+
# origin's — and one that says nothing about it reads exactly like a current one.
|
|
57
|
+
# Said FIRST, above the board it qualifies, because a caveat under sixty lines of
|
|
58
|
+
# tasks is a caveat nobody reaches.
|
|
59
|
+
_say_staleness(root)
|
|
60
|
+
|
|
61
|
+
# The domain map, in READING ORDER — what it does -> how it is built -> … ->
|
|
62
|
+
# what binds us. Printed before the board because it is the map a cold session
|
|
63
|
+
# needs to know where a call goes; `scan_domains` already sorts by
|
|
64
|
+
# `domains.order`, so this line cannot disagree with the tree.
|
|
65
|
+
domains = scan_domains(root)
|
|
66
|
+
if domains:
|
|
67
|
+
counts = rules_by_domain(root)
|
|
68
|
+
cells = [f"{d.name}({counts[d.name]})" if counts.get(d.name) else d.name
|
|
69
|
+
for d in domains]
|
|
70
|
+
print("DOMAINS " + " · ".join(cells) + " (n) = rules hosted")
|
|
71
|
+
|
|
72
|
+
if not s["versions"] and not s["backlog"]:
|
|
73
|
+
print("\nbacklog is empty")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
|
|
77
|
+
flag = {
|
|
78
|
+
"released": f" · released {v.released}",
|
|
79
|
+
"current": " · current",
|
|
80
|
+
"planned": " · planned",
|
|
81
|
+
}[v.status()]
|
|
82
|
+
target = f" · target {v.target}" if v.target and not v.released else ""
|
|
83
|
+
print(f"\nVERSION {v.name} — {v.title}{flag}{target}")
|
|
84
|
+
if v.outcome:
|
|
85
|
+
print(f" {v.outcome}")
|
|
86
|
+
for t in _ordered(v.tasks):
|
|
87
|
+
print(f" {_task_line(t)}")
|
|
88
|
+
# Moving epics first and in full; finished ones collapse to a line at the
|
|
89
|
+
# bottom. The tier they sit in is the same fact, so the board and the tree
|
|
90
|
+
# agree without either being told twice.
|
|
91
|
+
moving = [e for e in v.epics if not e.done_tier]
|
|
92
|
+
done = [e for e in v.epics if e.done_tier]
|
|
93
|
+
for e in moving:
|
|
94
|
+
print(f"\n EPIC {e.name} — {e.title}"
|
|
95
|
+
+ ("" if e.planned else " (archived at release)"))
|
|
96
|
+
if e.continues:
|
|
97
|
+
print(f" ↳ continues {e.continues} — its plan still holds")
|
|
98
|
+
for t in _ordered(e.tasks):
|
|
99
|
+
print(f" {_task_line(t)}")
|
|
100
|
+
for e in done:
|
|
101
|
+
n = len(e.all_tasks())
|
|
102
|
+
print(f"\n DONE {e.name} — {e.title} ({n}/{n})")
|
|
103
|
+
|
|
104
|
+
if s["backlog"]:
|
|
105
|
+
print("\nBACKLOG (unscheduled — pull an epic into a version to start)")
|
|
106
|
+
for e in s["backlog_epics"]:
|
|
107
|
+
print(f"\n EPIC {e.name} — {e.title}")
|
|
108
|
+
if e.continues:
|
|
109
|
+
print(f" ↳ continues {e.continues} — its plan still holds")
|
|
110
|
+
for t in sorted(e.backlog_tasks,
|
|
111
|
+
key=lambda x: (x.priority_rank(), x.name)):
|
|
112
|
+
print(f" {_task_line(t)}")
|
|
113
|
+
loose = sorted((t for t in s["backlog"] if not t.epic),
|
|
114
|
+
key=lambda x: (x.priority_rank(), x.name))
|
|
115
|
+
if loose:
|
|
116
|
+
print()
|
|
117
|
+
for t in loose:
|
|
118
|
+
print(f" {_task_line(t)}")
|
|
119
|
+
print()
|
|
120
|
+
warns = lint_warnings(root)
|
|
121
|
+
if warns:
|
|
122
|
+
print("LINT")
|
|
123
|
+
for w in warns:
|
|
124
|
+
print(f" WARN {w}", file=sys.stderr)
|
|
125
|
+
print()
|
|
126
|
+
return 0
|
|
127
|
+
def cmd_readme(args) -> int:
|
|
128
|
+
"""Regenerate what is DERIVED. An epic's tier is derived too — from whether its
|
|
129
|
+
tasks are all complete — so it settles here alongside the table, which is also
|
|
130
|
+
what migrates a tree that predates the tier."""
|
|
131
|
+
root = find_work_root()
|
|
132
|
+
for name, where in settle_epic_tier(root):
|
|
133
|
+
print(f"epic '{name}' -> {where}")
|
|
134
|
+
print(_regen_readme(root))
|
|
135
|
+
return 0
|
|
136
|
+
def misalignments(root: Path) -> list:
|
|
137
|
+
"""Every misalignment the harness can find, as `(severity, class, message)`.
|
|
138
|
+
|
|
139
|
+
Separate from the command that prints them because `wrap` asks the same question
|
|
140
|
+
at the moment somebody is actually going to act on the answer. Two sweeps would be
|
|
141
|
+
two opinions about whether the tree hangs together.
|
|
142
|
+
"""
|
|
143
|
+
s = scan(root)
|
|
144
|
+
return (_align_definitions(root) + _align_ledger_index(root)
|
|
145
|
+
+ _align_citations(root) + _align_hosts(root)
|
|
146
|
+
+ _align_domains(root)
|
|
147
|
+
+ _align_single_feature(root) + _align_versions(root, s)
|
|
148
|
+
+ _align_agents(root) + _align_retired(root) + _align_acceptance(root))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_align(args) -> int:
|
|
152
|
+
"""Report every misalignment class. REPORT-ONLY, exit 0 — by founder call.
|
|
153
|
+
|
|
154
|
+
With coverage still partial, a blocking gate would just be permanently red,
|
|
155
|
+
and a lint that is always red is a lint nobody reads. The flip to blocking is
|
|
156
|
+
the goal and becomes its own task once coverage is real."""
|
|
157
|
+
root = find_work_root()
|
|
158
|
+
findings = misalignments(root)
|
|
159
|
+
only = args.get("class")
|
|
160
|
+
if only:
|
|
161
|
+
findings = [f for f in findings if f[1] == only]
|
|
162
|
+
|
|
163
|
+
errors = [f for f in findings if f[0] == "error"]
|
|
164
|
+
warns = [f for f in findings if f[0] == "warn"]
|
|
165
|
+
# `info` is not a lesser warning — it is a COUNT of something legitimate that
|
|
166
|
+
# must stay visible (today: rules enforced by a person deciding). Folded into
|
|
167
|
+
# the warning total it would look like debt; dropped, it would let "a person
|
|
168
|
+
# decides" become the silent way to clear the no-enforced-by warning.
|
|
169
|
+
infos = [f for f in findings if f[0] == "info"]
|
|
170
|
+
for sev, cls, msg in errors + warns + infos:
|
|
171
|
+
mark = {"error": "✗", "warn": "⚠"}.get(sev, "·")
|
|
172
|
+
print(f" {mark} {cls:20} {msg}")
|
|
173
|
+
if not findings:
|
|
174
|
+
print(" no misalignments found")
|
|
175
|
+
tail = f" · {len(infos)} note(s)" if infos else ""
|
|
176
|
+
print(f"\n {len(warns)} warning(s) · {len(errors)} error(s){tail} · exit 0 "
|
|
177
|
+
f"(report-only — the flip to blocking is its own task)")
|
|
178
|
+
return 0
|
|
179
|
+
def cmd_migrate_owner(args) -> int:
|
|
180
|
+
"""Mechanical, one-way: `product:` becomes `owner:`, and the retired `infra`
|
|
181
|
+
sentinel becomes the `operations` domain. Skips files with uncommitted
|
|
182
|
+
changes unless --force: the tree is shared, and silently rewriting another
|
|
183
|
+
session's open edit is how a rename eats work that was never committed."""
|
|
184
|
+
root = find_work_root()
|
|
185
|
+
repo = root.parent
|
|
186
|
+
dirty = set()
|
|
187
|
+
try:
|
|
188
|
+
out = subprocess.run(["git", "status", "--porcelain"], cwd=repo,
|
|
189
|
+
capture_output=True, text=True, check=True).stdout
|
|
190
|
+
for line in out.splitlines():
|
|
191
|
+
if len(line) > 3:
|
|
192
|
+
dirty.add((repo / line[3:].strip().strip('"')).resolve())
|
|
193
|
+
except Exception:
|
|
194
|
+
pass # not a git repo, or git unavailable — migrate everything
|
|
195
|
+
|
|
196
|
+
def _is_dirty(md: Path) -> bool:
|
|
197
|
+
"""Git reports an untracked DIRECTORY as the directory, not its files, so
|
|
198
|
+
a plain membership test misses `work/.../new-task/task.md` inside a folder
|
|
199
|
+
another session just created. Match the path or any parent."""
|
|
200
|
+
p = md.resolve()
|
|
201
|
+
return any(d == p or d in p.parents for d in dirty)
|
|
202
|
+
|
|
203
|
+
changed, skipped = [], []
|
|
204
|
+
for md in sorted(root.rglob("task.md")):
|
|
205
|
+
text = read_item(md)
|
|
206
|
+
fm, body = split_frontmatter(text)
|
|
207
|
+
if fm is None or not re.search(r"^product:", fm, re.MULTILINE):
|
|
208
|
+
continue
|
|
209
|
+
if _is_dirty(md) and not args.get("force"):
|
|
210
|
+
skipped.append(rel(md, root))
|
|
211
|
+
continue
|
|
212
|
+
# Only the KEY is rewritten — the value and its spacing are untouched, so
|
|
213
|
+
# 105 files change one word each instead of churning frontmatter layout.
|
|
214
|
+
new_fm = re.sub(r"^product:", "owner:", fm, flags=re.MULTILINE)
|
|
215
|
+
eol = _eol(text)
|
|
216
|
+
write_item(md, f"---{eol}{new_fm}{eol}---{eol}{body}")
|
|
217
|
+
changed.append(rel(md, root))
|
|
218
|
+
|
|
219
|
+
print(f"renamed product: -> owner: in {len(changed)} task.md")
|
|
220
|
+
if skipped:
|
|
221
|
+
print(f"SKIPPED {len(skipped)} with uncommitted changes (another session "
|
|
222
|
+
f"may be editing them) — rerun after they land, or pass --force:")
|
|
223
|
+
for f in skipped:
|
|
224
|
+
print(f" {f}")
|
|
225
|
+
if changed:
|
|
226
|
+
_sync(root)
|
|
227
|
+
return 0
|