@appchy/jarvis 0.1.37 → 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 +10168 -37341
  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 -38821
  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,348 @@
1
+ """A shift: one instance, one task, no collisions.
2
+
3
+ `move <name> in-progress` requires already knowing the name, and nothing computed
4
+ it — so a scheduled instance had no entry point, and the first thing it would have
5
+ had to do is read the whole board and choose, which is a judgement call made before
6
+ any of the harness's guard rails are loaded. `next` is that entry point, and every
7
+ input it uses already existed: priority, `depends_on`, the bucket, the version's
8
+ derived status, and the task's `tier:` against `autonomy.ceiling`.
9
+
10
+ **Coordination is a file, not a service.** A `.claim` beside `task.md` holds the
11
+ instance id and an expiry. A second `next` never returns a claimed task; a claim
12
+ whose `code:` regions are already held live by another instance is refused and the
13
+ selector moves on, which reuses the region overlap `jarvis work code` has computed all
14
+ along. Expired claims are reaped on read, so an instance that crashes at 3am frees
15
+ its work without anyone being paged.
16
+
17
+ `status` is the same data read from the other end — what shipped, what is waiting
18
+ on you, what is at risk. Derived, never declared.
19
+ """
20
+ import json
21
+ import shutil
22
+ import sys
23
+ from datetime import date, datetime, timedelta, timezone
24
+ from pathlib import Path
25
+
26
+ from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
27
+ from .frontmatter import rewrite_file
28
+ from .model import locate, record_session, scan
29
+ from .generate import _sync
30
+ from . import autonomy, events, peers
31
+ # The ceiling is read through the MODULE, never bound in with `from … import`.
32
+ # A `from .autonomy import CEILING` captures the value at import time, so
33
+ # `config.apply` setting it afterwards would leave this file quietly running on
34
+ # the shipped default — one fact with two owners, which is the drift the whole
35
+ # harness is built to avoid.
36
+ from .autonomy import _open_questions, tier_of
37
+
38
+ CLAIM = ".claim"
39
+ #: How long a claim is good for. Long enough that a real task finishes inside it,
40
+ #: short enough that a crashed instance's work is takeable the same day. Config
41
+ #: moves it (`autonomy.lease_minutes`).
42
+ LEASE_MINUTES = 240
43
+
44
+
45
+ def _now():
46
+ return datetime.now(timezone.utc).replace(microsecond=0)
47
+
48
+
49
+ def read_claim(folder: Path):
50
+ """The live claim on a task folder, or None. An expired or unreadable claim is
51
+ treated as absent — a lease nobody can parse must not be able to strand work
52
+ forever, which is the failure mode of every lock that fails closed."""
53
+ p = folder / CLAIM
54
+ if not p.is_file():
55
+ return None
56
+ try:
57
+ data = json.loads(p.read_text())
58
+ expires = datetime.fromisoformat(str(data["expires"]))
59
+ except (OSError, ValueError, KeyError, json.JSONDecodeError):
60
+ return None
61
+ if expires <= _now():
62
+ return None
63
+ return data
64
+
65
+
66
+ def _write_claim(folder: Path, instance: str) -> dict:
67
+ data = {
68
+ "instance": instance,
69
+ # WHICH BOX, recorded because an id alone cannot say whether the session
70
+ # holding this is one you can talk to or one on another machine entirely.
71
+ # A claim never enters git, so this is the only place the pairing exists.
72
+ "machine": peers.here(),
73
+ "taken": _now().isoformat(),
74
+ "expires": (_now() + timedelta(minutes=LEASE_MINUTES)).isoformat(),
75
+ }
76
+ (folder / CLAIM).write_text(json.dumps(data, indent=2) + "\n")
77
+ return data
78
+
79
+
80
+ def _live_claims(s: dict) -> list:
81
+ """Every unexpired claim across the tree, as (claim, task) — the input to both
82
+ the same-task check and the region-conflict check."""
83
+ out = []
84
+ for v in s["versions"]:
85
+ for t in v.all_tasks():
86
+ c = read_claim(t.folder)
87
+ if c:
88
+ out.append((c, t))
89
+ return out
90
+
91
+
92
+ def _blockers(task, s: dict) -> str:
93
+ """Why an unattended run may NOT take this task, or "" if it may. One string,
94
+ because `next` prints it when nothing is eligible — a cron log that says
95
+ "nothing to do" and not why is a log nobody can debug."""
96
+ done = {t.name for v in s["versions"] for t in v.all_tasks()
97
+ if t.status == "complete"}
98
+ missing = [d for d in task.depends_on if d not in done]
99
+ if missing:
100
+ return f"depends on incomplete: {', '.join(missing)}"
101
+ tier = tier_of(task)
102
+ if tier > autonomy.CEILING:
103
+ return (f"tier {tier} > autonomy.ceiling {autonomy.CEILING} — this one is yours to "
104
+ f"decide, not the shift's")
105
+ return ""
106
+
107
+
108
+ def _region_conflict(task, claims: list) -> str:
109
+ if not task.code:
110
+ return ""
111
+ for c, other in claims:
112
+ if other.name == task.name:
113
+ continue
114
+ overlap = sorted(set(task.code) & set(other.code))
115
+ if overlap:
116
+ # Naming the holder is only half an answer — the half that leaves you
117
+ # waiting for a lease. The other half is whether that session is one
118
+ # you can actually talk to about it.
119
+ return (f"code region(s) {', '.join(overlap)} held by "
120
+ f"'{other.name}' · {peers.of_claim(c)}")
121
+ return ""
122
+
123
+
124
+ def cmd_next(args) -> int:
125
+ """Hand this instance exactly one task, and claim it.
126
+
127
+ `--peek` selects without claiming or moving — for a human asking what the shift
128
+ would take next, which must not have the side effect of taking it."""
129
+ root = find_work_root()
130
+ instance = (args.get("instance") or events.instance_id() or "local").strip()
131
+ peek = bool(args.get("peek"))
132
+ s = scan(root)
133
+ claims = _live_claims(s)
134
+
135
+ # An instance that already holds work resumes it rather than taking more. One
136
+ # instance, one task — a shift juggling three tasks is three half-finished
137
+ # tasks, and unattended nobody notices until morning.
138
+ for c, t in claims:
139
+ if c["instance"] == instance:
140
+ print(f"RESUME {t.name} — {t.title} [claimed {c['taken']}]")
141
+ _read_order(t, root)
142
+ return 0
143
+
144
+ live = [v for v in s["versions"] if not v.released]
145
+ candidates, skipped = [], []
146
+ for v in sorted(live, key=lambda x: (x.order, x.name)):
147
+ for t in v.bucket("queue"):
148
+ if read_claim(t.folder):
149
+ skipped.append((t, "claimed by another instance"))
150
+ continue
151
+ why = _blockers(t, s) or _region_conflict(t, claims)
152
+ if why:
153
+ skipped.append((t, why))
154
+ continue
155
+ candidates.append((t.priority_rank(), v.order, t.name, t))
156
+
157
+ if not candidates:
158
+ blocked = [t for v in live for t in v.bucket(BLOCKED)]
159
+ print("nothing to take.")
160
+ if blocked:
161
+ print(f" {len(blocked)} task(s) waiting on a human — `jarvis work needs`")
162
+ for t, why in sorted(skipped, key=lambda r: r[0].name)[:10]:
163
+ print(f" skipped {t.name}: {why}")
164
+ if not blocked and not skipped:
165
+ print(" the queue is empty — nothing is cut and ready.")
166
+ return 0
167
+
168
+ candidates.sort(key=lambda r: (r[0], r[1], r[2]))
169
+ task = candidates[0][3]
170
+
171
+ if peek:
172
+ print(f"NEXT (peek) {task.name} — {task.title} "
173
+ f"[{task.priority} · tier {tier_of(task)}]")
174
+ return 0
175
+
176
+ container = task.folder.parent.parent
177
+ dest = container / "in-progress" / task.name
178
+ if dest.exists():
179
+ die(f"{rel(dest, root)} already exists")
180
+ dest.parent.mkdir(parents=True, exist_ok=True)
181
+ shutil.move(str(task.folder), str(dest))
182
+ md = dest / "task.md"
183
+ rewrite_file(
184
+ md,
185
+ lambda d: d.update({"updated": date.today().isoformat()}),
186
+ )
187
+ claim = _write_claim(dest, instance)
188
+ record_session(dest)
189
+ events.append(root, "moved", task.name, **{"from": "queue", "to": "in-progress"})
190
+ events.append(root, "claimed", task.name, expires=claim["expires"])
191
+
192
+ print(f"TAKE {task.name} — {task.title} "
193
+ f"[{task.priority} · tier {tier_of(task)} · lease to {claim['expires']}]")
194
+ task = locate(root, task.name)
195
+ _read_order(task, root)
196
+ _sync(root)
197
+ return 0
198
+
199
+
200
+ def _read_order(task, root) -> None:
201
+ """The cold-start spine, in the order `references/operations.md` prescribes.
202
+ Printed rather than assumed: an unattended session has no prior conversation,
203
+ so the one thing it must not have to infer is what to read."""
204
+ print("\n read, in this order:")
205
+ print(f" 1. {rel(task.folder / 'task.md', root)} the brief")
206
+ for f, what in (("handoff.md", "where things stand"),
207
+ ("plan.md", "design + as-found + build"),
208
+ ("research.md", "raw findings")):
209
+ if (task.folder / f).is_file():
210
+ print(f" {rel(task.folder / f, root)} {what}")
211
+ print(f" 2. jarvis work rules --task {task.name} what constrains it")
212
+ if task.epic:
213
+ print(f" 3. the parent epic.md §Plan the design every sibling builds against")
214
+ print(f" 4. jarvis work verify before you claim it is done\n")
215
+ print(f" tier {tier_of(task)} · ceiling {autonomy.CEILING} — "
216
+ + ("decide inside the plan; park product/UX calls with `jarvis work ask`."
217
+ if tier_of(task) <= autonomy.CEILING else
218
+ "ABOVE CEILING: do not decide alone."))
219
+
220
+
221
+ def cmd_drop(args) -> int:
222
+ """Give a task back — the honest end to a shift that cannot finish it."""
223
+ root = find_work_root()
224
+ task = locate(root, args["name"])
225
+ if not task:
226
+ die(f"no task named '{args['name']}' found")
227
+ p = task.folder / CLAIM
228
+ if p.is_file():
229
+ p.unlink()
230
+ events.append(root, "released-claim", task.name,
231
+ why=(args.get("why") or "").strip() or None)
232
+ print(f"released claim on '{task.name}'")
233
+ else:
234
+ print(f"'{task.name}' has no claim")
235
+ return 0
236
+
237
+
238
+ def _ago(ts: str) -> str:
239
+ """How long ago a recorded moment was, in the coarsest unit that still says
240
+ something. "6h ago" answers *is anyone on this* far better than a timestamp the
241
+ reader has to subtract from now, and the difference between 6 and 7 hours never
242
+ changes what they do about it."""
243
+ try:
244
+ when = datetime.fromisoformat(ts.replace("Z", "+00:00"))
245
+ except (TypeError, ValueError, AttributeError):
246
+ return "at an unrecorded time"
247
+ if when.tzinfo is None:
248
+ when = when.replace(tzinfo=timezone.utc)
249
+ mins = int((datetime.now(timezone.utc) - when).total_seconds() // 60)
250
+ if mins < 1:
251
+ return "just now"
252
+ if mins < 60:
253
+ return f"{mins}m ago"
254
+ if mins < 60 * 48:
255
+ return f"{mins // 60}h ago"
256
+ return f"{mins // (60 * 24)}d ago"
257
+
258
+
259
+ def cmd_status(args) -> int:
260
+ """The founder's morning read, in the order the questions actually get asked.
261
+
262
+ Every line is derived — from the tree for what is true now, from the log for
263
+ what happened. Nothing here is a field somebody wrote saying how it is going."""
264
+ root = find_work_root()
265
+ from .report import _say_staleness
266
+
267
+ _say_staleness(root)
268
+ s = scan(root)
269
+ live = [v for v in s["versions"] if not v.released]
270
+
271
+ waiting = [(t, q) for v in s["versions"] for t in v.all_tasks()
272
+ for q in _open_questions(t)]
273
+ print(f"WAITING ON YOU ({len(waiting)})")
274
+ for t, q in waiting[:8]:
275
+ parts = q.split(" ")
276
+ print(f" {t.name}: {' '.join(parts[2:]) if len(parts) > 2 else q}")
277
+ if not waiting:
278
+ print(" nothing — the shift is not blocked on you.")
279
+
280
+ # One read of the record, shared by every section below. It is a `git log`
281
+ # under the git backend, so asking three times is three subprocesses for one
282
+ # answer that cannot change between them.
283
+ log = events.read(root)
284
+ recent = [e for e in log if e["event"] == "completed"][-5:]
285
+ print(f"\nSHIPPED (last {len(recent)})")
286
+ for e in recent:
287
+ print(f" {e['ts'][:10]} {e['name']}"
288
+ + (f" — {e['delivered']}" if e.get("delivered") else ""))
289
+ if not recent:
290
+ print(" nothing recorded yet.")
291
+
292
+ # Who last touched each item, out of the commits. A lease answers this only for
293
+ # the unattended loop — an interactive session writes none at all, so reading
294
+ # claims alone printed `unclaimed` against every item a person was working and
295
+ # told the one screen the shift is read from nothing whatsoever.
296
+ touched = {}
297
+ for e in log:
298
+ if e.get("by"):
299
+ touched[e["name"]] = e
300
+
301
+ in_flight = [t for v in live for t in v.bucket("in-progress")]
302
+ print(f"\nIN FLIGHT ({len(in_flight)})")
303
+ for t in in_flight:
304
+ c = read_claim(t.folder)
305
+ last = touched.get(t.name)
306
+ if c:
307
+ who = f" · {c['instance'][:8]} until {c['expires'][11:16]}Z"
308
+ elif last:
309
+ who = f" · last touched {_ago(last['ts'])}"
310
+ else:
311
+ who = " · never touched"
312
+ print(f" {t.name} — {t.title} [tier {tier_of(t)}{who}]")
313
+ # Only for work that is somebody ELSE's: on your own tasks this would be a
314
+ # line about yourself on every status, which is how a useful line becomes
315
+ # one people stop reading.
316
+ if c and c.get("instance") != peers.me():
317
+ print(f" {peers.of_claim(c)}")
318
+ elif not c and last and last["by"] != peers.me():
319
+ print(f" {peers.describe(last['by'], last.get('machine', ''))}")
320
+ if not in_flight:
321
+ print(" nothing is being worked on.")
322
+
323
+ # At risk: claimed-but-expired (an instance died), and gates that keep refusing.
324
+ stale = [t for t in in_flight if not read_claim(t.folder) and (t.folder / CLAIM).is_file()]
325
+ # A task refused repeatedly is a task not converging — UNLESS it is parked with
326
+ # an open question, which is the designed outcome rather than a failure. Counting
327
+ # those would put the harness's own correct behaviour on the risk list, and a
328
+ # risk list with routine entries on it is one that stops being read.
329
+ waiting = {t.name for v in live for t in v.bucket(BLOCKED)}
330
+ refused = {}
331
+ for e in log:
332
+ if e["event"] == "gate-refused" and e["name"] not in waiting:
333
+ refused[e["name"]] = refused.get(e["name"], 0) + 1
334
+ repeat = {n: c for n, c in refused.items() if c >= 2}
335
+ print(f"\nAT RISK ({len(stale) + len(repeat)})")
336
+ for t in stale:
337
+ print(f" {t.name}: lease expired — the instance working it is gone")
338
+ for n, c in sorted(repeat.items()):
339
+ print(f" {n}: completion refused {c}× — it is not converging")
340
+ if not stale and not repeat:
341
+ print(" nothing.")
342
+
343
+ print("\nBOARD")
344
+ for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
345
+ counts = " · ".join(f"{b} {len(v.bucket(b))}" for b in BUCKETS
346
+ if v.bucket(b))
347
+ print(f" {v.name} [{v.status()}] {counts or 'empty'}")
348
+ return 0