@appchy/jarvis 0.1.83 → 0.1.85
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/dist/bin.js +6 -2
- package/dist/bin.js.map +1 -1
- package/harness/harness/architecture.py +2 -2
- package/harness/harness/autonomy.py +23 -11
- package/harness/harness/branches.py +20 -7
- package/harness/harness/config.py +11 -1
- package/harness/harness/coverage.py +13 -4
- package/harness/harness/epic.py +16 -5
- package/harness/harness/events.py +18 -4
- package/harness/harness/gate.py +3 -3
- package/harness/harness/git.py +58 -19
- package/harness/harness/kickoff.py +2 -2
- package/harness/harness/lint.py +9 -4
- package/harness/harness/model.py +84 -5
- package/harness/harness/report.py +2 -54
- package/harness/harness/safety.py +12 -1
- package/harness/harness/shard.py +7 -1
- package/harness/harness/shift.py +11 -5
- package/harness/harness/task.py +30 -18
- package/harness/test_work.py +777 -1
- package/harness/work.py +38 -51
- package/package.json +2 -2
package/harness/harness/model.py
CHANGED
|
@@ -3,7 +3,7 @@ import re
|
|
|
3
3
|
from datetime import date
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
from .tree import backlog_dir, BUCKETS, DONE_TIER, PRIORITIES, RESERVED_MD
|
|
6
|
+
from .tree import backlog_dir, BUCKETS, cli, DONE_TIER, PRIORITIES, RESERVED_MD
|
|
7
7
|
from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, title_of
|
|
8
8
|
|
|
9
9
|
|
|
@@ -298,8 +298,22 @@ def scan(root: Path) -> dict:
|
|
|
298
298
|
|
|
299
299
|
return {"versions": versions, "backlog": backlog,
|
|
300
300
|
"backlog_epics": backlog_epics}
|
|
301
|
-
def locate(root: Path, name: str):
|
|
302
|
-
"""Find a task by name
|
|
301
|
+
def locate(root: Path, name: str, filed: bool = False):
|
|
302
|
+
"""Find a task by name. LIVE work by default; `filed=True` also searches the
|
|
303
|
+
cuts that have left the board.
|
|
304
|
+
|
|
305
|
+
`locate_version` has always searched all three homes, and says why: off the
|
|
306
|
+
board is not gone, and `path` keeps answering. That was never true of the work
|
|
307
|
+
INSIDE a cut — filing `00-somebody-new-can-start` made six tasks unresolvable
|
|
308
|
+
in one command, and between them they are named in 55 commit trailers, which
|
|
309
|
+
is the record a board write leaves.
|
|
310
|
+
|
|
311
|
+
**It is a parameter rather than the new default, and that is the whole care
|
|
312
|
+
here.** `locate` is also how every WRITE resolves its target, and what a
|
|
313
|
+
release delivered is a matter of record — a task inside a shipped cut must not
|
|
314
|
+
become movable. So reads opt in, and writes stay on live work and refuse by
|
|
315
|
+
saying where the work went rather than claiming it never existed.
|
|
316
|
+
"""
|
|
303
317
|
s = scan(root)
|
|
304
318
|
for v in s["versions"]:
|
|
305
319
|
for t in v.all_tasks():
|
|
@@ -308,7 +322,63 @@ def locate(root: Path, name: str):
|
|
|
308
322
|
for t in s["backlog"]:
|
|
309
323
|
if t.name == name:
|
|
310
324
|
return t
|
|
325
|
+
if filed:
|
|
326
|
+
for v in scan_filed(root):
|
|
327
|
+
for t in v.all_tasks():
|
|
328
|
+
if t.name == name:
|
|
329
|
+
return t
|
|
311
330
|
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def scan_filed(root: Path) -> list:
|
|
334
|
+
"""Every cut that has LEFT the board — both `versions/complete/` and
|
|
335
|
+
`versions/archive/`.
|
|
336
|
+
|
|
337
|
+
Deliberately not `scan_shipped`, which reads `complete/` alone because it
|
|
338
|
+
answers "what has this repo delivered". A cut in `archive/` delivered nothing:
|
|
339
|
+
it left because its work moved elsewhere and there was no outcome it could
|
|
340
|
+
honestly claim. Two different questions, so two functions rather than one with
|
|
341
|
+
a flag that makes every caller state which meaning it wanted.
|
|
342
|
+
"""
|
|
343
|
+
out = []
|
|
344
|
+
for home in ("complete", "archive"):
|
|
345
|
+
base = root / "versions" / home
|
|
346
|
+
if not base.is_dir():
|
|
347
|
+
continue
|
|
348
|
+
out += [Version(v) for v in sorted(base.iterdir())
|
|
349
|
+
if v.is_dir() and (v / "version.md").is_file()]
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def filed_home(root: Path, name: str) -> str:
|
|
354
|
+
"""Where a name went, as a sentence to append to a refusal — or `""`.
|
|
355
|
+
|
|
356
|
+
"no task named 'x' found" is the least useful true thing to say about work that
|
|
357
|
+
shipped last Thursday, and it is what every write said the moment a cut was
|
|
358
|
+
filed. The lookup that fails looks once more, in the cuts that left the board,
|
|
359
|
+
so the refusal can name the release instead of denying the work ever existed.
|
|
360
|
+
"""
|
|
361
|
+
for v in scan_filed(root):
|
|
362
|
+
for t in v.all_tasks():
|
|
363
|
+
if t.name == name:
|
|
364
|
+
return (f" — it shipped in '{v.name}'"
|
|
365
|
+
+ (f" on {v.released}" if v.released else "")
|
|
366
|
+
+ ", and what a release delivered is a matter of record. "
|
|
367
|
+
+ f"Read it with `{cli()} path {name}`.")
|
|
368
|
+
for e in v.epics:
|
|
369
|
+
if e.name == name:
|
|
370
|
+
return (f" — that epic shipped in '{v.name}'"
|
|
371
|
+
+ (f" on {v.released}" if v.released else "") + ".")
|
|
372
|
+
return ""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def missing(root: Path, name: str, noun: str = "task") -> str:
|
|
376
|
+
"""The refusal for a name that does not resolve, saying where it went if it did.
|
|
377
|
+
|
|
378
|
+
One wording in one place: thirteen sites said "no task named 'x' found" and
|
|
379
|
+
every one of them was wrong in the same new way once cuts started being filed.
|
|
380
|
+
"""
|
|
381
|
+
return f"no {noun} named '{name}' found{filed_home(root, name)}"
|
|
312
382
|
def locate_version(root: Path, name: str):
|
|
313
383
|
"""Find a version by name. Looks in BOTH homes a cut leaves the board for —
|
|
314
384
|
`versions/complete/` and `versions/archive/` — so a finished cut stays
|
|
@@ -347,9 +417,13 @@ def scan_shipped(root: Path) -> list:
|
|
|
347
417
|
cuts.sort(key=lambda v: (v.order, v.name))
|
|
348
418
|
cuts.sort(key=lambda v: v.released or "", reverse=True)
|
|
349
419
|
return cuts
|
|
350
|
-
def locate_epic(root: Path, name: str):
|
|
420
|
+
def locate_epic(root: Path, name: str, filed: bool = False):
|
|
351
421
|
"""Find an epic by name anywhere — in a version or in the backlog. Names are
|
|
352
|
-
globally unique across tasks, epics and versions, so a name alone resolves.
|
|
422
|
+
globally unique across tasks, epics and versions, so a name alone resolves.
|
|
423
|
+
|
|
424
|
+
`filed=True` reaches the cuts that have left the board, for the reason
|
|
425
|
+
`locate` takes the same parameter: a grouping that shipped is still a name
|
|
426
|
+
people cite, and `release` removes only the plan doc, never the folder."""
|
|
353
427
|
s = scan(root)
|
|
354
428
|
for v in s["versions"]:
|
|
355
429
|
for e in v.epics:
|
|
@@ -358,6 +432,11 @@ def locate_epic(root: Path, name: str):
|
|
|
358
432
|
for e in s["backlog_epics"]:
|
|
359
433
|
if e.name == name:
|
|
360
434
|
return e
|
|
435
|
+
if filed:
|
|
436
|
+
for v in scan_filed(root):
|
|
437
|
+
for e in v.epics:
|
|
438
|
+
if e.name == name:
|
|
439
|
+
return e
|
|
361
440
|
return None
|
|
362
441
|
def epic_home(root: Path, epic: "Epic") -> str:
|
|
363
442
|
"""A human-readable home for an epic — `version '<v>'` or `backlog`."""
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import subprocess
|
|
3
1
|
import sys
|
|
4
2
|
from pathlib import Path
|
|
5
3
|
|
|
6
|
-
from .tree import find_work_root
|
|
7
|
-
from .frontmatter import _eol, read_item, split_frontmatter, write_item
|
|
4
|
+
from .tree import find_work_root
|
|
8
5
|
from .model import Task, _ordered, scan, scan_shipped
|
|
9
6
|
from .lint import lint_warnings
|
|
10
|
-
from .generate import _regen_readme,
|
|
7
|
+
from .generate import _regen_readme, settle_epic_tier
|
|
11
8
|
from .align import (_align_acceptance, _align_agents, _align_citations,
|
|
12
9
|
_align_definitions, _align_domains, _align_hosts,
|
|
13
10
|
_align_ledger_index, _align_retired, _align_single_feature,
|
|
@@ -192,52 +189,3 @@ def cmd_align(args) -> int:
|
|
|
192
189
|
print(f"\n {len(warns)} warning(s) · {len(errors)} error(s){tail} · exit 0 "
|
|
193
190
|
f"(report-only — the flip to blocking is its own task)")
|
|
194
191
|
return 0
|
|
195
|
-
def cmd_migrate_owner(args) -> int:
|
|
196
|
-
"""Mechanical, one-way: `product:` becomes `owner:`, and the retired `infra`
|
|
197
|
-
sentinel becomes the `operations` domain. Skips files with uncommitted
|
|
198
|
-
changes unless --force: the tree is shared, and silently rewriting another
|
|
199
|
-
session's open edit is how a rename eats work that was never committed."""
|
|
200
|
-
root = find_work_root()
|
|
201
|
-
repo = root.parent
|
|
202
|
-
dirty = set()
|
|
203
|
-
try:
|
|
204
|
-
out = subprocess.run(["git", "status", "--porcelain"], cwd=repo,
|
|
205
|
-
capture_output=True, text=True, check=True).stdout
|
|
206
|
-
for line in out.splitlines():
|
|
207
|
-
if len(line) > 3:
|
|
208
|
-
dirty.add((repo / line[3:].strip().strip('"')).resolve())
|
|
209
|
-
except Exception:
|
|
210
|
-
pass # not a git repo, or git unavailable — migrate everything
|
|
211
|
-
|
|
212
|
-
def _is_dirty(md: Path) -> bool:
|
|
213
|
-
"""Git reports an untracked DIRECTORY as the directory, not its files, so
|
|
214
|
-
a plain membership test misses `work/.../new-task/task.md` inside a folder
|
|
215
|
-
another session just created. Match the path or any parent."""
|
|
216
|
-
p = md.resolve()
|
|
217
|
-
return any(d == p or d in p.parents for d in dirty)
|
|
218
|
-
|
|
219
|
-
changed, skipped = [], []
|
|
220
|
-
for md in sorted(root.rglob("task.md")):
|
|
221
|
-
text = read_item(md)
|
|
222
|
-
fm, body = split_frontmatter(text)
|
|
223
|
-
if fm is None or not re.search(r"^product:", fm, re.MULTILINE):
|
|
224
|
-
continue
|
|
225
|
-
if _is_dirty(md) and not args.get("force"):
|
|
226
|
-
skipped.append(rel(md, root))
|
|
227
|
-
continue
|
|
228
|
-
# Only the KEY is rewritten — the value and its spacing are untouched, so
|
|
229
|
-
# 105 files change one word each instead of churning frontmatter layout.
|
|
230
|
-
new_fm = re.sub(r"^product:", "owner:", fm, flags=re.MULTILINE)
|
|
231
|
-
eol = _eol(text)
|
|
232
|
-
write_item(md, f"---{eol}{new_fm}{eol}---{eol}{body}")
|
|
233
|
-
changed.append(rel(md, root))
|
|
234
|
-
|
|
235
|
-
print(f"renamed product: -> owner: in {len(changed)} task.md")
|
|
236
|
-
if skipped:
|
|
237
|
-
print(f"SKIPPED {len(skipped)} with uncommitted changes (another session "
|
|
238
|
-
f"may be editing them) — rerun after they land, or pass --force:")
|
|
239
|
-
for f in skipped:
|
|
240
|
-
print(f" {f}")
|
|
241
|
-
if changed:
|
|
242
|
-
_sync(root)
|
|
243
|
-
return 0
|
|
@@ -106,10 +106,21 @@ def cmd_id_new(args) -> int:
|
|
|
106
106
|
# Walk forward rather than retrying the same number: a loser in the race wants
|
|
107
107
|
# the NEXT number, and a leftover lock from a crashed session must not wedge the
|
|
108
108
|
# allocator forever.
|
|
109
|
+
#
|
|
110
|
+
# THE SCAN IS REPEATED UNDER THE CLAIM, and that is what makes the lock work at
|
|
111
|
+
# all. The scan above is slow and happens outside it; the lock is released the
|
|
112
|
+
# moment the heading is written. So a second session that scanned before the
|
|
113
|
+
# first one wrote, and claimed after it released, found the number free twice
|
|
114
|
+
# and both wrote the same `### D-nn`. Re-deriving here costs one extra tree read
|
|
115
|
+
# on the one command that allocates, and it is the only moment at which the
|
|
116
|
+
# question "is this number still free" can be asked and acted on atomically.
|
|
109
117
|
for candidate in range(n, n + 1000):
|
|
110
|
-
if _claim(root, candidate):
|
|
118
|
+
if not _claim(root, candidate):
|
|
119
|
+
continue
|
|
120
|
+
if candidate >= _next_free(root):
|
|
111
121
|
n = candidate
|
|
112
122
|
break
|
|
123
|
+
_release(root, candidate)
|
|
113
124
|
else: # pragma: no cover — defensive
|
|
114
125
|
die("could not claim an id after 1000 attempts — check .work/ids/")
|
|
115
126
|
|
package/harness/harness/shard.py
CHANGED
|
@@ -14,6 +14,12 @@ from pathlib import Path
|
|
|
14
14
|
from typing import NamedTuple
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
#: Where runners drop their shards, from `coverage.shard` — set by `config.apply()`,
|
|
18
|
+
#: the same way `coverage.VERIFY` is. A module global rather than an import, because
|
|
19
|
+
#: this module is imported by three readers and importing config from here would
|
|
20
|
+
#: close a cycle.
|
|
21
|
+
DIR = ".work/coverage"
|
|
22
|
+
|
|
17
23
|
#: Precedence when several sites claim one criterion — the SAME rank both
|
|
18
24
|
#: reporters already apply within a single runner. Anything unrecognised ranks
|
|
19
25
|
#: below `passed`, so a shard that learns a new status can never silently
|
|
@@ -72,7 +78,7 @@ def _load_run(repo: Path) -> Run:
|
|
|
72
78
|
runners separately is exactly the drift this module ended.
|
|
73
79
|
"""
|
|
74
80
|
read = Run({}, {}, [], [], [])
|
|
75
|
-
d = repo /
|
|
81
|
+
d = repo / DIR
|
|
76
82
|
if not d.is_dir():
|
|
77
83
|
return read
|
|
78
84
|
for p in sorted(d.glob("*.json")):
|
package/harness/harness/shift.py
CHANGED
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
|
|
26
26
|
from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
27
27
|
from .frontmatter import rewrite_file
|
|
28
|
-
from .model import locate, record_session, scan
|
|
28
|
+
from .model import locate, missing, record_session, scan
|
|
29
29
|
from .generate import _sync
|
|
30
30
|
from . import autonomy, events, links, peers
|
|
31
31
|
# The ceiling is read through the MODULE, never bound in with `from … import`.
|
|
@@ -56,9 +56,15 @@ def read_claim(folder: Path):
|
|
|
56
56
|
try:
|
|
57
57
|
data = json.loads(p.read_text())
|
|
58
58
|
expires = datetime.fromisoformat(str(data["expires"]))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
# TypeError is in the list because the comparison below throws it, not the
|
|
60
|
+
# parse: a claim written without a timezone reads fine and then cannot be
|
|
61
|
+
# compared against an aware `now`. That raised out of a function whose whole
|
|
62
|
+
# contract is that an unreadable claim reads as absent — so a single
|
|
63
|
+
# malformed `.claim` took down every command that asks who holds an item,
|
|
64
|
+
# which is the fail-closed lock this was written to avoid being.
|
|
65
|
+
if expires <= _now():
|
|
66
|
+
return None
|
|
67
|
+
except (OSError, TypeError, ValueError, KeyError, json.JSONDecodeError):
|
|
62
68
|
return None
|
|
63
69
|
return data
|
|
64
70
|
|
|
@@ -225,7 +231,7 @@ def cmd_drop(args) -> int:
|
|
|
225
231
|
root = find_work_root()
|
|
226
232
|
task = locate(root, args["name"])
|
|
227
233
|
if not task:
|
|
228
|
-
die(
|
|
234
|
+
die(missing(root, args["name"]))
|
|
229
235
|
p = task.folder / CLAIM
|
|
230
236
|
if p.is_file():
|
|
231
237
|
p.unlink()
|
package/harness/harness/task.py
CHANGED
|
@@ -10,13 +10,13 @@ from . import peers, tree
|
|
|
10
10
|
from . import links
|
|
11
11
|
from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
12
12
|
from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, split_frontmatter
|
|
13
|
-
from .model import current_session_id, locate, locate_epic, locate_version, record_session, scan
|
|
13
|
+
from .model import current_session_id, locate, locate_epic, locate_version, missing, record_session, scan
|
|
14
14
|
from .registry import code_vocabulary
|
|
15
15
|
from .scaffold import _check_kebab, _check_owner_ref, _check_priority, _check_unused, _scaffold_handoff, _scaffold_task
|
|
16
16
|
from .epic import _bucketed, cmd_epic_move, epic_for_task
|
|
17
17
|
from .generate import _sync
|
|
18
18
|
from .gate import delivery_gate, report_gate
|
|
19
|
-
from .autonomy import derive_tier
|
|
19
|
+
from .autonomy import _open_questions, derive_tier
|
|
20
20
|
from . import events
|
|
21
21
|
|
|
22
22
|
#: Where this repo keeps approved plan files, from `plans.dir` in config. None means
|
|
@@ -160,7 +160,7 @@ def cmd_place(args) -> int:
|
|
|
160
160
|
|
|
161
161
|
task = locate(root, name)
|
|
162
162
|
if not task:
|
|
163
|
-
die(
|
|
163
|
+
die(missing(root, name, "task or epic"))
|
|
164
164
|
if task.version == version.name:
|
|
165
165
|
die(f"'{name}' is already in version '{version_name}'")
|
|
166
166
|
if task.status == "complete":
|
|
@@ -236,7 +236,7 @@ def _to_backlog(root, name: str, args) -> int:
|
|
|
236
236
|
|
|
237
237
|
task = locate(root, name)
|
|
238
238
|
if not task:
|
|
239
|
-
die(
|
|
239
|
+
die(missing(root, name, "task or epic"))
|
|
240
240
|
if task.in_backlog:
|
|
241
241
|
die(f"'{name}' is already in the backlog")
|
|
242
242
|
if task.status == "complete":
|
|
@@ -276,14 +276,20 @@ def cmd_move(args) -> int:
|
|
|
276
276
|
to = args["status"]
|
|
277
277
|
if to not in BUCKETS:
|
|
278
278
|
die(f"status must be one of {', '.join(BUCKETS)}")
|
|
279
|
-
if to == BLOCKED:
|
|
280
|
-
die(f"`move {name} blocked` is not how a task blocks — a blocked task "
|
|
281
|
-
f"without a recorded question is one nobody can unblock. Use "
|
|
282
|
-
f"`jarvis work ask {name} --question \"…\"`.")
|
|
283
279
|
|
|
284
280
|
task = locate(root, name)
|
|
285
281
|
if not task:
|
|
286
|
-
die(
|
|
282
|
+
die(missing(root, name))
|
|
283
|
+
# The rule is right and the test used to be the wrong one: it asked whether you
|
|
284
|
+
# had just called `ask`, rather than whether the item has a question nobody has
|
|
285
|
+
# answered. So a task carrying its question in its own frontmatter — showing in
|
|
286
|
+
# `needs` at that moment — could not be put back into `blocked/` after somebody
|
|
287
|
+
# moved it out, which is exactly what a session does when it picks one up and
|
|
288
|
+
# finds the question still open.
|
|
289
|
+
if to == BLOCKED and not _open_questions(task):
|
|
290
|
+
die(f"`move {name} blocked` is not how a task blocks — a blocked task "
|
|
291
|
+
f"without a recorded question is one nobody can unblock. Use "
|
|
292
|
+
f"`jarvis work ask {name} --question \"…\"`.")
|
|
287
293
|
if task.in_backlog:
|
|
288
294
|
die(f"'{name}' is in backlog — pull it into a version first "
|
|
289
295
|
f"(jarvis work place {name} --version <v>)")
|
|
@@ -308,13 +314,13 @@ def cmd_move(args) -> int:
|
|
|
308
314
|
delivered = (args.get("delivered") or "").strip()
|
|
309
315
|
not_included = (args.get("not-included") or args.get("not_included") or "").strip()
|
|
310
316
|
if to == "complete":
|
|
311
|
-
|
|
312
|
-
if
|
|
317
|
+
unmet = delivery_gate(delivered, not_included)
|
|
318
|
+
if unmet:
|
|
313
319
|
print(f"error: '{name}' cannot complete — the delivery line is "
|
|
314
320
|
f"incomplete:", file=sys.stderr)
|
|
315
|
-
for m in
|
|
321
|
+
for m in unmet:
|
|
316
322
|
print(f" · {m}", file=sys.stderr)
|
|
317
|
-
events.append(root, "gate-refused", name, why="; ".join(
|
|
323
|
+
events.append(root, "gate-refused", name, why="; ".join(unmet)[:300])
|
|
318
324
|
return 1
|
|
319
325
|
if not report_gate(root, task, (args.get("accept") or "").strip(),
|
|
320
326
|
(args.get("owner") or "").strip()):
|
|
@@ -376,7 +382,7 @@ def cmd_handoff(args) -> int:
|
|
|
376
382
|
name = args["name"]
|
|
377
383
|
task = locate(root, name)
|
|
378
384
|
if not task:
|
|
379
|
-
die(
|
|
385
|
+
die(missing(root, name))
|
|
380
386
|
if task.in_backlog or task.status == "queue":
|
|
381
387
|
die(f"'{name}' is queued/backlog — handoff is for in-progress work "
|
|
382
388
|
f"(pick it up first: jarvis work move {name} in-progress)")
|
|
@@ -392,7 +398,7 @@ def cmd_session(args) -> int:
|
|
|
392
398
|
root = find_work_root()
|
|
393
399
|
task = locate(root, args["name"])
|
|
394
400
|
if not task:
|
|
395
|
-
die(
|
|
401
|
+
die(missing(root, args["name"]))
|
|
396
402
|
given = (args.get("id") or "").strip()
|
|
397
403
|
label = None
|
|
398
404
|
if given:
|
|
@@ -433,7 +439,7 @@ def cmd_plan(args) -> int:
|
|
|
433
439
|
name = args["name"]
|
|
434
440
|
task = locate(root, name)
|
|
435
441
|
if not task:
|
|
436
|
-
die(
|
|
442
|
+
die(missing(root, name))
|
|
437
443
|
if not args.get("file") and not PLANS_DIR:
|
|
438
444
|
die("no `plans.dir` configured — pass `--file <path>`, or set "
|
|
439
445
|
"`plans.dir` in .claude/work.config.json if this repo keeps a "
|
|
@@ -457,13 +463,19 @@ def cmd_plan(args) -> int:
|
|
|
457
463
|
print(f"recorded plan '{path}' on '{task.name}'")
|
|
458
464
|
return 0
|
|
459
465
|
def cmd_path(args) -> int:
|
|
466
|
+
"""Resolve any name to its folder — including work in a cut that has shipped.
|
|
467
|
+
|
|
468
|
+
This is the READ door, so it opts into the filed cuts at every tier.
|
|
469
|
+
`locate_version` already did; the two below did not, which is how filing one
|
|
470
|
+
cut made six tasks unresolvable while the cut holding them still answered.
|
|
471
|
+
"""
|
|
460
472
|
root = find_work_root()
|
|
461
473
|
name = args["name"]
|
|
462
|
-
task = locate(root, name)
|
|
474
|
+
task = locate(root, name, filed=True)
|
|
463
475
|
if task:
|
|
464
476
|
print(task.folder)
|
|
465
477
|
return 0
|
|
466
|
-
epic = locate_epic(root, name)
|
|
478
|
+
epic = locate_epic(root, name, filed=True)
|
|
467
479
|
if epic:
|
|
468
480
|
print(epic.folder)
|
|
469
481
|
return 0
|