@appchy/jarvis 0.1.36 → 0.1.39

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