@appchy/jarvis 0.1.84 → 0.1.86

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.
@@ -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")):
@@ -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
 
@@ -16,7 +16,7 @@ from .scaffold import _check_kebab, _check_owner_ref, _check_priority, _check_un
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
@@ -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
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>)")
@@ -330,7 +330,7 @@
330
330
  "shard": {
331
331
  "type": "string",
332
332
  "default": ".work/coverage",
333
- "description": "Where runners drop coverage shards. Gitignored on purpose — evidence is a fresh run, not a file somebody checked in."
333
+ "description": "Where runners drop coverage shards, repo-relative. Gitignored on purpose — evidence is a fresh run, not a file somebody checked in."
334
334
  }
335
335
  }
336
336
  },