@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,258 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# This module sits at <payload>/harness/tree.py, and the payload is the directory
|
|
7
|
+
# that ships inside the CLI. The anchor is derived from that ONE fact and named,
|
|
8
|
+
# because the split from a single work.py to this package moved every relative path
|
|
9
|
+
# up a level and broke `assets_dir()` silently — the scaffold templates simply
|
|
10
|
+
# stopped being found.
|
|
11
|
+
PAYLOAD_DIR = Path(__file__).resolve().parents[1]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
BUCKETS = ("queue", "in-progress", "blocked", "complete")
|
|
15
|
+
# `blocked/` is where a task waits on a HUMAN, and it exists because the harness
|
|
16
|
+
# now runs unattended. Under a person the third state was unnecessary — a question
|
|
17
|
+
# was asked and answered in the same conversation. With nobody in the chair the
|
|
18
|
+
# same question has exactly two bad answers: block the run forever, or decide it
|
|
19
|
+
# silently and leave no trace that a call the founder owned was made without them.
|
|
20
|
+
#
|
|
21
|
+
# So a question is an artifact. `ask` writes it, moves the task here and EXITS 0 —
|
|
22
|
+
# the instance takes other work rather than stalling. `needs` lists every open one,
|
|
23
|
+
# `answer` unblocks. Blocked is a bucket rather than a frontmatter flag for the
|
|
24
|
+
# same reason status is: two writers for one fact is how they drift apart.
|
|
25
|
+
BLOCKED = "blocked"
|
|
26
|
+
# Buckets holding work that is still MOVING — everything a lint should hold to the
|
|
27
|
+
# live rules. Named once because it was spelled out as a literal in four lints, and
|
|
28
|
+
# adding `blocked` to `BUCKETS` would otherwise have left all four quietly excusing
|
|
29
|
+
# a parked task from the rules its siblings answer to. A set that has to be updated
|
|
30
|
+
# in four places is a set that is wrong in three of them.
|
|
31
|
+
ACTIVE = ("queue", "in-progress", BLOCKED)
|
|
32
|
+
# Where a version parks an epic whose every task is done. It is deliberately the
|
|
33
|
+
# same word as the task bucket — an epic and a task mean the same thing by it —
|
|
34
|
+
# and it lives at the version's top level, so a finished goal drops out of the
|
|
35
|
+
# board without leaving the cut. Derived, never declared: `_settle_epic_tier`
|
|
36
|
+
# puts an epic here when its tasks say so and takes it straight back out when one
|
|
37
|
+
# reopens, exactly as a task's own status is the bucket it sits in.
|
|
38
|
+
DONE_TIER = "complete"
|
|
39
|
+
PRIORITIES = ("P0", "P1", "P2")
|
|
40
|
+
# A spec is a FILE, never a folder holding one file: `product/<feature>.md` and
|
|
41
|
+
# `architecture/<system>.md`. So a domain directory holds its specs alongside the
|
|
42
|
+
# ONE doc that describes the domain itself, and "every .md here is a spec" holds
|
|
43
|
+
# except for that one. It is `README.md` — the name every reader, tool and folder
|
|
44
|
+
# view already knows — and it is where a generated index lands too, in a marked
|
|
45
|
+
# block, so a domain never grows a second landing doc to disagree with the first.
|
|
46
|
+
RESERVED_MD = ("README.md",)
|
|
47
|
+
TASK_FM_ORDER = ("priority", "tier", "depends_on", "tags", "owner", "code", "covers",
|
|
48
|
+
"start", "end", "created", "updated", "sessions", "plans",
|
|
49
|
+
"asked", "verified", "observed", "completed")
|
|
50
|
+
# What a task's blast radius is, and therefore what an unattended run may do with
|
|
51
|
+
# it alone. This is the axis the harness did not have: `code:` region COUNT
|
|
52
|
+
# measured size (a three-region task is an epic in disguise), and size is not risk
|
|
53
|
+
# — renaming a variable across three regions is safe, rewriting auth in one is not.
|
|
54
|
+
#
|
|
55
|
+
# Deliberately four levels, not a score. A number invites arithmetic; a level
|
|
56
|
+
# invites a decision.
|
|
57
|
+
TIERS = (0, 1, 2, 3)
|
|
58
|
+
TIER_MEANING = {
|
|
59
|
+
0: "reversible and local — a typo, a comment, a test name",
|
|
60
|
+
1: "ordinary change behind existing tests",
|
|
61
|
+
2: "new behaviour, a new seam, or a migration that is reversible",
|
|
62
|
+
3: "irreversible, or it touches money, secrets, personal data or the law",
|
|
63
|
+
}
|
|
64
|
+
# Owners that FLOOR a task at tier 3 whatever it was given. Derived from the
|
|
65
|
+
# domain that owns the work, because that is a fact the tree already holds — and
|
|
66
|
+
# a derived floor cannot be forgotten the way a hand-set field can.
|
|
67
|
+
TIER3_OWNERS = ("security", "legal", "commercial")
|
|
68
|
+
# Above this an unattended run does not act alone: it asks. The default is 2, so a
|
|
69
|
+
# scheduled shift does ordinary work and stops at the irreversible. `autonomy.ceiling`
|
|
70
|
+
# in config moves it; 3 means "decide everything", which is a repo's call to make
|
|
71
|
+
# explicitly and never the shipped default.
|
|
72
|
+
DEFAULT_AUTONOMY_CEILING = 2
|
|
73
|
+
# Area tags a task may carry — `tags:` groups the NOW board by capability or
|
|
74
|
+
# market without a per-project board split. BOUND FROM `tags.allowed`; empty
|
|
75
|
+
# means no vocabulary is configured, and then any tag is accepted.
|
|
76
|
+
#
|
|
77
|
+
# This was a shipped tuple carrying one consumer's product names, which is the
|
|
78
|
+
# worst shape a default can take: every other repo inherited a vocabulary that
|
|
79
|
+
# described none of its work AND had its own tags refused against it. An area
|
|
80
|
+
# vocabulary is not derivable the way `code:` is (see `code_vocabulary()`), so it
|
|
81
|
+
# is stated in config or not at all.
|
|
82
|
+
TASK_TAGS_OK = ()
|
|
83
|
+
# The code-locality vocabulary a task's `code:` field draws from is DERIVED —
|
|
84
|
+
# see `code_vocabulary()`. It is the union of the `code:` regions the systems in
|
|
85
|
+
# `work/architecture/*.md` declare, so a region exists because a system
|
|
86
|
+
# owns it. There is deliberately no hand-curated tuple here: this list and the
|
|
87
|
+
# systems could disagree, and a fact that can be derived is never written.
|
|
88
|
+
VERSION_FM_ORDER = ("created", "updated", "order", "outcome", "start", "end",
|
|
89
|
+
"target", "released", "archived")
|
|
90
|
+
EPIC_FM_ORDER = ("created", "updated", "covers", "continues")
|
|
91
|
+
# A version is a RELEASE, so it cannot open while an earlier one is still open,
|
|
92
|
+
# and it must state what a user can newly DO when it ships. An EPIC is the
|
|
93
|
+
# temporary folder inside it that holds its tasks and owns the plan-it-together
|
|
94
|
+
# doc; it is removed at release, because how the work was planned stops being
|
|
95
|
+
# true the moment it ships. A TASK is ONE goal, end-to-end, internally phased.
|
|
96
|
+
#
|
|
97
|
+
# The measured shape of the failure, not taste: at the reshape (2026-08-01) 44
|
|
98
|
+
# of 106 tasks spanned ≥3 code regions, and a task touching three systems is a
|
|
99
|
+
# version wearing a task costume — which is how "we missed it, now we refactor"
|
|
100
|
+
# keeps happening.
|
|
101
|
+
#
|
|
102
|
+
# A `TASK_LINE_BUDGET` of 30 sat beside this and is GONE (founder call,
|
|
103
|
+
# 2026-08-02). Length was never the failure it was blamed for: a brief is read
|
|
104
|
+
# by a session starting cold, and the findings, dead ends and warnings that make
|
|
105
|
+
# that cheap are exactly what a line budget deletes. What actually goes wrong —
|
|
106
|
+
# a brief quietly becoming a competing plan — is caught by the artifact lint,
|
|
107
|
+
# which looks for the FILES, not the size.
|
|
108
|
+
TASK_REGION_CAP = 3
|
|
109
|
+
# The other end of the same rule, added 2026-08-02 (founder call), and it names a
|
|
110
|
+
# measured failure too: the backlog had reached 14 epics of which NINE held two
|
|
111
|
+
# tasks or fewer — one of them zero — so the board read as a pile of topics rather
|
|
112
|
+
# than a set of goals, and "plan it once for the whole goal" had nothing to plan.
|
|
113
|
+
# An epic is the unit you think about TOGETHER; at one or two tasks there is no
|
|
114
|
+
# together, and its epic.md degenerates into a second copy of the task brief.
|
|
115
|
+
#
|
|
116
|
+
# Below the floor, the FIRST move is to fold the tasks into an existing epic that
|
|
117
|
+
# already makes sense — not to invent a wrapper around them. A new epic is right
|
|
118
|
+
# only when more work is genuinely anticipated (its Plan can name the slices it
|
|
119
|
+
# has not cut yet), which is why this warns rather than blocks: an epic that is
|
|
120
|
+
# planned deep and cut shallow is legitimate, and says so in its own §Plan.
|
|
121
|
+
EPIC_TASK_FLOOR = 3
|
|
122
|
+
# A feature's lifecycle, product-language not engineering-status.
|
|
123
|
+
PRODUCT_STATES = ("idea", "defined", "building", "shipped", "retired")
|
|
124
|
+
BACKLOG_START = "<!-- BACKLOG:START -->"
|
|
125
|
+
BACKLOG_END = "<!-- BACKLOG:END -->"
|
|
126
|
+
# The generated system index lives INSIDE architecture/README.md, which is
|
|
127
|
+
# also the domain's hand-written landing doc — one file per folder.
|
|
128
|
+
SYSTEMS_START = "<!-- SYSTEMS:START -->"
|
|
129
|
+
SYSTEMS_END = "<!-- SYSTEMS:END -->"
|
|
130
|
+
# Files a task folder may legitimately carry (top-level). Anything else — in
|
|
131
|
+
# particular suffix variants like plan-foo.md / state.md / research-bar.md — is
|
|
132
|
+
# flagged by the lint. `research/` is the one allowed subdirectory. Working
|
|
133
|
+
# checklists (/code-review, /simplify findings) are NOT task files — they live
|
|
134
|
+
# in native TodoWrite.
|
|
135
|
+
TASK_FILES_OK = {
|
|
136
|
+
"task.md", "plan.md", "handoff.md", "research.md", "ui.md",
|
|
137
|
+
}
|
|
138
|
+
HANDOFF_CAP = 80
|
|
139
|
+
def cli() -> str:
|
|
140
|
+
"""How to name this tool back to the user.
|
|
141
|
+
|
|
142
|
+
One name, because there is now one way in: the payload ships inside the CLI and
|
|
143
|
+
is reached through it. Nobody can run `work.py` without knowing where the
|
|
144
|
+
payload landed, and that is the CLI's business rather than the caller's — so a
|
|
145
|
+
message naming the script was telling somebody to run a path they cannot
|
|
146
|
+
resolve, on the turn they hit an error.
|
|
147
|
+
"""
|
|
148
|
+
return "jarvis work"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def find_work_root() -> Path:
|
|
152
|
+
"""Locate the work/ directory. FAILS CLOSED — it never guesses a path.
|
|
153
|
+
|
|
154
|
+
Precedence: `WORK_DIR` (the tree directly) → `CLAUDE_PROJECT_DIR` → a walk up
|
|
155
|
+
from the cwd for a `work/` holding `versions/` or `backlog/` (so the payload's
|
|
156
|
+
own directory is never mistaken for it).
|
|
157
|
+
|
|
158
|
+
**There is no script-relative fallback, and removing it is a bug fix, not a
|
|
159
|
+
tightening.** It used to resolve `work/` beside the payload's OWN modules, so a
|
|
160
|
+
command run in a repo with no work tree wrote into the payload. Measured:
|
|
161
|
+
`version-new` in a fresh repo printed "<payload>/work/README.md not found",
|
|
162
|
+
created `<payload>/work/versions/v1/`, and exited 1. The consumer got nothing,
|
|
163
|
+
the payload got a version folder, and the exit code said failure while a
|
|
164
|
+
mutation had already landed.
|
|
165
|
+
|
|
166
|
+
Inside an installed package that fallback cannot be right by construction: the
|
|
167
|
+
payload is read-only shipped code and the tree belongs to the consumer. So an
|
|
168
|
+
unresolvable root exits non-zero naming what was looked for and what to run,
|
|
169
|
+
which is the one answer that cannot corrupt anything.
|
|
170
|
+
"""
|
|
171
|
+
root, tried, cur = locate_work_root()
|
|
172
|
+
if root:
|
|
173
|
+
return root
|
|
174
|
+
die("no work/ tree found — looked for "
|
|
175
|
+
+ (", ".join(tried) + ", and " if tried else "")
|
|
176
|
+
+ f"a work/ holding versions/ or backlog/ in {cur} or any parent.\n"
|
|
177
|
+
+ f" Run `{cli()} init` to create one, or pass --project <dir>.")
|
|
178
|
+
def locate_work_root() -> tuple:
|
|
179
|
+
"""The same search, reporting rather than exiting: `(root|None, tried, cwd)`.
|
|
180
|
+
|
|
181
|
+
Separate from `find_work_root` so that a caller which must NOT die on a missing
|
|
182
|
+
tree — `init`, which is about to create one, and the git seam, which has nothing
|
|
183
|
+
to commit without one — asks the same question and gets the same answer. Two
|
|
184
|
+
searches would be two answers about which repo this is.
|
|
185
|
+
"""
|
|
186
|
+
env = os.environ.get("WORK_DIR")
|
|
187
|
+
cur = Path.cwd().resolve()
|
|
188
|
+
if env:
|
|
189
|
+
return Path(env).resolve(), [], cur
|
|
190
|
+
tried = []
|
|
191
|
+
project = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
192
|
+
if project:
|
|
193
|
+
cand = Path(project).resolve() / "work"
|
|
194
|
+
if cand.is_dir():
|
|
195
|
+
return cand, tried, cur
|
|
196
|
+
tried.append(str(cand))
|
|
197
|
+
for d in (cur, *cur.parents):
|
|
198
|
+
cand = d / "work"
|
|
199
|
+
if cand.is_dir() and any((cand / sub).is_dir()
|
|
200
|
+
for sub in ("versions", "backlog")):
|
|
201
|
+
return cand, tried, cur
|
|
202
|
+
return None, tried, cur
|
|
203
|
+
def assets_dir() -> Path:
|
|
204
|
+
return PAYLOAD_DIR / "assets"
|
|
205
|
+
def rel(p: Path, root: Path) -> str:
|
|
206
|
+
try:
|
|
207
|
+
return str(p.relative_to(root.parent))
|
|
208
|
+
except ValueError:
|
|
209
|
+
return str(p)
|
|
210
|
+
def die(msg: str):
|
|
211
|
+
print(f"error: {msg}", file=sys.stderr)
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
#: Build output and vendored code — the non-dot generated directories. A repo adds
|
|
214
|
+
#: its own through config rather than editing this.
|
|
215
|
+
#: What the harness skips before any repo says anything. Kept apart from the live
|
|
216
|
+
#: value because `apply` rebuilds that from here on every call: unioning into the
|
|
217
|
+
#: live set instead made each config inherit the last one's additions, with nothing
|
|
218
|
+
#: anywhere able to undo them.
|
|
219
|
+
SHIPPED_SKIP_DIRS = {"node_modules", "dist", "build", "out", "coverage", "__out__",
|
|
220
|
+
"__pycache__", "vendor"}
|
|
221
|
+
|
|
222
|
+
#: The set in force — the shipped names plus whatever this repo's config adds.
|
|
223
|
+
SKIP_DIRS = set(SHIPPED_SKIP_DIRS)
|
|
224
|
+
|
|
225
|
+
#: A dot-directory that IS content, not cache. Everything else beginning with `.`
|
|
226
|
+
#: is skipped by rule, which is what makes this generic: `.next`, `.turbo`, `.git`,
|
|
227
|
+
#: `.venv`, `.work`, `.pytest_cache` and every tool cache a repo will invent are
|
|
228
|
+
#: covered without naming one of them.
|
|
229
|
+
#:
|
|
230
|
+
#: Naming caches individually is not a style preference — it is a correctness bug
|
|
231
|
+
#: with a measured instance. A graph engine's own snapshot directory held 1,521
|
|
232
|
+
#: generated files echoing real source, and scanning it minted a phantom test
|
|
233
|
+
#: binding for a criterion no test actually claimed: the report said a promise was
|
|
234
|
+
#: proven because a cache quoted the id. A skip list that must be told each cache's
|
|
235
|
+
#: name is a skip list that is wrong until someone notices.
|
|
236
|
+
KEEP_DOT_DIRS = {".claude"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _skipped(p: Path, repo: Path) -> bool:
|
|
240
|
+
"""Whether a path lies inside a generated or vendored directory."""
|
|
241
|
+
try:
|
|
242
|
+
parts = p.relative_to(repo).parts
|
|
243
|
+
except ValueError:
|
|
244
|
+
parts = p.parts
|
|
245
|
+
return any(part in SKIP_DIRS
|
|
246
|
+
or (part.startswith(".") and part not in KEEP_DOT_DIRS)
|
|
247
|
+
for part in parts[:-1] or parts)
|
|
248
|
+
TEXT_SUFFIXES = {".md", ".ts", ".tsx", ".py", ".mjs", ".js", ".json", ".sql",
|
|
249
|
+
".yml", ".yaml"}
|
|
250
|
+
def _repo_files(repo: Path) -> list:
|
|
251
|
+
out = []
|
|
252
|
+
for p in repo.rglob("*"):
|
|
253
|
+
if p.suffix not in TEXT_SUFFIXES or not p.is_file():
|
|
254
|
+
continue
|
|
255
|
+
if _skipped(p, repo):
|
|
256
|
+
continue
|
|
257
|
+
out.append(p)
|
|
258
|
+
return out
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import ids
|
|
8
|
+
from .tree import DONE_TIER, VERSION_FM_ORDER, cli, die, find_work_root, rel
|
|
9
|
+
from .frontmatter import rewrite_file
|
|
10
|
+
from .model import _is_epic_dir, locate_version, scan
|
|
11
|
+
from .scaffold import _check_unused, _check_version_name, _scaffold_version
|
|
12
|
+
from .epic import cmd_epic_release
|
|
13
|
+
from .generate import _sync
|
|
14
|
+
from . import events
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cmd_version_new(args) -> int:
|
|
18
|
+
"""Scaffold a version — behind ONE refusal, because a version is a RELEASE.
|
|
19
|
+
|
|
20
|
+
It must state a user-visible `outcome:`: if you cannot say what someone can
|
|
21
|
+
newly DO when it ships, it is not a release, and the work belongs in an epic
|
|
22
|
+
until it is. That refusal stays, and it is the one that makes a version mean
|
|
23
|
+
something.
|
|
24
|
+
|
|
25
|
+
**The second refusal is gone (founder, 2026-09-03).** A version used to be
|
|
26
|
+
unable to open while an earlier one was unreleased, aimed at how "nothing ever
|
|
27
|
+
closes and the list only grows" happened — measured at the reshape as 4
|
|
28
|
+
complete-but-unreleased versions, one of them 29 tasks and already in
|
|
29
|
+
production. But that is a failure to RELEASE, and it was guarded by refusing to
|
|
30
|
+
CREATE, which turns out to cost the thing it was meant to protect: with one cut
|
|
31
|
+
the only container available, everything that did not fit still had nowhere to
|
|
32
|
+
go, so it stayed. Measured here the same day: 155 tasks in one version, 62 of
|
|
33
|
+
them never started, and no way to lay out what comes next.
|
|
34
|
+
|
|
35
|
+
So the ladder is allowed and the sequencing is the person's: _"i might want to
|
|
36
|
+
work in parallel, just need it way more organized … it's my decision, not
|
|
37
|
+
something to enforce yet."_ What replaces the refusal is a NOTICE — the same
|
|
38
|
+
move the board already makes when somebody else holds an item, for the same
|
|
39
|
+
reason: a harness that argues gets worked around, and a refusal people routinely
|
|
40
|
+
override teaches them to override without reading."""
|
|
41
|
+
root = find_work_root()
|
|
42
|
+
name = args["name"]
|
|
43
|
+
_check_version_name(name)
|
|
44
|
+
_check_unused(root, name)
|
|
45
|
+
|
|
46
|
+
outcome = (args.get("outcome") or "").strip()
|
|
47
|
+
if not outcome or outcome == "true":
|
|
48
|
+
die("--outcome is required: state in ONE line what a user can newly DO "
|
|
49
|
+
"when this ships (e.g. --outcome 'a teacher can publish a lesson "
|
|
50
|
+
"under their own name'). A version is a release, not a container.")
|
|
51
|
+
|
|
52
|
+
open_versions = [v for v in scan(root)["versions"] if not v.released]
|
|
53
|
+
|
|
54
|
+
title = args.get("title") or name
|
|
55
|
+
target = (args.get("target") or "").strip()
|
|
56
|
+
if target and not re.match(r"^\d{4}-\d{2}-\d{2}$", target):
|
|
57
|
+
die("--target must be YYYY-MM-DD")
|
|
58
|
+
|
|
59
|
+
start = (args.get("start") or "").strip()
|
|
60
|
+
end = (args.get("end") or "").strip()
|
|
61
|
+
if start and not re.match(r"^\d{4}-\d{2}-\d{2}$", start):
|
|
62
|
+
die("--start must be YYYY-MM-DD")
|
|
63
|
+
if end and not re.match(r"^\d{4}-\d{2}-\d{2}$", end):
|
|
64
|
+
die("--end must be YYYY-MM-DD")
|
|
65
|
+
if start and end and start > end:
|
|
66
|
+
die(f"--start ({start}) must be <= --end ({end})")
|
|
67
|
+
|
|
68
|
+
# `order:` — explicit --order wins; a `NN-slug` name prefix must AGREE with
|
|
69
|
+
# an explicit --order (else die, catching a stale/typo'd flag); with no
|
|
70
|
+
# --order, a name prefix is the order (mirrors `_version_order`'s read-side
|
|
71
|
+
# derivation so a fresh scaffold is never silently un-ordered).
|
|
72
|
+
order_arg = (args.get("order") or "").strip()
|
|
73
|
+
if order_arg and not re.match(r"^\d+$", order_arg):
|
|
74
|
+
die("--order must be a non-negative integer")
|
|
75
|
+
order = order_arg
|
|
76
|
+
prefix_m = re.match(r"^(\d+)-", name)
|
|
77
|
+
if prefix_m:
|
|
78
|
+
prefix = prefix_m.group(1)
|
|
79
|
+
if order and int(order) != int(prefix):
|
|
80
|
+
die(f"--order {order} disagrees with the name's numeric prefix "
|
|
81
|
+
f"'{prefix}-' in '{name}' — drop --order or make them agree")
|
|
82
|
+
if not order:
|
|
83
|
+
order = prefix
|
|
84
|
+
|
|
85
|
+
with_design = str(args.get("design", "")).lower() in ("true", "1", "yes")
|
|
86
|
+
with_architecture = str(args.get("architecture", "")).lower() in ("true", "1", "yes")
|
|
87
|
+
folder = root / "versions" / name
|
|
88
|
+
if folder.exists():
|
|
89
|
+
die(f"{rel(folder, root)} already exists")
|
|
90
|
+
_scaffold_version(folder, title, target, with_design, with_architecture,
|
|
91
|
+
order=order, start=start, end=end, outcome=outcome)
|
|
92
|
+
print(f"created {rel(folder / 'version.md', root)}")
|
|
93
|
+
# What the old refusal used to prevent, said instead of enforced. Working two
|
|
94
|
+
# cuts at once is a real choice with a real cost, and it is the person's to
|
|
95
|
+
# make — but making it by accident, because nothing mentioned the other cut, is
|
|
96
|
+
# not a choice at all.
|
|
97
|
+
if open_versions:
|
|
98
|
+
names = ", ".join(v.name for v in open_versions)
|
|
99
|
+
print(f" note: {len(open_versions)} other version(s) not yet "
|
|
100
|
+
f"released ({names}). Nothing stops you working both — but a cut "
|
|
101
|
+
f"only closes when every task in it is complete.")
|
|
102
|
+
if with_design:
|
|
103
|
+
print(f" {rel(folder / 'design.md', root)} (paste at claude.ai/design)")
|
|
104
|
+
if with_architecture:
|
|
105
|
+
print(f" {rel(folder / 'architecture.md', root)} (the cross-task technical brief)")
|
|
106
|
+
_sync(root)
|
|
107
|
+
return 0
|
|
108
|
+
def _flatten_done_tier(version) -> None:
|
|
109
|
+
"""Lift every epic out of `<v>/complete/` back to the version's top level.
|
|
110
|
+
|
|
111
|
+
Called at release only. The tier exists to keep a moving board readable; once a
|
|
112
|
+
cut ships nothing in it is moving, so the distinction stops meaning anything and
|
|
113
|
+
would only add a level for `path`/`where` to walk through forever.
|
|
114
|
+
|
|
115
|
+
**EPICS ONLY.** `<v>/complete/` names two different things, told apart by the
|
|
116
|
+
marker file: a completed *epic*, and — in a flat version that never adopted the
|
|
117
|
+
tier — the completed-*task* bucket. Moving every subdir treated a grandfathered
|
|
118
|
+
version's finished tasks as epics and lifted them out of their bucket, and a
|
|
119
|
+
task's bucket IS its status, so releasing a flat cut silently un-completed
|
|
120
|
+
everything in it. `_is_epic_dir` is the same predicate the model reads both
|
|
121
|
+
tiers with, so there is one answer to "is this an epic" and not two.
|
|
122
|
+
"""
|
|
123
|
+
tier = version.folder / DONE_TIER
|
|
124
|
+
if not tier.is_dir():
|
|
125
|
+
return
|
|
126
|
+
for epic in sorted(tier.iterdir()):
|
|
127
|
+
if not _is_epic_dir(epic):
|
|
128
|
+
continue
|
|
129
|
+
dest = version.folder / epic.name
|
|
130
|
+
if dest.exists():
|
|
131
|
+
die(f"cannot flatten '{epic.name}' — {rel(dest, version.folder)} already exists")
|
|
132
|
+
shutil.move(str(epic), str(dest))
|
|
133
|
+
if not any(tier.iterdir()):
|
|
134
|
+
tier.rmdir()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_release(args) -> int:
|
|
138
|
+
root = find_work_root()
|
|
139
|
+
name = args["name"]
|
|
140
|
+
version = locate_version(root, name)
|
|
141
|
+
if not version:
|
|
142
|
+
die(f"no version named '{name}'")
|
|
143
|
+
if version.released:
|
|
144
|
+
die(f"version '{name}' is already released ({version.released})")
|
|
145
|
+
if not version.all_tasks():
|
|
146
|
+
die(f"version '{name}' has no tasks — add at least one before releasing")
|
|
147
|
+
incomplete = [t for t in version.all_tasks() if t.status != "complete"]
|
|
148
|
+
if incomplete:
|
|
149
|
+
die(f"version '{name}' has {len(incomplete)} task(s) not complete — "
|
|
150
|
+
f"finish or move them first")
|
|
151
|
+
if not version.outcome:
|
|
152
|
+
die(f"version '{name}' states no `outcome:` — a release says what a user "
|
|
153
|
+
f"can newly DO. Add one line to {rel(version.md, root)} first.")
|
|
154
|
+
|
|
155
|
+
# FLATTEN THE DONE TIER FIRST, while the version is still unreleased and
|
|
156
|
+
# `settle_epic_tier` will still act on it. At release every epic is complete by
|
|
157
|
+
# definition, so leaving them parked would make `<v>/complete/` the whole cut —
|
|
158
|
+
# a board affordance turned into the archived record's shape, for no reader. The
|
|
159
|
+
# record keeps the flat epic list it has always had.
|
|
160
|
+
_flatten_done_tier(version)
|
|
161
|
+
# THE FLATTEN MOVED FOLDERS, so every path hanging off `version` is now stale.
|
|
162
|
+
# Re-locate before anything else touches the tree.
|
|
163
|
+
#
|
|
164
|
+
# Skipping this is what broke `release` on its ONLY normal path: a version can
|
|
165
|
+
# be released only when every task is complete, which is exactly the condition
|
|
166
|
+
# that parks each epic in `<v>/complete/` — so every well-formed release hit it.
|
|
167
|
+
# `cmd_epic_release` walked the pre-move model and raised FileNotFoundError on
|
|
168
|
+
# `<v>/complete/<epic>/epic.md`, *after* `released:` had already been stamped:
|
|
169
|
+
# a traceback, a half-applied release, and `epic.md` — the one file release
|
|
170
|
+
# exists to remove — left in place for `align` to report.
|
|
171
|
+
version = locate_version(root, name)
|
|
172
|
+
if not version: # pragma: no cover — defensive
|
|
173
|
+
die(f"version '{name}' vanished mid-release — the tree moved underneath it")
|
|
174
|
+
|
|
175
|
+
md = version.md
|
|
176
|
+
rewrite_file(
|
|
177
|
+
md,
|
|
178
|
+
lambda d: d.update({
|
|
179
|
+
"released": date.today().isoformat(),
|
|
180
|
+
"updated": date.today().isoformat(),
|
|
181
|
+
}),
|
|
182
|
+
VERSION_FM_ORDER,
|
|
183
|
+
)
|
|
184
|
+
events.append(root, "released", name, outcome=version.outcome)
|
|
185
|
+
print(f"released '{name}' ({date.today().isoformat()})")
|
|
186
|
+
print(f" {version.outcome}")
|
|
187
|
+
# An epic is temporary BY DEFINITION — it is the plan-it-together doc, and
|
|
188
|
+
# how the work was planned stops being true once it ships.
|
|
189
|
+
cmd_epic_release(root, version)
|
|
190
|
+
print("\nDistill before archiving:")
|
|
191
|
+
print(" 1. Promote still-load-bearing decisions to the domain or system that owns them")
|
|
192
|
+
print(f" 2. Repoint any inbound deep-links to those {ids.LEDGER}-nn entries")
|
|
193
|
+
print(f" 3. {cli()} archive {name} (strips each task to task.md)")
|
|
194
|
+
_sync(root)
|
|
195
|
+
return 0
|
|
196
|
+
def _short_sha() -> str:
|
|
197
|
+
try:
|
|
198
|
+
out = subprocess.run(
|
|
199
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
200
|
+
capture_output=True, text=True, timeout=5,
|
|
201
|
+
)
|
|
202
|
+
return out.stdout.strip() if out.returncode == 0 else ""
|
|
203
|
+
except Exception:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _contained(p: Path, root: Path) -> Path:
|
|
208
|
+
"""`p`, proven to sit inside `root` — or a hard stop.
|
|
209
|
+
|
|
210
|
+
`archive` is the only command that deletes recursively, and centralising the
|
|
211
|
+
harness means its blast radius becomes *every installed repo* rather than one.
|
|
212
|
+
So every path it touches is checked against the tree root before anything is
|
|
213
|
+
removed, and the check is on the RESOLVED path: `resolve()` collapses `..` and
|
|
214
|
+
follows symlinks, so a task folder that is a symlink out of the tree, or a name
|
|
215
|
+
carrying traversal, both land outside `root` and stop here.
|
|
216
|
+
|
|
217
|
+
`strict=False` on purpose — a path being deleted may already be gone, and that
|
|
218
|
+
is not a containment failure.
|
|
219
|
+
"""
|
|
220
|
+
rp, rr = p.resolve(strict=False), root.resolve(strict=False)
|
|
221
|
+
if rp != rr and rr not in rp.parents:
|
|
222
|
+
die(f"refusing to touch {rp} — it resolves outside the work tree at {rr}. "
|
|
223
|
+
f"A symlink or a '..' in a folder name will do this.")
|
|
224
|
+
return rp
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_archive(args) -> int:
|
|
228
|
+
root = find_work_root()
|
|
229
|
+
name = args["name"]
|
|
230
|
+
dry = str(args.get("dry-run", "")).lower() in ("true", "1", "yes")
|
|
231
|
+
version = locate_version(root, name)
|
|
232
|
+
if not version:
|
|
233
|
+
die(f"no version named '{name}'")
|
|
234
|
+
if not version.released:
|
|
235
|
+
die(f"version '{name}' is not released — release it before archiving")
|
|
236
|
+
if version.fm.get("archived"):
|
|
237
|
+
die(f"version '{name}' is already archived ({version.fm['archived']})")
|
|
238
|
+
|
|
239
|
+
if dry:
|
|
240
|
+
print(f"DRY RUN — nothing will be written. `archive {name}` would:")
|
|
241
|
+
|
|
242
|
+
removed = 0
|
|
243
|
+
for t in version.all_tasks():
|
|
244
|
+
_contained(t.folder, root)
|
|
245
|
+
for p in sorted(t.folder.iterdir()):
|
|
246
|
+
if p.name == "task.md":
|
|
247
|
+
continue
|
|
248
|
+
_contained(p, root)
|
|
249
|
+
if dry:
|
|
250
|
+
print(f" strip {rel(p, root)}")
|
|
251
|
+
elif p.is_dir():
|
|
252
|
+
shutil.rmtree(p)
|
|
253
|
+
else:
|
|
254
|
+
p.unlink()
|
|
255
|
+
removed += 1
|
|
256
|
+
|
|
257
|
+
# Version-level scratch beyond the mandatory spine goes too.
|
|
258
|
+
keep = {"version.md", "stories.md", "design.md", "architecture.md"}
|
|
259
|
+
for p in sorted(version.folder.iterdir()):
|
|
260
|
+
if p.is_file() and p.name not in keep:
|
|
261
|
+
_contained(p, root)
|
|
262
|
+
if dry:
|
|
263
|
+
print(f" strip {rel(p, root)}")
|
|
264
|
+
else:
|
|
265
|
+
print(f" removed version scratch {p.name}")
|
|
266
|
+
p.unlink()
|
|
267
|
+
removed += 1
|
|
268
|
+
|
|
269
|
+
if dry:
|
|
270
|
+
dest = root / "archive" / "versions" / name
|
|
271
|
+
print(f" stamp archived: on {rel(version.md, root)}")
|
|
272
|
+
print(f" move {rel(version.folder, root)} -> {rel(dest, root)}")
|
|
273
|
+
print(f"\n {removed} file(s)/dir(s) would be stripped. "
|
|
274
|
+
f"Re-run without --dry-run to do it.")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
sha = _short_sha()
|
|
278
|
+
note = f"{date.today().isoformat()}"
|
|
279
|
+
md = version.md
|
|
280
|
+
rewrite_file(
|
|
281
|
+
md,
|
|
282
|
+
lambda d: d.update({
|
|
283
|
+
"archived": note,
|
|
284
|
+
"updated": date.today().isoformat(),
|
|
285
|
+
}),
|
|
286
|
+
VERSION_FM_ORDER,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# …and OFF the board. An archived version is a permanent record, not live
|
|
290
|
+
# work, and leaving it in `versions/` is why `jarvis work list` had to be read
|
|
291
|
+
# past 60 completed tasks to find the 6 that were actually moving. The
|
|
292
|
+
# record is preserved verbatim in `archive/versions/<v>/`, where `path` and
|
|
293
|
+
# `where` still resolve it — it is just no longer in the way.
|
|
294
|
+
dest = _contained(root / "archive" / "versions" / name, root)
|
|
295
|
+
if dest.exists():
|
|
296
|
+
die(f"{rel(dest, root)} already exists")
|
|
297
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
shutil.move(str(_contained(version.folder, root)), str(dest))
|
|
299
|
+
|
|
300
|
+
where = f" — full docs in git history @ {sha}" if sha else ""
|
|
301
|
+
events.append(root, "archived", name, stripped=removed)
|
|
302
|
+
print(f"archived '{name}': stripped {removed} file(s) to task.md, "
|
|
303
|
+
f"moved to {rel(dest, root)}{where}")
|
|
304
|
+
_sync(root)
|
|
305
|
+
return 0
|