@appchy/jarvis 0.1.37 → 0.1.39

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