@appchy/jarvis 0.1.36 → 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 +10104 -37174
  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 -38718
  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,364 @@
1
+ import os
2
+ import re
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from .tree import BUCKETS, DONE_TIER, PRIORITIES, RESERVED_MD
7
+ from .frontmatter import as_list, parse_frontmatter, read_item, rewrite_file, title_of
8
+
9
+
10
+ def current_session_id() -> str:
11
+ return os.environ.get("CLAUDE_CODE_SESSION_ID", "").strip()
12
+ def record_session(folder: Path, sid=None, label=None) -> bool:
13
+ """Append a Claude Code session to a task's frontmatter, deduped by session
14
+ id. Returns True when a new entry was added.
15
+
16
+ `sid` defaults to the CURRENT session, which is every caller but one. The
17
+ exception is a session this one STARTED: the wrap opens the successor and is
18
+ the only thing that will ever know both ids, and with no way to pass one the
19
+ link was hand-written into the handoff as prose and inferred by nothing. A
20
+ board fact belongs on the board.
21
+
22
+ `label` is appended to the entry so the two are told apart on sight. It is
23
+ not a separate field: `sessions:` is a flat list read by several things, and
24
+ a second shape for one entry would break every one of them to record a word.
25
+ """
26
+ sid = sid or current_session_id()
27
+ if not sid:
28
+ return False
29
+ md = folder / "task.md"
30
+ if not md.is_file():
31
+ return False
32
+ text = read_item(md)
33
+ sessions = as_list(parse_frontmatter(text).get("sessions"))
34
+ if any(sid in entry for entry in sessions):
35
+ return False
36
+ entry = f"{date.today().isoformat()} {sid}" + (f" {label}" if label else "")
37
+
38
+ # The check that matters happens INSIDE the mutation, against the text being
39
+ # written rather than the one read a moment earlier — otherwise a write landing
40
+ # between the two records the same session twice.
41
+ def mutate(d):
42
+ if any(sid in existing for existing in as_list(d.get("sessions"))):
43
+ return
44
+ d["sessions"] = as_list(d.get("sessions")) + [entry]
45
+ d["updated"] = date.today().isoformat()
46
+
47
+ rewrite_file(md, mutate)
48
+ return True
49
+ class Task:
50
+ """A task folder with a task.md inside it. `status` is the bucket name
51
+ (`queue`/`in-progress`/`complete`) for tasks in a version; `None` for a
52
+ backlog task (no status until pulled).
53
+
54
+ `epic` is the name of the epic holding it, or None for a loose task in a
55
+ version that has not adopted the epic shape (v20/v24 at the reshape). It is
56
+ DERIVED from where the folder sits — never a frontmatter field, for the same
57
+ reason status isn't: two writers for one fact is how they drift apart."""
58
+
59
+ def __init__(self, folder: Path, status, version, in_backlog: bool = False,
60
+ epic=None):
61
+ self.folder = folder
62
+ self.name = folder.name
63
+ self.status = status
64
+ self.version = version
65
+ self.in_backlog = in_backlog
66
+ self.epic = epic
67
+ self.md = folder / "task.md"
68
+ self.fm = parse_frontmatter(self.md.read_text())
69
+ self.title = title_of(self.md)
70
+ self.priority = str(self.fm.get("priority", "P2")).strip()
71
+ self.depends_on = as_list(self.fm.get("depends_on"))
72
+ self.code = as_list(self.fm.get("code"))
73
+ self.covers = as_list(self.fm.get("covers"))
74
+ # `owner:` is canonical; `product:` is the pre-domain spelling still on
75
+ # disk while the rename lands. One reader, so no consumer has to know.
76
+ self.owner = str(self.fm.get("owner")
77
+ or self.fm.get("product") or "").strip()
78
+ self.legacy_product = "owner" not in self.fm and "product" in self.fm
79
+ self.start = (str(self.fm.get("start", "")).strip() or None)
80
+ self.end = (str(self.fm.get("end", "")).strip() or None)
81
+ self.sessions = as_list(self.fm.get("sessions"))
82
+ self.updated = str(self.fm.get("updated", "")).strip()
83
+ self.completed = str(self.fm.get("completed", "")).strip()
84
+ self.artifacts = sorted(
85
+ p.name for p in folder.glob("*.md")
86
+ if p.name not in ("task.md", "stories.md")
87
+ )
88
+
89
+ def priority_rank(self) -> int:
90
+ return PRIORITIES.index(self.priority) if self.priority in PRIORITIES else 9
91
+
92
+ def display_status(self) -> str:
93
+ return self.status if self.status else "backlog"
94
+ def _version_order(fm: dict, name: str) -> int:
95
+ """Derive a version's roadmap sort order: an explicit `order:`
96
+ frontmatter value wins; else a leading numeric prefix in the name
97
+ (`0-scene-generator` -> 0, `3-poc-map` -> 3); else 9999 so un-ordered
98
+ versions sort last rather than breaking the roadmap."""
99
+ raw = str(fm.get("order", "")).strip()
100
+ if raw:
101
+ try:
102
+ return int(raw)
103
+ except ValueError:
104
+ pass
105
+ m = re.match(r"^(\d+)-", name)
106
+ if m:
107
+ return int(m.group(1))
108
+ return 9999
109
+ def _is_epic_dir(p: Path) -> bool:
110
+ """Is this subdirectory of a version an epic? It carries `epic.md` while it
111
+ is being planned, and only its buckets once `release` has removed that —
112
+ either shape counts, or a released version's own tasks would vanish from
113
+ every listing. The version's own bucket dirs are excluded by name."""
114
+ if not p.is_dir() or p.name in BUCKETS:
115
+ return False
116
+ return (p / "epic.md").is_file() or any((p / b).is_dir() for b in BUCKETS)
117
+ class Epic:
118
+ """An `epic.md` folder — the TEMPORARY tier between a version and its tasks:
119
+ the coherent goal you plan together, holding the tasks that deliver it.
120
+
121
+ Two shapes, one class. In a version it owns bucket subdirs
122
+ (`<v>/<epic>/{queue,in-progress,complete}/<task>/`); in the backlog its tasks
123
+ sit directly under it (`backlog/<epic>/<task>/`) because a backlog task has
124
+ no status until it is pulled — the same reason `backlog/` never had buckets.
125
+
126
+ An epic is temporary BY DEFINITION: `release` removes `epic.md`, because how
127
+ the work was planned stops being true the moment it ships. The folder stays
128
+ as the grouping of what shipped; git holds the plan."""
129
+
130
+ def __init__(self, folder: Path, version=None, in_backlog: bool = False):
131
+ self.folder = folder
132
+ self.name = folder.name
133
+ self.md = folder / "epic.md"
134
+ # After release the plan doc is gone and only the grouping remains, so
135
+ # everything read from epic.md must tolerate its absence — otherwise
136
+ # `release` would make its own tasks unreachable, which is a far worse
137
+ # bug than a missing title.
138
+ self.planned = self.md.is_file()
139
+ self.fm = parse_frontmatter(self.md.read_text()) if self.planned else {}
140
+ self.title = title_of(self.md) if self.planned else folder.name
141
+ self.version = version
142
+ self.in_backlog = in_backlog
143
+ # Read off the PATH rather than passed in, so it cannot be set to something
144
+ # the tree disagrees with — the folder's home is the only statement of it.
145
+ self.done_tier = folder.parent.name == DONE_TIER
146
+ self.covers = as_list(self.fm.get("covers"))
147
+ # Same vocabulary as a task's `owner:` — a bare `<feature>` or a bare
148
+ # org-domain. It exists on an epic for one reason: an epic owned by a
149
+ # DOMAIN has no `AC-nn` layer to qualify against, because a domain hosts
150
+ # rules rather than criteria. Naming that domain is how such an epic
151
+ # states its finish line instead of leaving `covers:` to read as an
152
+ # oversight. An epic owned by a FEATURE has criteria available and is
153
+ # still expected to name them.
154
+ self.owner = str(self.fm.get("owner") or "").strip()
155
+ # The epic this one carries on from, in an earlier cut. An epic name is
156
+ # globally unique, so work moved into a later version arrives under a new
157
+ # name and would otherwise read as a fresh goal that happens to resemble an
158
+ # old one — losing the plan that already settled how to build it.
159
+ self.continues = str(self.fm.get("continues") or "").strip()
160
+ self.tasks: dict = {b: [] for b in BUCKETS}
161
+ self.backlog_tasks: list = []
162
+ if in_backlog:
163
+ for p in sorted(folder.iterdir()):
164
+ if p.is_dir() and (p / "task.md").is_file():
165
+ self.backlog_tasks.append(
166
+ Task(p, status=None, version=None, in_backlog=True,
167
+ epic=self.name)
168
+ )
169
+ else:
170
+ for b in BUCKETS:
171
+ bucket = folder / b
172
+ if not bucket.is_dir():
173
+ continue
174
+ for p in sorted(bucket.iterdir()):
175
+ if p.is_dir() and (p / "task.md").is_file():
176
+ self.tasks[b].append(
177
+ Task(p, status=b, version=version, epic=self.name)
178
+ )
179
+
180
+ def all_tasks(self) -> list:
181
+ return self.backlog_tasks + [t for b in BUCKETS for t in self.tasks[b]]
182
+
183
+ def is_done(self) -> bool:
184
+ """Every task in it is complete — and there is at least one.
185
+
186
+ An EMPTY epic is deliberately not done: a goal nobody has cut tasks for has
187
+ not been achieved, it has not been started, and parking it out of sight is
188
+ the one way this tier could hide work instead of revealing it."""
189
+ tasks = self.all_tasks()
190
+ return bool(tasks) and all(t.status == "complete" for t in tasks)
191
+ class Version:
192
+ """A version folder with version.md — a RELEASE, not a container of ideas:
193
+ it states the user-visible `outcome:` it delivers, and cannot open while an
194
+ earlier version is still unreleased.
195
+
196
+ Holds EPICS (`<v>/<epic>/`), each with its own buckets. Versions that
197
+ predate the epic tier hold their tasks in buckets directly, and both shapes
198
+ are read here — `all_tasks()` returns the union, so every consumer sees one
199
+ flat list and none of them had to learn the nesting."""
200
+
201
+ def __init__(self, folder: Path):
202
+ self.folder = folder
203
+ self.name = folder.name
204
+ self.md = folder / "version.md"
205
+ self.fm = parse_frontmatter(self.md.read_text())
206
+ self.title = title_of(self.md)
207
+ self.order = _version_order(self.fm, self.name)
208
+ self.outcome = str(self.fm.get("outcome", "")).strip()
209
+ self.start = (str(self.fm.get("start", "")).strip() or None)
210
+ self.end = (str(self.fm.get("end", "")).strip() or None)
211
+ self.target = str(self.fm.get("target", "")).strip()
212
+ self.released = str(self.fm.get("released", "")).strip()
213
+ # Loose tasks — the pre-epic shape, still read so grandfathered versions
214
+ # keep working untouched (v20/v24 hold live work at the reshape).
215
+ self.tasks: dict = {b: [] for b in BUCKETS}
216
+ for b in BUCKETS:
217
+ bucket = folder / b
218
+ if not bucket.is_dir():
219
+ continue
220
+ for p in sorted(bucket.iterdir()):
221
+ if p.is_dir() and (p / "task.md").is_file():
222
+ self.tasks[b].append(
223
+ Task(p, status=b, version=self.name)
224
+ )
225
+ # Epics sit at the version's top level while they are MOVING, and drop into
226
+ # `<v>/complete/` once every task in them is done — so the board shows work in
227
+ # flight and finished goals stop being something to read past. The tier is
228
+ # DERIVED and materialized by `_settle_epic_tier`, never hand-set: an epic is
229
+ # in `complete/` because its tasks put it there, and reopening one moves it
230
+ # straight back out. Both tiers are read here, so every consumer still sees one
231
+ # `epics` list and none of them had to learn the nesting.
232
+ self.epics: list = [
233
+ Epic(p, version=self.name) for p in sorted(folder.iterdir())
234
+ if _is_epic_dir(p)
235
+ ]
236
+ done_tier = folder / DONE_TIER
237
+ if done_tier.is_dir():
238
+ self.epics += [
239
+ Epic(p, version=self.name) for p in sorted(done_tier.iterdir())
240
+ if _is_epic_dir(p)
241
+ ]
242
+
243
+ def loose_tasks(self) -> list:
244
+ return [t for b in BUCKETS for t in self.tasks[b]]
245
+
246
+ def all_tasks(self) -> list:
247
+ return self.loose_tasks() + [t for e in self.epics for t in e.all_tasks()]
248
+
249
+ def bucket(self, name: str) -> list:
250
+ """Every task in one bucket, across the loose tier and every epic."""
251
+ return self.tasks[name] + [t for e in self.epics for t in e.tasks[name]]
252
+
253
+ def status(self) -> str:
254
+ """`released` if version.md carries a released date; `current` if any
255
+ task is in-progress; otherwise `planned`."""
256
+ if self.released:
257
+ return "released"
258
+ if any(t.status == "in-progress" for t in self.all_tasks()):
259
+ return "current"
260
+ return "planned"
261
+ def scan(root: Path) -> dict:
262
+ """Return {versions: [Version], backlog: [Task], backlog_epics: [Epic]}.
263
+
264
+ `backlog` is EVERY backlog task — loose ones and the ones inside a backlog
265
+ epic — so the many consumers that walk it are unaffected by the epic tier.
266
+ `backlog_epics` is there for the few that care about the grouping."""
267
+ versions = []
268
+ vdir = root / "versions"
269
+ if vdir.is_dir():
270
+ for v in sorted(vdir.iterdir()):
271
+ if v.is_dir() and (v / "version.md").is_file():
272
+ versions.append(Version(v))
273
+ # Roadmap order, not folder-name lexicographic — `order:` wins,
274
+ # else a `NN-slug` name prefix, else last (`_version_order`).
275
+ versions.sort(key=lambda v: (v.order, v.name))
276
+
277
+ backlog, backlog_epics = [], []
278
+ bdir = root / "backlog"
279
+ if bdir.is_dir():
280
+ # A folder is an EPIC if it holds epic.md, a TASK if it holds task.md.
281
+ # The marker file is the discriminator, so there is no registry to keep
282
+ # in step with the tree.
283
+ for sub in sorted(bdir.iterdir()):
284
+ if not sub.is_dir():
285
+ continue
286
+ if (sub / "epic.md").is_file():
287
+ epic = Epic(sub, in_backlog=True)
288
+ backlog_epics.append(epic)
289
+ backlog += epic.backlog_tasks
290
+ elif (sub / "task.md").is_file():
291
+ backlog.append(
292
+ Task(sub, status=None, version=None, in_backlog=True)
293
+ )
294
+
295
+ return {"versions": versions, "backlog": backlog,
296
+ "backlog_epics": backlog_epics}
297
+ def locate(root: Path, name: str):
298
+ """Find a task by name anywhere. Returns a Task or None."""
299
+ s = scan(root)
300
+ for v in s["versions"]:
301
+ for t in v.all_tasks():
302
+ if t.name == name:
303
+ return t
304
+ for t in s["backlog"]:
305
+ if t.name == name:
306
+ return t
307
+ return None
308
+ def locate_version(root: Path, name: str):
309
+ """Find a version by name. Looks in `archive/versions/` too, so a released
310
+ cut stays LOOKUPABLE after it leaves the board — `path` and `where` keep
311
+ answering, while `scan` (and therefore `list`, the README and every lint)
312
+ sees only live work. Archived is off the board, not gone."""
313
+ for base in ("versions", "archive/versions"):
314
+ folder = root / base / name
315
+ if (folder / "version.md").is_file():
316
+ return Version(folder)
317
+ return None
318
+ def locate_epic(root: Path, name: str):
319
+ """Find an epic by name anywhere — in a version or in the backlog. Names are
320
+ globally unique across tasks, epics and versions, so a name alone resolves."""
321
+ s = scan(root)
322
+ for v in s["versions"]:
323
+ for e in v.epics:
324
+ if e.name == name:
325
+ return e
326
+ for e in s["backlog_epics"]:
327
+ if e.name == name:
328
+ return e
329
+ return None
330
+ def epic_home(root: Path, epic: "Epic") -> str:
331
+ """A human-readable home for an epic — `version '<v>'` or `backlog`."""
332
+ return "backlog" if epic.in_backlog else f"version '{epic.version}'"
333
+ def locate_feature(root: Path, name: str):
334
+ """The one place that resolves `<feature>` to its spec FILE — mirrors
335
+ `locate_version`. `cmd_feature_new` and `_owner_ref_lint` both use it so the
336
+ existence check can't drift between "scaffold" and "lint" call sites.
337
+
338
+ There is no app tier and no folder tier: this repo is ONE product, so a feature
339
+ name is globally unique and `product/<feature>.md` is its whole address.
340
+ Returns the .md, not a directory — a feature no longer HAS a directory."""
341
+ md = root / "product" / f"{name}.md"
342
+ return md if md.is_file() and md.name not in RESERVED_MD else None
343
+ def scan_features(root: Path) -> list:
344
+ """Every feature spec, as a list of `.md` paths. The mirror of
345
+ `scan_systems`, and the one definition of "what counts as a feature file" —
346
+ three call sites globbed `product/*/feature.md` before the flatten, and a
347
+ flat glob would sweep up `README.md` if each re-derived the filter."""
348
+ product = root / "product"
349
+ if not product.is_dir():
350
+ return []
351
+ return [p for p in sorted(product.glob("*.md")) if p.name not in RESERVED_MD]
352
+ def _ordered(buckets: dict) -> list:
353
+ """in-progress, then blocked, then queue (by priority), then complete.
354
+
355
+ Blocked sits SECOND, directly under what is moving — above the queue, not
356
+ filed away near what is finished. It is the only bucket whose contents are
357
+ somebody's obligation rather than the harness's, and a board that buries the
358
+ one list a person has to act on is a board that quietly reinstates the
359
+ babysitting.
360
+ """
361
+ return (buckets["in-progress"]
362
+ + buckets["blocked"]
363
+ + sorted(buckets["queue"], key=lambda t: (t.priority_rank(), t.name))
364
+ + buckets["complete"])
@@ -0,0 +1,187 @@
1
+ """Somebody else is on this — and here is whether you can reach them.
2
+
3
+ **What was already known, and thrown away.** The harness has always recorded who
4
+ else is working. A `.claim` holds an instance id; a board commit holds
5
+ `Work-Session` and `Work-Machine`. What every surface then DID with that knowledge
6
+ was print eight hex characters — `held by a7c5d0cb` — which names a session without
7
+ reaching one. The id was right there and led nowhere, so the only move left was to
8
+ wait for a lease to expire, or take the work and collide.
9
+
10
+ So one function turns an id into the thing a session can act on: whether that
11
+ instance is on THIS machine, where `ListAgents` lists it and `SendMessage` reaches
12
+ it, or on another, where it cannot be reached from here at all.
13
+
14
+ **It never sends anything.** Not on a collision, not on a plan change, not ever.
15
+ An agent that messages another agent without the person in the loop is a
16
+ conversation nobody asked for and nobody can take back, so this decides nothing and
17
+ starts nothing: it says who, and whether they are reachable. Acting on that is the
18
+ session's call, and the person's.
19
+
20
+ **Reach is honest about its edge.** `SendMessage` addresses sessions on this
21
+ machine (and whatever Remote Control has connected). A claim from another host is
22
+ therefore reported as unreachable rather than left looking addressable — a name you
23
+ cannot deliver to is worse than a plain statement that you cannot. Carrying a
24
+ message between machines is a transport a repo brings with it; this harness reports
25
+ the reach it actually has and does not invent one.
26
+ """
27
+ import json
28
+ import os
29
+ from pathlib import Path
30
+
31
+ from . import git
32
+
33
+ #: How much of an instance id is worth showing. Eight hex characters is what every
34
+ #: surface here has always printed and what `ListAgents` matches on sight.
35
+ SHORT = 8
36
+
37
+
38
+ def me() -> str:
39
+ """This session's own instance id — the same one a claim is written with."""
40
+ return git._session()
41
+
42
+
43
+ def here() -> str:
44
+ """This machine's name, by the same rule a commit trailer uses. One answer, so
45
+ a claim and a commit can never disagree about which box they came from."""
46
+ return git.machine()
47
+
48
+
49
+ #: Where an agent client publishes the sessions it currently has running. Claude
50
+ #: Code is the only one that publishes anything today; the shape is one JSON file
51
+ #: per process carrying its own session id, its addressable name and its pid.
52
+ #: Overridable so this is testable without a real home directory.
53
+ SESSIONS = "WORK_SESSIONS_DIR"
54
+ DEFAULT_SESSIONS = "~/.claude/sessions"
55
+
56
+ #: Answers already worked out this run, keyed by the directory they came from. Keyed
57
+ #: rather than a single slot because the tests point this at several directories in
58
+ #: one process, and a cache that ignored where it read from would answer for the
59
+ #: wrong one.
60
+ _RUNNING = {}
61
+
62
+
63
+ def running():
64
+ """Every agent session live on THIS machine as `{session id: name}`, or `None`
65
+ when nothing here publishes that at all.
66
+
67
+ The `None` is the whole point and must not be flattened into an empty dict. No
68
+ directory means *this client does not say*, which is where Codex and Gemini sit
69
+ — and a caller that reads "nothing is running" from silence would report every
70
+ peer as dead on the clients that are least able to argue. An empty dict is a
71
+ different and much stronger claim: something published a list and this session
72
+ was not on it.
73
+
74
+ A pid is checked rather than trusted. The directory is cleaned up on exit, so in
75
+ practice it holds only live runs, but a process killed hard leaves its file
76
+ behind — and a stale entry here would resurrect exactly the ghost this exists to
77
+ catch. Signal 0 asks the kernel whether the pid is there without touching it.
78
+
79
+ **Known limit: a recycled pid reads as alive.** If a session is killed hard AND
80
+ the operating system later hands its number to some unrelated program, this says
81
+ running about a session that is gone. It is not closed here, and the alternatives
82
+ were worse: every artifact that could corroborate the pid — the messaging socket
83
+ included — is left behind by the same hard kill, so pairing two stale files
84
+ proves nothing, and reading a process's start time needs a child process this
85
+ module is deliberately not allowed to spawn. What bounds it instead is where the answer is used: one
86
+ row on a status screen reading "running" instead of "ended", never a board write,
87
+ which is the constraint the rule about a client's facts already sets.
88
+
89
+ The result is cached per directory for the life of the process. The harness is a
90
+ short-lived command, so re-globbing and re-signalling once per printed row is
91
+ repeated work for an answer that cannot change inside one run.
92
+ """
93
+ where = Path(os.path.expanduser(os.environ.get(SESSIONS) or DEFAULT_SESSIONS))
94
+ key = str(where)
95
+ if key in _RUNNING:
96
+ return _RUNNING[key]
97
+ if not where.is_dir():
98
+ _RUNNING[key] = None
99
+ return None
100
+ out = {}
101
+ for f in sorted(where.glob("*.json")):
102
+ try:
103
+ entry = json.loads(f.read_text(encoding="utf-8", errors="replace"))
104
+ except (json.JSONDecodeError, ValueError, OSError):
105
+ continue # a half-written file is not a dead session
106
+ if not isinstance(entry, dict):
107
+ continue
108
+ run, pid = str(entry.get("sessionId", "")), entry.get("pid")
109
+ if not run:
110
+ continue
111
+ try:
112
+ # Strictly positive: signal 0 to pid 0 asks about the whole process
113
+ # group and to a negative pid about another one, so either would answer
114
+ # "alive" about something that is not this session at all.
115
+ if int(pid) <= 0:
116
+ continue
117
+ os.kill(int(pid), 0)
118
+ except (TypeError, ValueError, ProcessLookupError):
119
+ continue
120
+ except PermissionError:
121
+ pass # alive and owned by somebody else, which is still alive
122
+ out[run] = str(entry.get("name", "")).strip()
123
+ _RUNNING[key] = out
124
+ return out
125
+
126
+
127
+ def describe(instance: str, host: str = "") -> str:
128
+ """`instance` (and the machine it sits on) as a line that says what to do next.
129
+
130
+ Answers that are genuinely different actions: it is you (carry on), it is
131
+ somebody elsewhere (you cannot reach them from here), or it is somebody on this
132
+ machine — and that last one splits again on whether they are still running.
133
+
134
+ That split is the correction. This used to promise that any id on this machine
135
+ could be reached, which is a promise about a process nobody had checked was
136
+ alive. It was believed: a run steering another run guessed at an address, a
137
+ different session recognised the description of itself, acted on it, and wrote
138
+ into the brief of work it was not doing. An id whose session has exited is not a
139
+ peer to negotiate with, it is abandoned work — and those want opposite moves.
140
+ """
141
+ who = (instance or "").strip()
142
+ if not who:
143
+ return "an unnamed session"
144
+ short = who[:SHORT]
145
+ if who == me():
146
+ return f"{short} — this session"
147
+ box = (host or "").strip()
148
+ if box and here() and box != here():
149
+ return (f"{short} on {box} — another machine, so it cannot be reached from "
150
+ f"here; the board is the only thing you share")
151
+ live = running()
152
+ if live is None:
153
+ return (f"{short} — this machine: `ListAgents` lists it, `SendMessage` reaches "
154
+ f"it if the plan needs to change")
155
+ if who not in live:
156
+ return (f"{short} — this machine, but that session has ENDED: there is "
157
+ f"nobody to reach, so this is abandoned rather than held")
158
+ name = live[who]
159
+ if not name:
160
+ return (f"{short} — this machine and still running, though it publishes no "
161
+ f"name to address it by")
162
+ return (f"`{name}` ({short}) — this machine and still running: `SendMessage` "
163
+ f"reaches it if the plan needs to change")
164
+
165
+
166
+ def of_claim(claim: dict) -> str:
167
+ """The same line, from a `.claim` — which carries the machine since this
168
+ landed, so a hold taken elsewhere reads as elsewhere rather than as local."""
169
+ return describe(str(claim.get("instance", "")), str(claim.get("machine", "")))
170
+
171
+
172
+ def arrivals(rows: list) -> str:
173
+ """What just landed on the board from somewhere else, as one line.
174
+
175
+ Read from the commits a pull brought in, so it answers the question a session
176
+ actually has when the ground moves under it — *who changed the plan, and can I
177
+ ask them about it* — rather than announcing that a fetch happened.
178
+ """
179
+ others = [r for r in rows if str(r.get("by", "")) != me()]
180
+ if not others:
181
+ return ""
182
+ items = sorted({str(r.get("name", "")) for r in others if r.get("name")})
183
+ sources = sorted({(str(r.get("by", "")), str(r.get("machine", "")))
184
+ for r in others if r.get("by") or r.get("machine")})
185
+ said = "; ".join(describe(who, host) for who, host in sources) or "another session"
186
+ shown = ", ".join(items[:4]) + (f" +{len(items) - 4} more" if len(items) > 4 else "")
187
+ return f"the board moved under you — {shown or 'board edits'} · from {said}"
@@ -0,0 +1,29 @@
1
+ from .tree import die, find_work_root, rel
2
+ from .model import locate_feature
3
+ from .scaffold import _check_kebab, _check_state, _scaffold_feature
4
+ from .generate import _sync
5
+
6
+
7
+ def cmd_feature_new(args) -> int:
8
+ """Scaffold `work/product/<feature>.md`.
9
+
10
+ There is no `--app`, and no `app-new`: this repo is ONE product, so a feature
11
+ sits directly under `product/` and its name is globally unique — which is
12
+ also why `owner:` is a bare feature name. It is a FILE, not a folder holding
13
+ one file: the path a spec is cited by must never change again, and a folder
14
+ that appears the day a sibling arrives is a path that changes on file count
15
+ (`AC-20`)."""
16
+ root = find_work_root()
17
+ name = args["name"]
18
+ _check_kebab("feature", name)
19
+ if locate_feature(root, name):
20
+ die(f"a feature named '{name}' already exists")
21
+ md = root / "product" / f"{name}.md"
22
+ if md.exists():
23
+ die(f"{rel(md, root)} already exists")
24
+ state = args.get("state") or "idea"
25
+ _check_state(state)
26
+ _scaffold_feature(md, state)
27
+ print(f"created {rel(md, root)}")
28
+ _sync(root)
29
+ return 0