@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,267 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from . import ids
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .tree import (BACKLOG_END, BACKLOG_START, DONE_TIER, SYSTEMS_END,
|
|
9
|
+
SYSTEMS_START, die, rel)
|
|
10
|
+
from .model import Task, Version, _ordered, scan
|
|
11
|
+
from .registry import rules_by_domain, scan_domains, scan_systems
|
|
12
|
+
from .lint import print_lint
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _relpath_from_readme(folder: Path, root: Path) -> str:
|
|
16
|
+
return str(folder.relative_to(root)).replace(os.sep, "/")
|
|
17
|
+
def _task_row(t: Task, root: Path) -> str:
|
|
18
|
+
link = f"[{t.name}]({_relpath_from_readme(t.md, root)})"
|
|
19
|
+
deps = ", ".join(t.depends_on) if t.depends_on else "—"
|
|
20
|
+
return (f"| {link} — {t.title} | {t.priority} | "
|
|
21
|
+
f"{t.display_status()} | {deps} |")
|
|
22
|
+
def _table(tasks: list, root: Path) -> str:
|
|
23
|
+
rows = ["| Task | Priority | Status | Depends on |", "|---|---|---|---|"]
|
|
24
|
+
rows += [_task_row(t, root) for t in tasks]
|
|
25
|
+
return "\n".join(rows)
|
|
26
|
+
def _epic_block(e, root: Path) -> str:
|
|
27
|
+
"""An epic's table under its own heading. `epic.md` is gone once the version
|
|
28
|
+
released, so the heading only links when there is something to link to."""
|
|
29
|
+
tasks = _ordered(e.tasks) if not e.in_backlog else sorted(
|
|
30
|
+
e.backlog_tasks, key=lambda t: (t.priority_rank(), t.name))
|
|
31
|
+
head = (f"[{e.title}]({_relpath_from_readme(e.md, root)})"
|
|
32
|
+
if e.planned else f"{e.title} _( planned; epic.md archived at release )_")
|
|
33
|
+
if not tasks:
|
|
34
|
+
return f"#### Epic — {head}\n\n_no tasks yet_"
|
|
35
|
+
return f"#### Epic — {head}\n\n" + _table(tasks, root)
|
|
36
|
+
def _version_section(v: Version, root: Path) -> str:
|
|
37
|
+
status = v.status()
|
|
38
|
+
badge = {
|
|
39
|
+
"released": f"released {v.released}",
|
|
40
|
+
"current": "current",
|
|
41
|
+
"planned": "planned" + (f" · target {v.target}" if v.target else ""),
|
|
42
|
+
}[status]
|
|
43
|
+
link = f"[{v.title}]({_relpath_from_readme(v.md, root)})"
|
|
44
|
+
head = f"### Version — {link} _( {badge} )_"
|
|
45
|
+
if v.outcome:
|
|
46
|
+
head += f"\n\n{v.outcome}"
|
|
47
|
+
parts = []
|
|
48
|
+
# Loose tasks first — the pre-epic shape, still rendered so grandfathered
|
|
49
|
+
# versions read normally — then one block per epic.
|
|
50
|
+
loose = _ordered(v.tasks)
|
|
51
|
+
if loose:
|
|
52
|
+
parts.append(_table(loose, root))
|
|
53
|
+
parts += [_epic_block(e, root) for e in v.epics]
|
|
54
|
+
if not parts:
|
|
55
|
+
return f"{head}\n\n_no tasks yet_"
|
|
56
|
+
return head + "\n\n" + "\n\n".join(parts)
|
|
57
|
+
def _regen_readme(root: Path) -> str:
|
|
58
|
+
"""Rebuild the README backlog table between its markers. Returns a status
|
|
59
|
+
line. Raises via die() when the file or its markers are missing."""
|
|
60
|
+
readme = root / "README.md"
|
|
61
|
+
# SELF-HEALING, not fatal — the same call already made for the SYSTEMS markers,
|
|
62
|
+
# and for the same reason. `_sync` runs at the END of every mutating command, so
|
|
63
|
+
# dying here reports failure *after* the mutation has landed: `version-new` in a
|
|
64
|
+
# tree without this file created the version, printed an error and exited 1. A
|
|
65
|
+
# generator that owns a block can create the block it owns.
|
|
66
|
+
if not readme.is_file():
|
|
67
|
+
readme.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
readme.write_text(f"# Work\n\n{BACKLOG_START}\n{BACKLOG_END}\n")
|
|
69
|
+
text = readme.read_text()
|
|
70
|
+
if BACKLOG_START not in text or BACKLOG_END not in text:
|
|
71
|
+
text = text.rstrip() + f"\n\n{BACKLOG_START}\n{BACKLOG_END}\n"
|
|
72
|
+
readme.write_text(text)
|
|
73
|
+
|
|
74
|
+
s = scan(root)
|
|
75
|
+
sections = []
|
|
76
|
+
|
|
77
|
+
for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
|
|
78
|
+
sections.append(_version_section(v, root))
|
|
79
|
+
|
|
80
|
+
if s["backlog"]:
|
|
81
|
+
# The backlog is where epics are PLANNED — a version is a release and is
|
|
82
|
+
# expensive to open, so an epic waits here until its cut can.
|
|
83
|
+
blocks = [_epic_block(e, root) for e in s["backlog_epics"]]
|
|
84
|
+
loose = sorted((t for t in s["backlog"] if not t.epic),
|
|
85
|
+
key=lambda x: (x.priority_rank(), x.name))
|
|
86
|
+
if loose:
|
|
87
|
+
blocks.append(_table(loose, root))
|
|
88
|
+
sections.append(
|
|
89
|
+
"### Backlog _( unscheduled — pull an epic into a version to start )_"
|
|
90
|
+
"\n\n" + "\n\n".join(blocks)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# The domain map, in READING ORDER, above the board. `scan_domains` sorts by
|
|
94
|
+
# `domains.order` from config, so this block and `list` cannot disagree — and a
|
|
95
|
+
# repo that adds a tenth domain gets it here with no code change.
|
|
96
|
+
domains = scan_domains(root)
|
|
97
|
+
if domains:
|
|
98
|
+
rows = ["### The domains", "",
|
|
99
|
+
"Where a durable call goes, in reading order — what it does → how it's",
|
|
100
|
+
"built → how it looks → how we know it's good → what keeps it safe → how we",
|
|
101
|
+
"run it → how users get unblocked → how we reach and earn → what binds us.",
|
|
102
|
+
"", "| Domain | Rules |", "|---|---|"]
|
|
103
|
+
counts = rules_by_domain(root)
|
|
104
|
+
for d in domains:
|
|
105
|
+
rows.append(f"| [{d.name}]({d.name}/README.md) | {counts.get(d.name) or '—'} |")
|
|
106
|
+
sections.insert(0, "\n".join(rows))
|
|
107
|
+
|
|
108
|
+
block = "\n\n".join(sections) if sections else "_backlog is empty_"
|
|
109
|
+
# The replacement is a CALLABLE so that what goes in comes back out. A title is
|
|
110
|
+
# something a person typed, and as a replacement template `\d` is a bad escape
|
|
111
|
+
# that raises — after the task folder is already on disk, so the tree is mutated,
|
|
112
|
+
# the command dies, and every later command that regenerates this file dies too.
|
|
113
|
+
# `\n` and `\t` do not raise; they quietly become a real newline in the table.
|
|
114
|
+
new_text = re.sub(
|
|
115
|
+
re.escape(BACKLOG_START) + r".*?" + re.escape(BACKLOG_END),
|
|
116
|
+
lambda _: BACKLOG_START + "\n" + block + "\n" + BACKLOG_END,
|
|
117
|
+
text,
|
|
118
|
+
flags=re.DOTALL,
|
|
119
|
+
)
|
|
120
|
+
if new_text != text:
|
|
121
|
+
readme.write_text(new_text)
|
|
122
|
+
total_tasks = (sum(len(v.all_tasks()) for v in s["versions"])
|
|
123
|
+
+ len(s["backlog"]))
|
|
124
|
+
return (f"updated {rel(readme, root)} "
|
|
125
|
+
f"({len(s['versions'])} version(s), {total_tasks} task(s))")
|
|
126
|
+
return "README.md backlog table already up to date"
|
|
127
|
+
def _regen_architecture_readme(root: Path) -> str:
|
|
128
|
+
"""Rewrite the system index + mermaid dependency map INSIDE
|
|
129
|
+
`architecture/README.md`'s `<!-- SYSTEMS -->` markers. Both are DERIVED — a
|
|
130
|
+
hand-drawn architecture diagram is the most reliably stale artifact a repo
|
|
131
|
+
can own, and a hand-kept index is the second.
|
|
132
|
+
|
|
133
|
+
A marked block rather than a whole generated file, because `README.md` is
|
|
134
|
+
also the domain's own landing doc: one file per folder, so a reader never
|
|
135
|
+
has to work out which of two entry docs is authoritative. The generator owns
|
|
136
|
+
its block and nothing else — the same split `work/README.md` already uses
|
|
137
|
+
for the backlog table."""
|
|
138
|
+
arch = root / "architecture"
|
|
139
|
+
systems = scan_systems(root)
|
|
140
|
+
readme = arch / "README.md"
|
|
141
|
+
if not arch.is_dir() or not systems or not readme.is_file():
|
|
142
|
+
return "no systems yet — architecture/README.md not generated"
|
|
143
|
+
text = readme.read_text()
|
|
144
|
+
if SYSTEMS_START not in text or SYSTEMS_END not in text:
|
|
145
|
+
# SELF-HEALING, not fatal. This used to `die()`, which made a fresh repo
|
|
146
|
+
# unusable: `domain-new architecture` scaffolds this README without the
|
|
147
|
+
# markers, so the very first `system-new` hard-failed on a file the tool
|
|
148
|
+
# itself had just written. The generated block is the generator's business
|
|
149
|
+
# — if there is no marked block to own, it appends one rather than
|
|
150
|
+
# demanding the author hand-place two HTML comments they were never told
|
|
151
|
+
# about. Only the block between the markers is ever rewritten, so an
|
|
152
|
+
# author's own prose above it is untouched either way.
|
|
153
|
+
text = text.rstrip() + f"\n\n## The systems\n\n{SYSTEMS_START}\n{SYSTEMS_END}\n"
|
|
154
|
+
readme.write_text(text)
|
|
155
|
+
|
|
156
|
+
names = {s.name for s in systems}
|
|
157
|
+
lines = [
|
|
158
|
+
"A **system** is a capability boundary with durable rules: it owns `code:`",
|
|
159
|
+
"regions and hosts the rules that govern them. Deliberately not",
|
|
160
|
+
"one per package — packages are derivable from the file tree.", "",
|
|
161
|
+
"| System | Owns (`code:`) | Rules | Depends on |", "|---|---|---|---|",
|
|
162
|
+
]
|
|
163
|
+
for s in systems:
|
|
164
|
+
unenforced = sum(1 for v in s.rules.values() if not v)
|
|
165
|
+
n = f"{len(s.rules)}" + (f" ({unenforced} unenforced)" if unenforced else "")
|
|
166
|
+
lines.append(f"| [{s.title}]({s.name}.md) | "
|
|
167
|
+
f"{', '.join(f'`{c}`' for c in s.code)} | {n} | "
|
|
168
|
+
f"{', '.join(s.depends_on) or '—'} |")
|
|
169
|
+
|
|
170
|
+
lines += ["", "## How they depend", "", "```mermaid", "flowchart TD"]
|
|
171
|
+
for s in systems:
|
|
172
|
+
lines.append(f' {s.name.replace("-", "_")}["{s.title}"]')
|
|
173
|
+
for s in systems:
|
|
174
|
+
for dep in s.depends_on:
|
|
175
|
+
if dep in names:
|
|
176
|
+
lines.append(f' {s.name.replace("-", "_")} --> '
|
|
177
|
+
f'{dep.replace("-", "_")}')
|
|
178
|
+
lines += ["```", "",
|
|
179
|
+
"An arrow means *depends on*. A cycle here is a design smell, not a",
|
|
180
|
+
"drawing choice — it means two systems can't be reasoned about apart."]
|
|
181
|
+
|
|
182
|
+
new_text = re.sub(
|
|
183
|
+
re.escape(SYSTEMS_START) + r".*?" + re.escape(SYSTEMS_END),
|
|
184
|
+
lambda _: SYSTEMS_START + "\n" + "\n".join(lines) + "\n" + SYSTEMS_END,
|
|
185
|
+
text,
|
|
186
|
+
flags=re.DOTALL,
|
|
187
|
+
)
|
|
188
|
+
if new_text == text:
|
|
189
|
+
return "architecture/README.md already up to date"
|
|
190
|
+
readme.write_text(new_text)
|
|
191
|
+
return f"updated {rel(readme, root)} ({len(systems)} systems)"
|
|
192
|
+
def _regen_decisions_index(root: Path) -> str:
|
|
193
|
+
"""Rebuild a legacy DECISIONS.md's Index from its `### <id> — …` headings, so
|
|
194
|
+
hand-maintained lines stop being a thing anyone syncs.
|
|
195
|
+
|
|
196
|
+
REFUSES when the existing index says MORE than the headings do. Today many
|
|
197
|
+
index lines carry annotations the heading lacks (a bare title in the
|
|
198
|
+
heading vs `… : Brilliant-style, AI-authored-on-the-fly learning for any topic
|
|
199
|
+
(expanded by <another id>)` in the index), so generating would silently delete
|
|
200
|
+
rationale. Phase 2b re-authors each entry with a self-sufficient heading;
|
|
201
|
+
until then this stays inert on purpose — a generator that destroys content is
|
|
202
|
+
worse than the hand-sync it replaces."""
|
|
203
|
+
md = root / "DECISIONS.md"
|
|
204
|
+
if not md.is_file():
|
|
205
|
+
return "no DECISIONS.md"
|
|
206
|
+
text = md.read_text()
|
|
207
|
+
m = re.search(r"^## Index\s*$(.*?)(?=^###\s|\Z)", text, re.MULTILINE | re.DOTALL)
|
|
208
|
+
if not m:
|
|
209
|
+
return "DECISIONS.md has no ## Index section — not generated"
|
|
210
|
+
headings = dict(ids.titled_heading(3).findall(text))
|
|
211
|
+
existing = dict(ids.index_entry().findall(m.group(1)))
|
|
212
|
+
richer = [id_ for id_, line in existing.items()
|
|
213
|
+
if len(line.strip()) > len(headings.get(id_, "").strip()) + 8]
|
|
214
|
+
if richer:
|
|
215
|
+
return (f"DECISIONS.md index NOT regenerated — {len(richer)} index line(s) "
|
|
216
|
+
f"say more than their `###` heading; generating would delete that "
|
|
217
|
+
f"text (Phase 2b makes headings self-sufficient)")
|
|
218
|
+
|
|
219
|
+
block = ("\n\n<!-- GENERATED by jarvis work — do not hand-edit. Edit the entries. -->\n\n"
|
|
220
|
+
+ "\n".join(f"- **{id_}** — {t.strip()}" for id_, t in headings.items())
|
|
221
|
+
+ "\n\n")
|
|
222
|
+
new_text = text[:m.start(1)] + block + text[m.end(1):]
|
|
223
|
+
if new_text == text:
|
|
224
|
+
return "DECISIONS.md index already up to date"
|
|
225
|
+
md.write_text(new_text)
|
|
226
|
+
return f"updated {rel(md, root)} index ({len(headings)} entries)"
|
|
227
|
+
def settle_epic_tier(root: Path, include_released: bool = False) -> list:
|
|
228
|
+
"""Put every epic in the tier its own TASKS put it in, and report what moved.
|
|
229
|
+
|
|
230
|
+
This is what keeps `<v>/complete/` from being a second, hand-maintained opinion
|
|
231
|
+
about status. The fact is derived — an epic is done when all its tasks are —
|
|
232
|
+
and this materializes it in BOTH directions on every mutation, so reopening one
|
|
233
|
+
task lifts its epic straight back onto the board. Nothing is ever parked by
|
|
234
|
+
hand, and nothing can sit in `complete/` disagreeing with what it contains.
|
|
235
|
+
|
|
236
|
+
Released versions are skipped: they are a record, not a board, and `release`
|
|
237
|
+
flattens the tier before stamping so an archived cut keeps a flat epic list."""
|
|
238
|
+
moved = []
|
|
239
|
+
for v in scan(root)["versions"]:
|
|
240
|
+
if v.released and not include_released:
|
|
241
|
+
continue
|
|
242
|
+
for e in v.epics:
|
|
243
|
+
done = e.is_done()
|
|
244
|
+
if done == e.done_tier:
|
|
245
|
+
continue
|
|
246
|
+
dest = (v.folder / DONE_TIER / e.name) if done else (v.folder / e.name)
|
|
247
|
+
if dest.exists():
|
|
248
|
+
continue
|
|
249
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
shutil.move(str(e.folder), str(dest))
|
|
251
|
+
moved.append((e.name, "complete" if done else "in flight"))
|
|
252
|
+
# Leave no empty `complete/` behind — an empty tier reads as a claim that
|
|
253
|
+
# something finished here.
|
|
254
|
+
tier = v.folder / DONE_TIER
|
|
255
|
+
if tier.is_dir() and not any(tier.iterdir()):
|
|
256
|
+
tier.rmdir()
|
|
257
|
+
return moved
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _sync(root: Path):
|
|
261
|
+
"""Post-mutation housekeeping: regenerate every derived artifact, surface lint."""
|
|
262
|
+
for name, where in settle_epic_tier(root):
|
|
263
|
+
print(f"epic '{name}' -> {where}")
|
|
264
|
+
print(_regen_readme(root))
|
|
265
|
+
print(_regen_architecture_readme(root))
|
|
266
|
+
print(_regen_decisions_index(root))
|
|
267
|
+
print_lint(root)
|