@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.
- 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 +10104 -37174
- 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 -38718
- 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,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
|