@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,507 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from . import peers, tree
|
|
7
|
+
# `TASK_TAGS_OK` is deliberately NOT imported by name: `config.apply` binds it on
|
|
8
|
+
# the `tree` module, and a `from … import` captures the value at import time, so
|
|
9
|
+
# the name here would still hold the pre-config default. Read through the module.
|
|
10
|
+
from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
11
|
+
from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, split_frontmatter
|
|
12
|
+
from .model import current_session_id, locate, locate_epic, locate_version, record_session, scan
|
|
13
|
+
from .registry import code_vocabulary
|
|
14
|
+
from .scaffold import _check_kebab, _check_owner_ref, _check_priority, _check_unused, _scaffold_handoff, _scaffold_task
|
|
15
|
+
from .epic import _bucketed, cmd_epic_move, epic_for_task
|
|
16
|
+
from .generate import _sync
|
|
17
|
+
from .gate import delivery_gate, report_gate
|
|
18
|
+
from .autonomy import derive_tier
|
|
19
|
+
from . import events
|
|
20
|
+
|
|
21
|
+
#: Where this repo keeps approved plan files, from `plans.dir` in config. None means
|
|
22
|
+
#: the repo keeps none, and `plan` then requires an explicit `--file` rather than
|
|
23
|
+
#: inventing a directory the repo deliberately does not have.
|
|
24
|
+
PLANS_DIR = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_new(args) -> int:
|
|
28
|
+
root = find_work_root()
|
|
29
|
+
name = args["name"]
|
|
30
|
+
_check_kebab("task", name)
|
|
31
|
+
_check_unused(root, name)
|
|
32
|
+
|
|
33
|
+
priority = args.get("priority") or "P2"
|
|
34
|
+
# A bare `--priority` (no value) parses to "true" (see parse_argv); treat it
|
|
35
|
+
# as P1 ("next") — the natural meaning of flagging a task as a priority.
|
|
36
|
+
# Otherwise the check below rejects "true" and the whole `new` fails, a
|
|
37
|
+
# common scripted slip.
|
|
38
|
+
if priority == "true":
|
|
39
|
+
priority = "P1"
|
|
40
|
+
_check_priority(priority)
|
|
41
|
+
title = args.get("title") or name.replace("-", " ").capitalize()
|
|
42
|
+
depends = [d.strip() for d in (args.get("depends") or "").split(",") if d.strip()]
|
|
43
|
+
for d in depends:
|
|
44
|
+
_check_kebab("dependency", d)
|
|
45
|
+
tags = [t.strip() for t in (args.get("tags") or "").split(",") if t.strip()]
|
|
46
|
+
# An empty vocabulary means the repo configured none, so there is nothing to
|
|
47
|
+
# check against and every tag stands. Refusing here instead would make `tags:`
|
|
48
|
+
# unusable in any repo that had not opted in.
|
|
49
|
+
for t in tags:
|
|
50
|
+
if tree.TASK_TAGS_OK and t not in tree.TASK_TAGS_OK:
|
|
51
|
+
die(f"unknown tag '{t}' — area tags are: {', '.join(tree.TASK_TAGS_OK)} "
|
|
52
|
+
f"(set `tags.allowed` in .claude/work.config.json to change them)")
|
|
53
|
+
owner = (args.get("owner") or args.get("product") or "").strip()
|
|
54
|
+
if owner:
|
|
55
|
+
_check_owner_ref(owner)
|
|
56
|
+
code = [c.strip() for c in (args.get("code") or "").split(",") if c.strip()]
|
|
57
|
+
vocabulary = code_vocabulary(root)
|
|
58
|
+
for c in code:
|
|
59
|
+
if c not in vocabulary:
|
|
60
|
+
die(f"unknown code region '{c}' — regions are: "
|
|
61
|
+
f"{', '.join(vocabulary)}")
|
|
62
|
+
covers = [c.strip() for c in (args.get("covers") or "").split(",") if c.strip()]
|
|
63
|
+
for c in covers:
|
|
64
|
+
if not re.match(r"^AC-\d+$", c):
|
|
65
|
+
die(f"--covers entries must look like 'AC-01' (got '{c}')")
|
|
66
|
+
# Derived from the tree first, raisable by hand, never lowerable — so a task
|
|
67
|
+
# whose owner makes it tier 3 is tier 3 whether or not anyone remembered.
|
|
68
|
+
tier, tier_why = derive_tier(owner, code, args.get("tier"))
|
|
69
|
+
start = (args.get("start") or "").strip()
|
|
70
|
+
end = (args.get("end") or "").strip()
|
|
71
|
+
if start and not re.match(r"^\d{4}-\d{2}-\d{2}$", start):
|
|
72
|
+
die("--start must be YYYY-MM-DD")
|
|
73
|
+
if end and not re.match(r"^\d{4}-\d{2}-\d{2}$", end):
|
|
74
|
+
die("--end must be YYYY-MM-DD")
|
|
75
|
+
if start and end and start > end:
|
|
76
|
+
die(f"--start ({start}) must be <= --end ({end})")
|
|
77
|
+
|
|
78
|
+
# Every task names an EPIC. A task is one goal end-to-end; an epic is the
|
|
79
|
+
# coherent goal it serves — work that belongs to no goal is exactly the
|
|
80
|
+
# scattered-task failure the epic tier was created to end, so this is a
|
|
81
|
+
# refusal, not a lint.
|
|
82
|
+
version_name = args.get("version")
|
|
83
|
+
epic, version_name = epic_for_task(root, args, version_name)
|
|
84
|
+
if not epic:
|
|
85
|
+
die(f"--epic is required: a task belongs to an epic, never loose in a "
|
|
86
|
+
f"version. Create one first — jarvis work epic-new <epic>"
|
|
87
|
+
+ (f" --version {version_name}" if version_name else "")
|
|
88
|
+
+ " (no --version puts it in the backlog)")
|
|
89
|
+
if version_name:
|
|
90
|
+
version = locate_version(root, version_name)
|
|
91
|
+
if version and version.released:
|
|
92
|
+
die(f"version '{version_name}' is released — pick a planned version")
|
|
93
|
+
folder = _bucketed(epic, name)
|
|
94
|
+
home = f"epic '{epic.name}' in " + (f"version '{version_name}'"
|
|
95
|
+
if version_name else "backlog")
|
|
96
|
+
|
|
97
|
+
_scaffold_task(folder, title, priority, depends)
|
|
98
|
+
md = folder / "task.md"
|
|
99
|
+
|
|
100
|
+
# `tier` is written unconditionally — it is the field an unattended run reads
|
|
101
|
+
# to decide whether the task is its to take, and an absent one would fall back
|
|
102
|
+
# to a derivation every reader would have to repeat.
|
|
103
|
+
def mutate(d):
|
|
104
|
+
d["tier"] = tier
|
|
105
|
+
if tags:
|
|
106
|
+
d["tags"] = tags
|
|
107
|
+
if owner:
|
|
108
|
+
d["owner"] = owner
|
|
109
|
+
if code:
|
|
110
|
+
d["code"] = code
|
|
111
|
+
if covers:
|
|
112
|
+
d["covers"] = covers
|
|
113
|
+
if start:
|
|
114
|
+
d["start"] = start
|
|
115
|
+
if end:
|
|
116
|
+
d["end"] = end
|
|
117
|
+
|
|
118
|
+
rewrite_file(md, mutate)
|
|
119
|
+
record_session(folder)
|
|
120
|
+
events.append(root, "created", name, tier=tier, priority=priority,
|
|
121
|
+
epic=epic.name)
|
|
122
|
+
print(f"created {rel(folder / 'task.md', root)} ({home})")
|
|
123
|
+
print(f" tier {tier} — {tier_why}")
|
|
124
|
+
_sync(root)
|
|
125
|
+
return 0
|
|
126
|
+
def cmd_place(args) -> int:
|
|
127
|
+
"""Put work where it belongs — into a version, or back out into the backlog.
|
|
128
|
+
|
|
129
|
+
ONE verb, because there was never more than one intent. This was two commands:
|
|
130
|
+
`pull` moved work out of the backlog and `rehome` moved it between versions, so
|
|
131
|
+
which one you needed depended on where the work already WAS — a fact the caller
|
|
132
|
+
normally has to go and look up before it can even choose a command. The
|
|
133
|
+
destination is what a caller knows; the journey is the harness's business.
|
|
134
|
+
|
|
135
|
+
The NAME decides the tier, as it always did: an epic moves whole, a single task
|
|
136
|
+
needs `--epic` to say where it lands.
|
|
137
|
+
"""
|
|
138
|
+
root = find_work_root()
|
|
139
|
+
name = args["name"]
|
|
140
|
+
to_backlog = str(args.get("backlog", "")).lower() == "true"
|
|
141
|
+
version_name = args.get("version")
|
|
142
|
+
if to_backlog and version_name:
|
|
143
|
+
die("--backlog and --version are two different destinations — pick one")
|
|
144
|
+
if not to_backlog and not version_name:
|
|
145
|
+
die("usage: jarvis work place <name> --version <v> [--epic <e>] "
|
|
146
|
+
"(or --backlog --epic <e> to take it back out of a cut)")
|
|
147
|
+
|
|
148
|
+
if locate_epic(root, name):
|
|
149
|
+
return cmd_epic_move(args)
|
|
150
|
+
if to_backlog:
|
|
151
|
+
return _to_backlog(root, name, args)
|
|
152
|
+
|
|
153
|
+
version = locate_version(root, version_name)
|
|
154
|
+
if not version:
|
|
155
|
+
die(f"no version named '{version_name}' — create it with: "
|
|
156
|
+
f"jarvis work version-new {version_name} --outcome '…'")
|
|
157
|
+
if version.released:
|
|
158
|
+
die(f"version '{version_name}' is released — pick a planned version")
|
|
159
|
+
|
|
160
|
+
task = locate(root, name)
|
|
161
|
+
if not task:
|
|
162
|
+
die(f"no task or epic named '{name}' found")
|
|
163
|
+
if task.version == version.name:
|
|
164
|
+
die(f"'{name}' is already in version '{version_name}'")
|
|
165
|
+
if task.status == "complete":
|
|
166
|
+
die(f"'{name}' is complete — it shipped in '{task.version}' and stays "
|
|
167
|
+
f"there. What a release delivered is a matter of record, and a record "
|
|
168
|
+
f"that can be edited afterwards cannot answer what any release "
|
|
169
|
+
f"contained.")
|
|
170
|
+
if task.status == "in-progress":
|
|
171
|
+
print(f"note: '{name}' is in progress — somebody may be working it right "
|
|
172
|
+
f"now, and this moves the ground under them.")
|
|
173
|
+
|
|
174
|
+
epic, _ = epic_for_task(root, args, version_name)
|
|
175
|
+
if not epic:
|
|
176
|
+
die(f"placing a single task needs --epic: it lands inside an epic of "
|
|
177
|
+
f"'{version_name}', never loose. Epics in that version: "
|
|
178
|
+
f"{', '.join(e.name for e in version.epics) or 'none yet'}")
|
|
179
|
+
dest = epic.folder / "queue" / name
|
|
180
|
+
if dest.exists():
|
|
181
|
+
die(f"{rel(dest, root)} already exists")
|
|
182
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
shutil.move(str(task.folder), str(dest))
|
|
184
|
+
md = dest / "task.md"
|
|
185
|
+
rewrite_file(
|
|
186
|
+
md,
|
|
187
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
188
|
+
)
|
|
189
|
+
record_session(dest)
|
|
190
|
+
print(f"placed '{name}' -> {rel(dest, root)}")
|
|
191
|
+
_sync(root)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _to_backlog(root, name: str, args) -> int:
|
|
196
|
+
"""Take work back OUT of a cut, into a backlog epic.
|
|
197
|
+
|
|
198
|
+
The return door, and it was missing until 2026-09-03. `pull` brought work into
|
|
199
|
+
a version and `rehome` moved it between versions; nothing took it out. So the
|
|
200
|
+
only legal answers to *this turned out not to belong in this release* were leave
|
|
201
|
+
it there or finish it, and a cut that can only grow can never be released —
|
|
202
|
+
measured here at 155 tasks in one version with 62 unstarted, which is the exact
|
|
203
|
+
failure `version_new`'s refusal was written to prevent from the other end.
|
|
204
|
+
|
|
205
|
+
**Only unstarted work leaves.** A completed task shipped in this cut and that is
|
|
206
|
+
the historical record; an in-progress one has somebody on it and moving the
|
|
207
|
+
ground under them is not a board operation. Both are refused rather than
|
|
208
|
+
silently skipped, because a demotion that quietly left half the work behind is
|
|
209
|
+
how a caller learns the wrong thing about what just happened.
|
|
210
|
+
"""
|
|
211
|
+
epic_name = (args.get("epic") or "").strip()
|
|
212
|
+
if not epic_name:
|
|
213
|
+
die("taking work out of a cut needs --epic: it lands in a BACKLOG epic, "
|
|
214
|
+
"never loose. Make one first with `jarvis work epic-new <name>` (no "
|
|
215
|
+
"--version puts it in the backlog, which is what you want here).")
|
|
216
|
+
epic = locate_epic(root, epic_name)
|
|
217
|
+
if not epic:
|
|
218
|
+
die(f"no epic named '{epic_name}' found")
|
|
219
|
+
if not epic.in_backlog:
|
|
220
|
+
die(f"epic '{epic_name}' is in version '{epic.version}', not the backlog — "
|
|
221
|
+
f"moving work there is `rehome <name> --version {epic.version} "
|
|
222
|
+
f"--epic {epic_name}`")
|
|
223
|
+
|
|
224
|
+
task = locate(root, name)
|
|
225
|
+
if not task:
|
|
226
|
+
die(f"no task or epic named '{name}' found")
|
|
227
|
+
if task.in_backlog:
|
|
228
|
+
die(f"'{name}' is already in the backlog")
|
|
229
|
+
if task.status == "complete":
|
|
230
|
+
die(f"'{name}' is complete — it shipped in '{task.version}' and stays "
|
|
231
|
+
f"there. What a release delivered is a matter of record, and a record "
|
|
232
|
+
f"that can be edited afterwards cannot answer what any release "
|
|
233
|
+
f"contained.")
|
|
234
|
+
if task.status == "in-progress":
|
|
235
|
+
# A warning, not a refusal, and the same one the version-to-version path
|
|
236
|
+
# gives: the rule is about what SHIPPED, and taking unfinished work out of a
|
|
237
|
+
# cut is exactly what you do when it turns out not to belong there. A
|
|
238
|
+
# stricter guard here than there would only mean the way out is the one
|
|
239
|
+
# direction you cannot use.
|
|
240
|
+
print(f"note: '{name}' is in progress — somebody may be working it right "
|
|
241
|
+
f"now, and this moves the ground under them.")
|
|
242
|
+
|
|
243
|
+
dest = epic.folder / name
|
|
244
|
+
if dest.exists():
|
|
245
|
+
die(f"{rel(dest, root)} already exists")
|
|
246
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
shutil.move(str(task.folder), str(dest))
|
|
248
|
+
md = dest / "task.md"
|
|
249
|
+
rewrite_file(
|
|
250
|
+
md,
|
|
251
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
252
|
+
)
|
|
253
|
+
print(f"took '{name}' out of the cut -> {rel(dest, root)}")
|
|
254
|
+
_sync(root)
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_move(args) -> int:
|
|
259
|
+
root = find_work_root()
|
|
260
|
+
name = args["name"]
|
|
261
|
+
to = args["status"]
|
|
262
|
+
if to not in BUCKETS:
|
|
263
|
+
die(f"status must be one of {', '.join(BUCKETS)}")
|
|
264
|
+
if to == BLOCKED:
|
|
265
|
+
die(f"`move {name} blocked` is not how a task blocks — a blocked task "
|
|
266
|
+
f"without a recorded question is one nobody can unblock. Use "
|
|
267
|
+
f"`jarvis work ask {name} --question \"…\"`.")
|
|
268
|
+
|
|
269
|
+
task = locate(root, name)
|
|
270
|
+
if not task:
|
|
271
|
+
die(f"no task named '{name}' found")
|
|
272
|
+
if task.in_backlog:
|
|
273
|
+
die(f"'{name}' is in backlog — pull it into a version first "
|
|
274
|
+
f"(jarvis work place {name} --version <v>)")
|
|
275
|
+
if task.status == to:
|
|
276
|
+
print(f"'{name}' is already in {to}/")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
# Somebody else is on this. It WARNS rather than refuses: `next` is the door
|
|
280
|
+
# that hands work out and it already declines a held task, whereas `move` is
|
|
281
|
+
# what a person uses when they have decided to take something over — and a
|
|
282
|
+
# harness that argued with that would just get worked around. What it owes is
|
|
283
|
+
# the fact, and who to talk to before the two of you collide.
|
|
284
|
+
from .shift import read_claim
|
|
285
|
+
held = read_claim(task.folder)
|
|
286
|
+
if held and held.get("instance") != peers.me():
|
|
287
|
+
print(f"note: '{name}' is held by another session — {peers.of_claim(held)}",
|
|
288
|
+
file=sys.stderr)
|
|
289
|
+
|
|
290
|
+
# The gate runs BEFORE the folder moves. It used to warn after — which meant
|
|
291
|
+
# the task was already sitting in `complete/` when the warning printed, and
|
|
292
|
+
# the board said done regardless of who read it.
|
|
293
|
+
delivered = (args.get("delivered") or "").strip()
|
|
294
|
+
not_included = (args.get("not-included") or args.get("not_included") or "").strip()
|
|
295
|
+
if to == "complete":
|
|
296
|
+
missing = delivery_gate(delivered, not_included)
|
|
297
|
+
if missing:
|
|
298
|
+
print(f"error: '{name}' cannot complete — the delivery line is "
|
|
299
|
+
f"incomplete:", file=sys.stderr)
|
|
300
|
+
for m in missing:
|
|
301
|
+
print(f" · {m}", file=sys.stderr)
|
|
302
|
+
events.append(root, "gate-refused", name, why="; ".join(missing)[:300])
|
|
303
|
+
return 1
|
|
304
|
+
if not report_gate(root, task, (args.get("accept") or "").strip(),
|
|
305
|
+
(args.get("owner") or "").strip()):
|
|
306
|
+
return 1
|
|
307
|
+
|
|
308
|
+
# The task's container is the parent of its bucket — the EPIC folder for a
|
|
309
|
+
# task in the epic shape, the VERSION folder for a grandfathered loose one.
|
|
310
|
+
# Deriving it from the path rather than the tier means `move` works in both
|
|
311
|
+
# shapes without knowing which it is in.
|
|
312
|
+
container = task.folder.parent.parent
|
|
313
|
+
dest = container / to / name
|
|
314
|
+
if dest.exists():
|
|
315
|
+
die(f"{rel(dest, root)} already exists")
|
|
316
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
shutil.move(str(task.folder), str(dest))
|
|
318
|
+
|
|
319
|
+
def mutate(d):
|
|
320
|
+
d["updated"] = date.today().isoformat()
|
|
321
|
+
if to == "complete":
|
|
322
|
+
d["completed"] = date.today().isoformat()
|
|
323
|
+
else:
|
|
324
|
+
d.pop("completed", None)
|
|
325
|
+
|
|
326
|
+
md = dest / "task.md"
|
|
327
|
+
rewrite_file(md, mutate)
|
|
328
|
+
record_session(dest)
|
|
329
|
+
events.append(root, "moved", name, **{"from": task.status, "to": to})
|
|
330
|
+
print(f"moved '{name}': {task.status}/ -> {to}/")
|
|
331
|
+
|
|
332
|
+
# No handoff is scaffolded on pickup — `handoff.md` is created on demand via
|
|
333
|
+
# `jarvis work handoff` only when a task actually hands across conversations
|
|
334
|
+
# (a blank scaffold on every pickup was noise). Working checklists live in
|
|
335
|
+
# native TodoWrite, so completion strips nothing.
|
|
336
|
+
if to == "complete":
|
|
337
|
+
# The lease is the claim on WORK IN FLIGHT; finished work holds nothing.
|
|
338
|
+
# Leaving it would make `status` report a live instance on a done task.
|
|
339
|
+
claim = dest / ".claim"
|
|
340
|
+
if claim.is_file():
|
|
341
|
+
claim.unlink()
|
|
342
|
+
events.append(root, "completed", name, delivered=delivered or None,
|
|
343
|
+
not_included=not_included or None)
|
|
344
|
+
# The forwardable line. `not_included` is asked for every time and never
|
|
345
|
+
# inferred: the failure it prevents is overpromising, and a field the
|
|
346
|
+
# harness fills in for you prevents nothing.
|
|
347
|
+
print(f" delivered: {delivered}")
|
|
348
|
+
print(f" NOT included: {not_included}")
|
|
349
|
+
|
|
350
|
+
_sync(root)
|
|
351
|
+
return 0
|
|
352
|
+
def cmd_handoff(args) -> int:
|
|
353
|
+
root = find_work_root()
|
|
354
|
+
name = args["name"]
|
|
355
|
+
task = locate(root, name)
|
|
356
|
+
if not task:
|
|
357
|
+
die(f"no task named '{name}' found")
|
|
358
|
+
if task.in_backlog or task.status == "queue":
|
|
359
|
+
die(f"'{name}' is queued/backlog — handoff is for in-progress work "
|
|
360
|
+
f"(pick it up first: jarvis work move {name} in-progress)")
|
|
361
|
+
if _scaffold_handoff(task.folder, task.title):
|
|
362
|
+
print(f"scaffolded {rel(task.folder / 'handoff.md', root)}")
|
|
363
|
+
else:
|
|
364
|
+
print(f"'{name}' already has a handoff.md — edit it in place")
|
|
365
|
+
return 0
|
|
366
|
+
def cmd_session(args) -> int:
|
|
367
|
+
"""Record a session on a task. The current one by default; `--id` records one
|
|
368
|
+
this session STARTED, which is the wrap handing its successor to the board
|
|
369
|
+
rather than to a line of prose in the handoff."""
|
|
370
|
+
root = find_work_root()
|
|
371
|
+
task = locate(root, args["name"])
|
|
372
|
+
if not task:
|
|
373
|
+
die(f"no task named '{args['name']}' found")
|
|
374
|
+
given = (args.get("id") or "").strip()
|
|
375
|
+
label = None
|
|
376
|
+
if given:
|
|
377
|
+
# Refused rather than written. This value arrives from a tool's answer,
|
|
378
|
+
# lands in committed frontmatter, and is read back by anything that walks
|
|
379
|
+
# `sessions:` — so a truncated or error-shaped id is caught here, where it
|
|
380
|
+
# is one message, not later as a session nobody can resume.
|
|
381
|
+
if not re.fullmatch(r"[0-9a-fA-F-]{8,64}", given):
|
|
382
|
+
die(f"--id {given!r} is not a session id — expected the `sessionId` the "
|
|
383
|
+
f"start returned, e.g. 487e9271-c3d8-4f38-b42e-6716fe078506")
|
|
384
|
+
sid = given
|
|
385
|
+
# Only a session that is not this one is a successor. Passing your own id
|
|
386
|
+
# explicitly is legal and means what it says: record me.
|
|
387
|
+
if sid != current_session_id():
|
|
388
|
+
label = "successor"
|
|
389
|
+
else:
|
|
390
|
+
sid = current_session_id()
|
|
391
|
+
if not sid:
|
|
392
|
+
print("no CLAUDE_CODE_SESSION_ID in environment — nothing recorded")
|
|
393
|
+
return 0
|
|
394
|
+
if record_session(task.folder, sid, label):
|
|
395
|
+
print(f"recorded {label or 'session'} {sid} on '{task.name}'")
|
|
396
|
+
else:
|
|
397
|
+
print(f"session {sid} already recorded on '{task.name}'")
|
|
398
|
+
return 0
|
|
399
|
+
def cmd_plan(args) -> int:
|
|
400
|
+
"""Append a dated, deduped pointer to an approved plan file onto a task's
|
|
401
|
+
`plans:` frontmatter list (mirrors `sessions:`). The plan content is NOT
|
|
402
|
+
copied in — only a path, so the ref survives desktop<->cloud<->mobile hops
|
|
403
|
+
(E2). A fresh session walks `plans:` newest-first to the first ref that still
|
|
404
|
+
exists and re-enters plan mode from there.
|
|
405
|
+
|
|
406
|
+
The path is `--file`, else `<plans.dir>/<name>.md`. With `plans.dir` null the
|
|
407
|
+
harness REFUSES rather than defaulting: one consumer keeps a plans directory
|
|
408
|
+
and another deleted it on purpose, so a shipped default would silently
|
|
409
|
+
scaffold a pointer into a directory the repo does not have."""
|
|
410
|
+
root = find_work_root()
|
|
411
|
+
name = args["name"]
|
|
412
|
+
task = locate(root, name)
|
|
413
|
+
if not task:
|
|
414
|
+
die(f"no task named '{name}' found")
|
|
415
|
+
if not args.get("file") and not PLANS_DIR:
|
|
416
|
+
die("no `plans.dir` configured — pass `--file <path>`, or set "
|
|
417
|
+
"`plans.dir` in .claude/work.config.json if this repo keeps a "
|
|
418
|
+
"plans directory")
|
|
419
|
+
path = (args.get("file") or f"{PLANS_DIR.rstrip('/')}/{task.name}.md").strip()
|
|
420
|
+
md = task.folder / "task.md"
|
|
421
|
+
text = read_item(md)
|
|
422
|
+
plans = as_list(parse_frontmatter(text).get("plans"))
|
|
423
|
+
if any(path in entry for entry in plans):
|
|
424
|
+
print(f"plan '{path}' already recorded on '{task.name}'")
|
|
425
|
+
return 0
|
|
426
|
+
entry = f"{date.today().isoformat()} {path}"
|
|
427
|
+
|
|
428
|
+
def mutate(d):
|
|
429
|
+
if any(path in existing for existing in as_list(d.get("plans"))):
|
|
430
|
+
return
|
|
431
|
+
d["plans"] = as_list(d.get("plans")) + [entry]
|
|
432
|
+
d["updated"] = date.today().isoformat()
|
|
433
|
+
|
|
434
|
+
rewrite_file(md, mutate)
|
|
435
|
+
print(f"recorded plan '{path}' on '{task.name}'")
|
|
436
|
+
return 0
|
|
437
|
+
def cmd_path(args) -> int:
|
|
438
|
+
root = find_work_root()
|
|
439
|
+
name = args["name"]
|
|
440
|
+
task = locate(root, name)
|
|
441
|
+
if task:
|
|
442
|
+
print(task.folder)
|
|
443
|
+
return 0
|
|
444
|
+
epic = locate_epic(root, name)
|
|
445
|
+
if epic:
|
|
446
|
+
print(epic.folder)
|
|
447
|
+
return 0
|
|
448
|
+
version = locate_version(root, name)
|
|
449
|
+
if version:
|
|
450
|
+
print(version.folder)
|
|
451
|
+
return 0
|
|
452
|
+
die(f"no task, epic or version named '{name}' found")
|
|
453
|
+
def cmd_code(args) -> int:
|
|
454
|
+
"""Every task whose `code:` includes `<region>`, across all versions +
|
|
455
|
+
backlog, grouped by version -> bucket. The cross-version glue for reading
|
|
456
|
+
where a seam is already being touched before adding another task to it
|
|
457
|
+
(foundation-before-feature is an inference the brain draws over this)."""
|
|
458
|
+
root = find_work_root()
|
|
459
|
+
region = args["region"]
|
|
460
|
+
vocabulary = code_vocabulary(root)
|
|
461
|
+
if region not in vocabulary:
|
|
462
|
+
die(f"unknown code region '{region}' — regions are: "
|
|
463
|
+
f"{', '.join(vocabulary)}")
|
|
464
|
+
|
|
465
|
+
s = scan(root)
|
|
466
|
+
found = False
|
|
467
|
+
|
|
468
|
+
def _emit(tasks: list) -> bool:
|
|
469
|
+
for t in tasks:
|
|
470
|
+
print(f" {t.name} — {t.title} [owner: {t.owner or '—'}]")
|
|
471
|
+
return bool(tasks)
|
|
472
|
+
|
|
473
|
+
for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
|
|
474
|
+
for b in BUCKETS:
|
|
475
|
+
# Loose tasks first (the grandfathered shape), then per epic — the
|
|
476
|
+
# epic label is what makes "who else is already in this seam"
|
|
477
|
+
# answerable at the level the work is actually planned.
|
|
478
|
+
hits = [t for t in v.tasks[b] if region in t.code]
|
|
479
|
+
if hits:
|
|
480
|
+
found = True
|
|
481
|
+
print(f"\nVERSION {v.name} / {b}")
|
|
482
|
+
_emit(hits)
|
|
483
|
+
for e in v.epics:
|
|
484
|
+
hits = [t for t in e.tasks[b] if region in t.code]
|
|
485
|
+
if hits:
|
|
486
|
+
found = True
|
|
487
|
+
print(f"\nVERSION {v.name} / EPIC {e.name} / {b}")
|
|
488
|
+
_emit(hits)
|
|
489
|
+
|
|
490
|
+
for e in s["backlog_epics"]:
|
|
491
|
+
hits = sorted((t for t in e.backlog_tasks if region in t.code),
|
|
492
|
+
key=lambda x: (x.priority_rank(), x.name))
|
|
493
|
+
if hits:
|
|
494
|
+
found = True
|
|
495
|
+
print(f"\nBACKLOG / EPIC {e.name}")
|
|
496
|
+
_emit(hits)
|
|
497
|
+
|
|
498
|
+
loose = sorted((t for t in s["backlog"] if region in t.code and not t.epic),
|
|
499
|
+
key=lambda x: (x.priority_rank(), x.name))
|
|
500
|
+
if loose:
|
|
501
|
+
found = True
|
|
502
|
+
print("\nBACKLOG")
|
|
503
|
+
_emit(loose)
|
|
504
|
+
|
|
505
|
+
if not found:
|
|
506
|
+
print(f"no tasks tagged code: {region}")
|
|
507
|
+
return 0
|