@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.
- package/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10168 -37341
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38821
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- 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
|