@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.
- package/dist/bin.js +1 -1
- package/harness/harness/autonomy.py +20 -8
- package/harness/harness/branches.py +20 -7
- package/harness/harness/config.py +31 -3
- package/harness/harness/coverage.py +25 -11
- package/harness/harness/epic.py +22 -6
- package/harness/harness/events.py +18 -4
- package/harness/harness/git.py +68 -26
- package/harness/harness/lint.py +19 -7
- 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 +9 -3
- package/harness/harness/task.py +11 -5
- package/harness/schema/work.config.schema.json +1 -1
- package/harness/test_work.py +735 -1
- package/harness/work.py +57 -51
- package/package.json +5 -5
|
@@ -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
|
@@ -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
|
|
package/harness/harness/task.py
CHANGED
|
@@ -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
|
},
|