@appchy/jarvis 0.1.36 → 0.1.39

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