@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.
@@ -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 anywhere. Returns a Task or None."""
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, rel
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, _sync, settle_epic_tier
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
 
@@ -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 / ".work" / "coverage"
81
+ d = repo / DIR
76
82
  if not d.is_dir():
77
83
  return read
78
84
  for p in sorted(d.glob("*.json")):
@@ -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
- except (OSError, ValueError, KeyError, json.JSONDecodeError):
60
- return None
61
- if expires <= _now():
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(f"no task named '{args['name']}' found")
234
+ die(missing(root, args["name"]))
229
235
  p = task.folder / CLAIM
230
236
  if p.is_file():
231
237
  p.unlink()
@@ -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(f"no task or epic named '{name}' found")
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(f"no task or epic named '{name}' found")
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(f"no task named '{name}' found")
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
- missing = delivery_gate(delivered, not_included)
312
- if missing:
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 missing:
321
+ for m in unmet:
316
322
  print(f" · {m}", file=sys.stderr)
317
- events.append(root, "gate-refused", name, why="; ".join(missing)[:300])
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(f"no task named '{name}' found")
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(f"no task named '{args['name']}' found")
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(f"no task named '{name}' found")
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