@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,254 @@
1
+ from pathlib import Path
2
+
3
+ from . import ids
4
+ from .tree import BACKLOG_END, BACKLOG_START, assets_dir, die, find_work_root, rel
5
+ from .model import locate
6
+ from .registry import (DOMAIN_ORDER, _citation_counts, definition_sites,
7
+ domains_for_owner, hosts, locate_domain, locate_system,
8
+ scan_systems, systems_for_code)
9
+ from .scaffold import _check_kebab
10
+ from .generate import _sync
11
+
12
+ #: The shipped charters, beside the other scaffold assets. A charter for one of the
13
+ #: known domains is AUTHORED PROSE about a boundary — which neighbour owns what, and
14
+ #: why the split is there — and that cannot be generated from a name. So the nine
15
+ #: ship as files and `domain-new`/`init` copy them; anything else gets the generic
16
+ #: stub, which is still a working domain.
17
+ #: Derived from `assets_dir()` rather than counted out again from this file: getting
18
+ #: it wrong is silent — `_charter` falls back to the generic stub, so every domain
19
+ #: still scaffolds and still declares itself, and the only symptom is that nine
20
+ #: authored charters quietly never ship.
21
+ ASSETS = assets_dir() / "domains"
22
+
23
+
24
+ def _charter(name: str, title: str) -> str:
25
+ """The shipped charter for `name`, or a generic stub.
26
+
27
+ `README.md`, not a bespoke `domain.md`: the landing doc of a folder should be
28
+ named what every reader, tool and folder view already looks for. The
29
+ `type: domain` frontmatter — not the filename — is what makes it a domain.
30
+
31
+ A domain hosts rules exactly as a system does, so every shape carries the
32
+ `## Rules` heading: a domain whose README has nowhere to put an id is a domain
33
+ whose ids get mis-filed into the nearest system.
34
+ """
35
+ shipped = ASSETS / f"{name}.md"
36
+ if shipped.is_file():
37
+ return shipped.read_text()
38
+ return (
39
+ f"---\ntype: domain\nid: {name}\n---\n\n# {title}\n\n## What belongs here\n\n"
40
+ f"_One paragraph: the kind of durable call this domain owns, and how you "
41
+ f"tell it from its neighbours._\n\n"
42
+ f"## Non-goals\n\n- _What lives elsewhere._\n\n"
43
+ f"## Rules\n\n<!-- ### {ids.LEDGER}-nn — _the rule, in one line_\n"
44
+ f" enforced_by: _…_ -->\n"
45
+ )
46
+
47
+
48
+ def cmd_domain_new(args) -> int:
49
+ root = find_work_root()
50
+ name = args["name"]
51
+ _check_kebab("domain", name)
52
+ if locate_domain(root, name):
53
+ die(f"a domain named '{name}' already exists")
54
+ folder = root / name
55
+ folder.mkdir(parents=True, exist_ok=True)
56
+ title = args.get("title") or name.replace("-", " ").capitalize()
57
+ (folder / "README.md").write_text(_charter(name, title))
58
+ shipped = " (shipped charter)" if (ASSETS / f"{name}.md").is_file() else ""
59
+ print(f"created {rel(folder / 'README.md', root)}{shipped}")
60
+ _sync(root)
61
+ return 0
62
+
63
+
64
+ #: The one file `_regen_readme` cannot work without, so `init` writes it first.
65
+ _README = f"""# Work
66
+
67
+ The board. Tasks live in three tiers — version, epic, task — beside the org-domains
68
+ that host every kind of durable call. `jarvis work list` prints what is moving;
69
+ `jarvis work where <id>` resolves any rule id to its home.
70
+
71
+ The table below is GENERATED. Edit the prose around it, never the block itself.
72
+
73
+ {BACKLOG_START}
74
+ {BACKLOG_END}
75
+ """
76
+
77
+ _ROADMAP = """# Roadmap
78
+
79
+ The release **order**, never the status — status is derived from the tree, and a
80
+ second copy of it here is a second copy to drift.
81
+
82
+ <!-- One line per planned version, in the order they open. A version is a RELEASE:
83
+ it states what a user can newly DO when it ships, and it cannot open while an
84
+ earlier one is unreleased. -->
85
+ """
86
+
87
+
88
+ def cmd_init(args) -> int:
89
+ """Scaffold a work tree: the board, the roadmap, and the nine org-domains.
90
+
91
+ **Idempotent, and never destructive.** It creates what is missing and skips what
92
+ exists, reporting both — because the realistic second run of `init` is a repo
93
+ that got half a tree and wants the rest, and an `init` that refuses outright
94
+ sends that user to `mkdir`. Nothing is ever overwritten, so a charter a repo has
95
+ edited stays edited.
96
+
97
+ All nine domains are created, including the ones that will hold no rules for a
98
+ long time. The cost of an empty charter is one file; the cost of a missing domain
99
+ is a rule mis-filed under whichever system happened to be nearby, which is the
100
+ failure the domain split exists to end.
101
+
102
+ The root comes from the caller (`--project` / `CLAUDE_PROJECT_DIR` / cwd) rather
103
+ than from `find_work_root`, which cannot help here: it looks for a `work/` that
104
+ does not exist yet.
105
+ """
106
+ project = Path(args["project_root"]).resolve()
107
+ if not project.is_dir():
108
+ die(f"project root '{project}' is not a directory")
109
+ root = project / "work"
110
+
111
+ made, skipped = [], []
112
+
113
+ def write(path: Path, text: str) -> None:
114
+ if path.exists():
115
+ skipped.append(rel(path, root))
116
+ return
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ path.write_text(text)
119
+ made.append(rel(path, root))
120
+
121
+ write(root / "README.md", _README)
122
+ write(root / "ROADMAP.md", _ROADMAP)
123
+ for d in ("versions", "backlog"):
124
+ (root / d).mkdir(parents=True, exist_ok=True)
125
+ # Reading order, not alphabetical — and read from config, so a repo that added a
126
+ # tenth domain gets it scaffolded too rather than having to remember to.
127
+ for name in DOMAIN_ORDER:
128
+ write(root / name / "README.md",
129
+ _charter(name, name.replace("-", " ").capitalize()))
130
+
131
+ print(f"initialised {rel(root, root) or 'work/'} at {root}")
132
+ if made:
133
+ print(f" created {len(made)}: {', '.join(made)}")
134
+ if skipped:
135
+ print(f" kept {len(skipped)} existing: {', '.join(skipped)}")
136
+ if not made:
137
+ print(" nothing to do — the tree was already complete")
138
+ _sync(root)
139
+ return 0
140
+ def cmd_system_new(args) -> int:
141
+ root = find_work_root()
142
+ name = args["name"]
143
+ _check_kebab("system", name)
144
+ if locate_system(root, name):
145
+ die(f"a system named '{name}' already exists")
146
+ code = [c.strip() for c in (args.get("code") or "").split(",") if c.strip()]
147
+ owned = {c: s.name for s in scan_systems(root) for c in s.code}
148
+ for c in code:
149
+ if c in owned:
150
+ die(f"code region '{c}' is already owned by system '{owned[c]}' — "
151
+ f"a region belongs to exactly one system")
152
+ md = root / "architecture" / f"{name}.md"
153
+ md.parent.mkdir(parents=True, exist_ok=True)
154
+ title = args.get("title") or name.replace("-", " ").capitalize()
155
+ md.write_text(
156
+ f"---\ntype: system\ncode: [{', '.join(code)}]\npaths: []\ndepends_on: []\n---\n\n"
157
+ f"# {title}\n\n## Purpose\n\n_What capability this owns, in two sentences._\n\n"
158
+ f"## Boundary\n\n**In** — _…_\n\n**Out** — _…_\n\n"
159
+ f"## Shape\n\n```mermaid\nflowchart TD\n A[\"…\"] --> B[\"…\"]\n```\n\n"
160
+ # The PROSE is the definition — `_parse_rules` reads these headings and the
161
+ # `enforced_by:` under each. The summary table this used to scaffold is
162
+ # deleted, not generated: a table three lines above the entry it summarises
163
+ # is one more thing to keep in sync.
164
+ #
165
+ # The word is RULE. Scaffolding "Invariants" here is how the retired word
166
+ # comes back one system at a time, however carefully the docs were renamed.
167
+ f"## Rules\n\n### {ids.LEDGER}-nn — _the rule, in one line_\nenforced_by: _…_\n\n"
168
+ f"**Rule.** _…_\n\n**Why.** _…_\n\n**Never.** _…_\n\n"
169
+ f"## Seams\n\n- _…_\n\n## Non-goals\n\n- _…_\n"
170
+ )
171
+ print(f"created {rel(md, root)}")
172
+ _sync(root)
173
+ return 0
174
+ def cmd_where(args) -> int:
175
+ """Resolve an id → its home, its `code:` regions, its `enforced_by` and how
176
+ many files cite it. Ids are permanent; the home is a lookup, never a
177
+ renumber."""
178
+ root = find_work_root()
179
+ id_ = args["id"].upper()
180
+ if not ids.ID_EXACT.match(id_):
181
+ die(f"'{args['id']}' is not a {ids.LEDGER}-nn id")
182
+ id_ = ids.normalise(id_)
183
+ sites = definition_sites(root).get(id_, [])
184
+ # Every HOST, not just systems: a rule about proof or shipping lives in its
185
+ # org-domain, and a `where` that only walked systems reported it as unhosted.
186
+ homes = [(home, name, rules, code) for home, name, rules, code in hosts(root)
187
+ if id_ in rules]
188
+
189
+ if not sites and not homes:
190
+ die(f"{id_} resolves to nothing — no `### {id_}` on any surface and no "
191
+ f"host declares it")
192
+
193
+ title = next((t for _, t in sites if t), "")
194
+ print(f" {id_} {title or '(no title)'}")
195
+ if homes:
196
+ for home, _, rules, code in homes:
197
+ print(f" home {home}")
198
+ if code:
199
+ print(f" code {', '.join(code)}")
200
+ print(f" enforced_by {rules[id_] or '⚠ none declared'}")
201
+ if len(homes) > 1:
202
+ print(f" ⚠ {len(homes)} docs host {id_} — an id has ONE home")
203
+ else:
204
+ print(f" home {sites[0][0]}")
205
+ if len(sites) > 1:
206
+ print(f" ⚠ defined on {len(sites)} surfaces "
207
+ f"({', '.join(h for h, _ in sites)}) — an id has ONE definition site")
208
+ n = _citation_counts(root).get(id_, 0)
209
+ print(f" cited by {n} file(s)")
210
+ return 0
211
+ def cmd_rules(args) -> int:
212
+ """The rules that constrain a task, DERIVED — `task.code ∩ system.code` for
213
+ systems, `task.owner` for domains. Run it while planning the EPIC, over the
214
+ union of its tasks' regions: a plan that trips one of these is the plan that
215
+ gets rewritten, and finding that out per-task is finding it out four times."""
216
+ root = find_work_root()
217
+ name = args.get("task")
218
+ if not name:
219
+ die("usage: jarvis work rules --task <name>")
220
+ t = locate(root, name)
221
+ if not t:
222
+ die(f"no task named '{name}'")
223
+ systems = systems_for_code(root, t.code) if t.code else []
224
+ domains = domains_for_owner(root, t.owner)
225
+ if not systems and not domains:
226
+ print(f" {name}: no `code:` regions and no resolvable `owner:` — "
227
+ f"nothing to derive from")
228
+ return 0
229
+ # Titles come from wherever the id is DEFINED. Reading only a central ledger
230
+ # printed a column of blanks for every id that had already moved to its host.
231
+ titles = {id_: next((t_ for _, t_ in s if t_), "")
232
+ for id_, s in definition_sites(root).items()}
233
+
234
+ if t.code:
235
+ print(f" code: [{', '.join(t.code)}] → "
236
+ f"system: {', '.join(s.name for s in systems) or '(none)'}")
237
+ if t.owner:
238
+ print(f" owner: {t.owner} → "
239
+ f"domain: {', '.join(d.name for d in domains) or '(none)'}")
240
+ by_home = {h[0]: h[2] for h in hosts(root)}
241
+ total = 0
242
+ for home, hit in ([(f"architecture/{s.name}", sorted(set(t.code) & set(s.code)))
243
+ for s in systems]
244
+ + [(f"{d.name}/README", [t.owner]) for d in domains]):
245
+ rules = by_home.get(home, {})
246
+ print(f"\n {home} (via {', '.join(hit)})")
247
+ for id_, evidence in sorted(rules.items(), key=lambda kv: ids.sort_key(kv[0])):
248
+ total += 1
249
+ mark = " " if evidence else "!"
250
+ print(f" {mark}{id_:7} {titles.get(id_, '')[:76]}")
251
+ if total:
252
+ print(f"\n ⚠ these {total} rules constrain your plan — cite them, or "
253
+ f"change the plan. `!` = no enforced_by, so nothing will catch you.")
254
+ return 0
@@ -0,0 +1,374 @@
1
+ """The decision boundary: what an unattended run may settle alone, and what it
2
+ parks for the founder.
3
+
4
+ Every load-bearing rule in `SKILL.md` was written for a session with a person in
5
+ the chair — *route every product, UX or architecture decision through the user*.
6
+ Run that same skill on a schedule and the rule does not fail loudly; it degrades
7
+ into *decide it yourself*, with no record that a call the founder owned was made
8
+ without them. That is the failure this module exists to make impossible.
9
+
10
+ Two mechanics, and they only work together:
11
+
12
+ **`tier:`** is what a task's blast radius IS — the axis the harness lacked, since
13
+ `code:` region count measured size and size is not risk. It is DERIVED where the
14
+ tree already knows the answer (an `owner:` of security/legal/commercial floors a
15
+ task at 3) and can be raised by hand, never lowered: a run that could talk its own
16
+ tier down is a run with no ceiling at all.
17
+
18
+ **`ask`** is what happens at the boundary. It writes the question, moves the task
19
+ to `blocked/`, and **exits 0** — so the instance takes other work instead of
20
+ stalling. `needs` is the founder's one screen; `answer` unblocks. Nothing here
21
+ decides anything: that is the point.
22
+ """
23
+ import re
24
+ import shutil
25
+ import sys
26
+ from datetime import date
27
+
28
+ from .tree import (BLOCKED, BUCKETS, DEFAULT_AUTONOMY_CEILING, TIER3_OWNERS,
29
+ TIER_MEANING, TIERS, die, find_work_root, rel)
30
+ from .frontmatter import as_list, parse_frontmatter, rewrite_file
31
+ from .model import locate, record_session, scan
32
+ from .generate import _sync
33
+ from . import events
34
+
35
+ #: `autonomy.ceiling` from config — the highest tier an unattended run acts on
36
+ #: alone. Set by `config.apply`, so a repo's answer is bound before any command
37
+ #: reads it.
38
+ CEILING = DEFAULT_AUTONOMY_CEILING
39
+
40
+
41
+ def derive_tier(owner: str, code: list, given=None) -> tuple:
42
+ """A task's tier, and the one-line reason for it.
43
+
44
+ The floor comes from the tree, so it cannot be forgotten the way a hand-set
45
+ field can; `given` may raise it and is refused if it tries to lower it. Returns
46
+ `(tier, why)` — `why` is printed on refusal, because a floor that will not say
47
+ which signal set it is a floor people argue with.
48
+ """
49
+ floor, why = 1, "default for an ordinary change"
50
+ owner_root = (owner or "").split("/")[0]
51
+ if owner_root in TIER3_OWNERS:
52
+ floor, why = 3, f"owner: {owner_root} — this domain is always tier 3"
53
+
54
+ if given is None:
55
+ return floor, why
56
+ try:
57
+ want = int(str(given).strip())
58
+ except ValueError:
59
+ die(f"--tier must be one of {', '.join(str(t) for t in TIERS)} (got {given!r})")
60
+ if want not in TIERS:
61
+ die(f"--tier must be one of {', '.join(str(t) for t in TIERS)} (got {want})")
62
+ if want < floor:
63
+ die(f"--tier {want} is below this task's derived floor of {floor} ({why}).\n"
64
+ f" A tier may be raised, never lowered — a run that can talk its "
65
+ f"own blast radius down has no ceiling at all.")
66
+ return want, ("as given" if want == floor else f"raised from {floor} ({why})")
67
+
68
+
69
+ def tier_of(task) -> int:
70
+ """The tier on a task, defaulting to its derived floor. Tolerates a task
71
+ written before tiers existed — every consumer must keep working on a tree that
72
+ predates this field, or adding it would strand every existing board."""
73
+ raw = str(task.fm.get("tier", "")).strip()
74
+ if raw:
75
+ try:
76
+ return int(raw)
77
+ except ValueError:
78
+ pass
79
+ return derive_tier(task.owner, task.code)[0]
80
+
81
+
82
+ def within_ceiling(task) -> bool:
83
+ """May an unattended run act on this alone?"""
84
+ return tier_of(task) <= CEILING
85
+
86
+
87
+ def _open_questions(task) -> list:
88
+ """Questions with no answer yet, oldest first.
89
+
90
+ `asked:` is an append-only transcript: a question is
91
+ `<date> <owner> <question> [options: …]`, and an answer is a later entry
92
+ beginning `answered`. So openness is not a property of a single line — it is
93
+ what is left when each answer is paired off against the longest-waiting
94
+ question. Reading it any other way (matching a prefix, trusting a flag) is how
95
+ an answered question keeps showing up in `needs` forever, which trains the one
96
+ person this list exists for to stop reading it.
97
+ """
98
+ out = []
99
+ for entry in as_list(task.fm.get("asked")):
100
+ s = str(entry).strip()
101
+ if s.startswith("answered"):
102
+ if out:
103
+ out.pop(0)
104
+ elif s:
105
+ out.append(s)
106
+ return out
107
+
108
+
109
+ def _reread(root, name):
110
+ """The task as it is on disk NOW — after a write this process just made.
111
+
112
+ `locate` returns a snapshot with the frontmatter parsed at scan time, and a caller
113
+ that has since written to the file is holding a stale one.
114
+ """
115
+ return locate(root, name)
116
+
117
+
118
+ def cmd_ask(args) -> int:
119
+ """Park a question and RETURN — the instance moves on to other work.
120
+
121
+ This is the anti-babysitting primitive. It never blocks the caller, never
122
+ guesses, and never lets a question be asked twice: the answer lands in the
123
+ tree, and `--durable` says it belongs in a domain rule instead."""
124
+ root = find_work_root()
125
+ name = args["name"]
126
+ question = (args.get("question") or "").strip()
127
+ if not question:
128
+ die("usage: jarvis work ask <task> --question \"…\" [--options \"a | b\"] "
129
+ "[--owner founder] [--durable]")
130
+ task = locate(root, name)
131
+ if not task:
132
+ die(f"no task named '{name}' found")
133
+ if task.in_backlog:
134
+ die(f"'{name}' is in backlog — a backlog task has no status to park. "
135
+ f"Pull it into a version first.")
136
+
137
+ owner = (args.get("owner") or "founder").strip()
138
+ options = (args.get("options") or "").strip()
139
+ durable = bool(args.get("durable"))
140
+
141
+ # A question already on this task, asked again, is a run going in circles —
142
+ # the exact loop an unattended shift can run all night without noticing.
143
+ for existing in _open_questions(task):
144
+ if question.lower() in existing.lower():
145
+ print(f"'{name}' already has this question open — not asking twice:")
146
+ print(f" {existing}")
147
+ return 0
148
+
149
+ entry = f"{date.today().isoformat()} {owner} {question}"
150
+ if options:
151
+ entry += f" [options: {options}]"
152
+ if durable:
153
+ entry += " [durable]"
154
+
155
+ was = task.status
156
+ dest = task.folder
157
+ if was != BLOCKED:
158
+ container = task.folder.parent.parent
159
+ dest = container / BLOCKED / name
160
+ if dest.exists():
161
+ die(f"{rel(dest, root)} already exists")
162
+ dest.parent.mkdir(parents=True, exist_ok=True)
163
+ shutil.move(str(task.folder), str(dest))
164
+
165
+ md = dest / "task.md"
166
+
167
+ def mutate(d):
168
+ d["asked"] = as_list(d.get("asked")) + [entry]
169
+ d["updated"] = date.today().isoformat()
170
+
171
+ rewrite_file(md, mutate)
172
+ record_session(dest)
173
+ events.append(root, "asked", name, question=question, owner=owner,
174
+ options=options or None, durable=durable or None)
175
+ if was != BLOCKED:
176
+ events.append(root, "blocked", name, was=was)
177
+ print(f"parked '{name}': {was}/ -> {BLOCKED}/")
178
+ print(f" asked {owner}: {question}")
179
+ if options:
180
+ print(f" options: {options}")
181
+ print(f" take other work — `jarvis work next` skips blocked tasks. "
182
+ f"`jarvis work needs` shows every open question.")
183
+ _sync(root)
184
+ return 0
185
+
186
+
187
+ def cmd_answer(args) -> int:
188
+ """Resolve the open question and put the task back to work."""
189
+ root = find_work_root()
190
+ name = args["name"]
191
+ choice = (args.get("choose") or args.get("answer") or "").strip()
192
+ if not choice:
193
+ die("usage: jarvis work answer <task> --choose \"…\"")
194
+ task = locate(root, name)
195
+ if not task:
196
+ die(f"no task named '{name}' found")
197
+ open_qs = _open_questions(task)
198
+ if not open_qs:
199
+ die(f"'{name}' has no open question — nothing to answer")
200
+
201
+ to = (args.get("to") or "queue").strip()
202
+ # Checked against the real buckets, because nothing scans a directory that is not
203
+ # one: `--to complte` used to create it, move the folder in, print success and
204
+ # exit 0, after which `locate` returned None and the item was off the board with
205
+ # no way back through the CLI.
206
+ if to not in BUCKETS:
207
+ die(f"--to {to!r} is not a bucket — one of {', '.join(BUCKETS)}")
208
+ # Completion runs a gate: the evidence has to exist, the criteria have to be met,
209
+ # and `completed:` has to be stamped. Answering a question is not that, and this
210
+ # door used to skip all of it — landing a task in `complete/` with no `completed:`
211
+ # and parking the whole epic as done behind it.
212
+ if to == "complete":
213
+ die("answering a question cannot complete a task — the completion gate has "
214
+ "to run. Answer it, then `jarvis work move <task> complete`.")
215
+ md = task.folder / "task.md"
216
+ answered = f"answered {date.today().isoformat()} {choice}"
217
+
218
+ def mutate(d):
219
+ d["asked"] = as_list(d.get("asked")) + [answered]
220
+ d["updated"] = date.today().isoformat()
221
+
222
+ rewrite_file(md, mutate)
223
+ events.append(root, "answered", name, choice=choice, question=open_qs[0])
224
+
225
+ # Re-read after the answer landed: a task asked two questions and answered one is
226
+ # still blocked on the other, and leaving `blocked/` here put it back in `queue/`
227
+ # where an unattended run could take it — the exact invariant the bucket holds.
228
+ still_open = _open_questions(_reread(root, name) or task)
229
+ dest = task.folder
230
+ if task.status == BLOCKED and still_open:
231
+ print(f"answered '{name}' — still blocked on {len(still_open)} more "
232
+ f"question(s), so it stays put:")
233
+ for q in still_open:
234
+ print(f" {q[:120]}")
235
+ print(f" {choice}")
236
+ _sync(root)
237
+ return 0
238
+ if task.status == BLOCKED:
239
+ container = task.folder.parent.parent
240
+ dest = container / to / name
241
+ if dest.exists():
242
+ die(f"{rel(dest, root)} already exists")
243
+ dest.parent.mkdir(parents=True, exist_ok=True)
244
+ shutil.move(str(task.folder), str(dest))
245
+ events.append(root, "moved", name, **{"from": BLOCKED, "to": to})
246
+ print(f"answered '{name}' -> {to}/")
247
+ else:
248
+ print(f"answered '{name}' (already in {task.status}/)")
249
+ print(f" {choice}")
250
+ if any("[durable]" in q for q in open_qs):
251
+ print(" NOTE this was asked as [durable] — promote the answer to "
252
+ "the domain that owns it (`jarvis work id-new --host <domain> --title …`) "
253
+ "so it is never asked again.")
254
+ _sync(root)
255
+ return 0
256
+
257
+
258
+ def cmd_needs(args) -> int:
259
+ """Every open question, on one screen. The founder's whole obligation.
260
+
261
+ Sorted oldest-first: a question waiting three days is more urgent than one
262
+ asked this morning, and a list that hides that is a list that trains people to
263
+ skim it."""
264
+ root = find_work_root()
265
+ s = scan(root)
266
+ rows = []
267
+ for v in s["versions"]:
268
+ for t in v.all_tasks():
269
+ for q in _open_questions(t):
270
+ rows.append((q.split(" ")[0], v.name, t, q))
271
+ for t in s["backlog"]:
272
+ for q in _open_questions(t):
273
+ rows.append((q.split(" ")[0], "backlog", t, q))
274
+
275
+ if not rows:
276
+ blocked = [t for v in s["versions"] for t in v.bucket(BLOCKED)]
277
+ if blocked:
278
+ print("nothing is waiting on you — but these are parked in blocked/ "
279
+ "with no question recorded, which is a bug in whatever parked them:")
280
+ for t in blocked:
281
+ print(f" {t.name} — {t.title}")
282
+ return 0
283
+ print("nothing is waiting on you.")
284
+ return 0
285
+
286
+ rows.sort(key=lambda r: (r[0], r[2].name))
287
+ today = date.today()
288
+ print(f"WAITING ON YOU ({len(rows)} question(s))\n")
289
+ for asked_on, where, t, q in rows:
290
+ parts = q.split(" ")
291
+ owner = parts[1] if len(parts) > 2 else "founder"
292
+ body = " ".join(parts[2:]) if len(parts) > 2 else q
293
+ age = ""
294
+ try:
295
+ days = (today - date.fromisoformat(asked_on)).days
296
+ age = f" · waiting {days}d" if days else " · asked today"
297
+ except ValueError:
298
+ pass
299
+ print(f" {t.name} [{where} · tier {tier_of(t)} · for {owner}{age}]")
300
+ print(f" {body}")
301
+ print(f" → jarvis work answer {t.name} --choose \"…\"\n")
302
+ return 0
303
+
304
+
305
+ def cmd_log(args) -> int:
306
+ """The raw record, oldest first."""
307
+ root = find_work_root()
308
+ rows = events.read(root, since=(args.get("since") or "").strip(),
309
+ name=(args.get("task") or "").strip())
310
+ if not rows:
311
+ print("no events recorded yet")
312
+ return 0
313
+ for e in rows:
314
+ # Under git the record also knows WHICH MACHINE — the thing a session id
315
+ # could never say, and the reason the file it replaced was not enough.
316
+ who = " [" + " @ ".join(p for p in (e.get("by", "")[:8], e.get("machine", "")) if p) + "]" \
317
+ if (e.get("by") or e.get("machine")) else ""
318
+ extra = {k: v for k, v in e.items()
319
+ if k not in ("ts", "event", "name", "by", "machine", "sha", "author")}
320
+ tail = (" " + " · ".join(f"{k}={v}" for k, v in sorted(extra.items()))) if extra else ""
321
+ print(f"{e['ts']} {e['event']:>14} {e['name']}{who}{tail}")
322
+ return 0
323
+
324
+
325
+ def cmd_digest(args) -> int:
326
+ """What happened, in the founder's terms. The morning read after a shift.
327
+
328
+ Deliberately reads the log and NOTHING else — it is the one view that is about
329
+ time, and mixing in current state is how a history file starts being consulted
330
+ for what is true now."""
331
+ root = find_work_root()
332
+ since = (args.get("since") or "").strip()
333
+ rows = events.read(root, since=since)
334
+ if not rows:
335
+ print(f"no events{f' since {since}' if since else ''} yet")
336
+ return 0
337
+
338
+ def names(kind):
339
+ return sorted({e["name"] for e in rows if e["event"] == kind})
340
+
341
+ shipped = names("completed")
342
+ asked = [e for e in rows if e["event"] == "asked"]
343
+ answered = names("answered")
344
+ refused = [e for e in rows if e["event"] == "gate-refused"]
345
+ released = names("released")
346
+ # What was WORKED ON is a task moving into in-progress, not a lease being taken.
347
+ # A claim is one way to arrive there and it is not the only one — a person who
348
+ # moved three tasks by hand read as an idle shift, and under git a claim is not
349
+ # recorded at all, because a hold belongs to a machine rather than to a branch.
350
+ worked = sorted({e["name"] for e in rows
351
+ if e["event"] == "claimed"
352
+ or (e["event"] == "moved" and e.get("to") == "in-progress")})
353
+
354
+ print(f"DIGEST {len(rows)} event(s)"
355
+ + (f" since {since}" if since else "") + "\n")
356
+ if released:
357
+ print(f" RELEASED {', '.join(released)}")
358
+ if shipped:
359
+ print(f" SHIPPED {len(shipped)}: {', '.join(shipped)}")
360
+ if worked:
361
+ print(f" WORKED {len(worked)}: {', '.join(worked)}")
362
+ if asked:
363
+ print(f" ASKED {len(asked)} question(s) — `jarvis work needs` to answer:")
364
+ for e in asked:
365
+ print(f" {e['name']}: {e.get('question', '')}")
366
+ if answered:
367
+ print(f" ANSWERED {', '.join(answered)}")
368
+ if refused:
369
+ print(f" GATE HELD {len(refused)} completion(s) refused:")
370
+ for e in refused:
371
+ print(f" {e['name']}: {e.get('why', '')}")
372
+ if not any((released, shipped, worked, asked, answered, refused)):
373
+ print(" nothing notable — no work was taken, and nothing was asked.")
374
+ return 0