@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,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