@appchy/jarvis 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10168 -37341
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38821
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. package/dist/env.json +0 -1
@@ -0,0 +1,140 @@
1
+ """The ledger id vocabulary — ONE definition of what a durable-rule id looks like,
2
+ so the letter is a repo setting rather than a constant baked into eight regexes.
3
+
4
+ A repo picks its own prefix: `D-nn`, `G-nn`, `ADR-nn`. The harness is otherwise
5
+ identical, and a fork that differs only in a letter is a fork that drifts — so the
6
+ letter lives here and nowhere else. `jarvis work where <id>` resolves whichever a repo
7
+ uses, because ids are permanent and only their HOME moves.
8
+
9
+ Everything downstream builds its pattern from `LEDGER` instead of writing `G-\\d+`
10
+ inline. Adding a second recognised prefix later (a merged repo, a migration window)
11
+ is a change to this list, not a sweep.
12
+ """
13
+ import re
14
+
15
+ #: The prefix this repo's durable rules carry. One token, no trailing dash.
16
+ #: Overridden per-repo by `ids.prefix` in `.claude/work.config.json`; the value
17
+ #: here is the shipped default, used when no config is present.
18
+ LEDGER = "D"
19
+
20
+ #: Prefixes a citation may legally use. The first is canonical — `id_new` and
21
+ #: every scaffold write this one. Extras are recognised but never generated,
22
+ #: which is how a migration window stays readable without going silent.
23
+ RECOGNISED = (LEDGER,)
24
+
25
+ #: Whether the DASH is optional when reading — `D12` as well as `D-12`.
26
+ #: OFF by default, and that default is a bug fix. A short prefix collides with
27
+ #: ordinary data the moment the dash is optional: a repo whose prefix is `P` had
28
+ #: every `priority: P0` in its task frontmatter read as a citation to a rule `P-0`
29
+ #: that does not exist, and `align` reported it as a dangling citation in seven
30
+ #: files. Turn it on only where an interface really does publish undashed ids.
31
+ UNDASHED = False
32
+
33
+
34
+ def configure(prefix: str, recognised: tuple = (), undashed: bool = False) -> None:
35
+ """Rebind the vocabulary from config, once, before anything parses.
36
+
37
+ Every pattern below is rebuilt rather than reassigned piecemeal, because a
38
+ module that captured `ID` at import time would keep the default and report a
39
+ repo's entire ledger as dangling — the failure mode is a wall of false
40
+ warnings, which is how a lint stops being read.
41
+ """
42
+ global LEDGER, RECOGNISED, UNDASHED, ID, ID_EXACT
43
+ LEDGER = prefix.rstrip("-")
44
+ RECOGNISED = tuple(dict.fromkeys((LEDGER, *(r.rstrip("-") for r in recognised))))
45
+ UNDASHED = undashed
46
+ ID = re.compile(_id_pattern())
47
+ ID_EXACT = re.compile(rf"^(?:{'|'.join(RECOGNISED)})-(?!0+\Z)\d+\Z")
48
+
49
+
50
+ def _id_pattern() -> str:
51
+ """The dash is REQUIRED unless a repo opts into `UNDASHED`. Writes always emit
52
+ the dashed canonical form either way.
53
+
54
+ **Number zero is not an id.** Ledger numbering starts at 1 in every repo, so
55
+ `<PREFIX>-0` can only be a typo or — the case that found this — prose ABOUT an
56
+ id-shaped token. A handoff log recording the undashed-prefix bug had to write
57
+ the words "`priority: P0` was read as a citation to `P-0`", and `align` then
58
+ reported `P-0` as a dangling citation in the very note explaining that it
59
+ isn't one. Excluding it here rather than in the citation check keeps one
60
+ definition of what an id is: `where P-0` now says it is not an id, instead of
61
+ hunting for a home that cannot exist.
62
+ """
63
+ dash = "-?" if UNDASHED else "-"
64
+ return rf"\b(?:{'|'.join(RECOGNISED)}){dash}(?!0+\b)\d+\b"
65
+
66
+
67
+ #: An id anywhere in a line of prose.
68
+ ID = re.compile(_id_pattern())
69
+
70
+ #: An id anchored for exact-match validation — no surrounding text allowed.
71
+ #:
72
+ #: `\Z` rather than `$`, which in Python matches before a trailing newline: `"D-12\n"`
73
+ #: passed as an exact id and reached a gate that then wrote the newline into a
74
+ #: heading. And zero is excluded here for the reason `_id_pattern` excludes it, so the
75
+ #: two answers to "is this an id" cannot disagree.
76
+ ID_EXACT = re.compile(rf"^(?:{'|'.join(RECOGNISED)})-(?!0+\Z)\d+\Z")
77
+
78
+
79
+ def pattern(group: bool = True) -> str:
80
+ """The bare id regex as a string, for embedding in a larger pattern.
81
+
82
+ `group=True` captures the id; callers that only need to anchor a heading pass
83
+ False so their own groups keep their numbering.
84
+ """
85
+ # The alternation is grouped, or with two recognised prefixes this reads as
86
+ # "prefix A, or prefix B followed by -nn" — and a bare `D` in any heading gets
87
+ # indexed as a rule id. `_id_pattern` has always grouped it; this had not.
88
+ alt = "|".join(RECOGNISED)
89
+ return rf"((?:{alt})-\d+)" if group else rf"(?:(?:{alt})-\d+)"
90
+
91
+
92
+ def heading(level: int = 3) -> re.Pattern:
93
+ """`### D-nn — title` — a definition site. Level 2 is a system's `## D-nn`."""
94
+ return re.compile(rf"^{'#' * level}\s+{pattern()}\b", re.MULTILINE)
95
+
96
+
97
+ def titled_heading(level: int = 3) -> re.Pattern:
98
+ """`### D-nn — title`, capturing (id, title). An entry with no title is not a
99
+ definition — `align` reports it rather than indexing a blank."""
100
+ return re.compile(rf"^{'#' * level}\s+{pattern()}\s*[—-]\s*(.+)$", re.MULTILINE)
101
+
102
+
103
+ def index_entry() -> re.Pattern:
104
+ """`- **D-nn** — title` or `- D-nn — title`, the Index line shapes.
105
+
106
+ The bold is optional because it is typography, not structure: one repo writes
107
+ the id bare and another writes it bold, and an Index the parser cannot read
108
+ reports every entry as missing.
109
+ """
110
+ return re.compile(rf"^-\s+\*{{0,2}}{pattern()}\*{{0,2}}\s*[—-]\s*(.+)$", re.MULTILINE)
111
+
112
+
113
+ def sort_key(id_: str) -> int:
114
+ """Numeric order, prefix- and dash-agnostic, so 9 sorts before 10 rather than
115
+ lexicographically.
116
+
117
+ The digits AFTER the last dash, never every digit in the string. A prefix need
118
+ only be alphanumeric, so `ADR2` is legal, and stripping non-digits turned
119
+ `ADR2-14` into 214 — which then fed "the highest id anywhere" and had `normalise`
120
+ fabricating `ADR2-214`.
121
+ """
122
+ tail = id_.rsplit("-", 1)[-1] if "-" in id_ else id_
123
+ digits = re.sub(r"\D", "", tail)
124
+ return int(digits) if digits else 0
125
+
126
+
127
+ def normalise(id_: str) -> str:
128
+ """Undashed or lower-case in, dashed canonical out. The one place that form
129
+ is made.
130
+
131
+ **The prefix it was written with survives.** `RECOGNISED` exists precisely so a
132
+ repo can read a second prefix through a migration, and rewriting every one of them
133
+ to `LEDGER` collapsed the two id spaces into one: `normalise("ADR-7")` returned
134
+ `"D-7"`, so `where ADR-7` printed D-7's rule and a citation to either resolved to
135
+ the other. Only the FORM is canonicalised here — the dash and the case — never
136
+ which ledger an id belongs to.
137
+ """
138
+ head = id_.rsplit("-", 1)[0] if "-" in id_ else re.sub(r"\d+$", "", id_)
139
+ known = {r.lower(): r for r in RECOGNISED}
140
+ return f"{known.get(head.strip().lower(), LEDGER)}-{sort_key(id_)}"
@@ -0,0 +1,231 @@
1
+ """The opening prompt for the NEXT session — derived, not written by hand.
2
+
3
+ A wrap ends with a prompt that opens the next session on this work. For a long
4
+ time that prompt was authored by whichever session happened to be wrapping, from
5
+ a template in `commands/wrap.md`, and it had one defect that made it useless as
6
+ shipped: **it never invoked the skill.** `SessionStart` tells a fresh session the
7
+ harness is here; nothing in it makes the session LOAD it. So every handed-over
8
+ prompt had the skill's own name typed in front of it by hand before it could be
9
+ used — every session, by the one person the handoff exists to save.
10
+
11
+ That is exactly the kind of fact a template cannot hold and a derivation can. The
12
+ command comes from `instructions.skill`, the paths come from the tree, the status
13
+ and next step come from the handoff the wrap has just made current, and the
14
+ standards the plan is held against come from `spine.*`. Nothing here is repeated
15
+ prose about any particular repo.
16
+
17
+ **The prompt is the whole brief the session starts with.** There is no second
18
+ message: `session_start` takes one string, the session takes its turn, and then
19
+ it sits open waiting for a person. So this file is not a nudge — it is the entire
20
+ contract with a session that will begin work unattended, which is why it states
21
+ the order (orient → settle what is open → state the plan → then code) rather than
22
+ assuming a fresh session infers it.
23
+ """
24
+ from pathlib import Path
25
+
26
+ from .model import locate
27
+ from .tree import cli, die, find_work_root, rel
28
+
29
+ #: `graph.mcp`, `session.mcp`, `session.tool`, `spine.*`, `instructions.skill` —
30
+ #: bound by `config.apply` rather than re-read here, for the same reason every
31
+ #: other module reads module attributes: one reader of the config file, and it is
32
+ #: not this.
33
+ GRAPH_MCP = None
34
+ SESSION_MCP = None
35
+ SESSION_TOOL = None
36
+ SPINE = []
37
+ SKILL = None
38
+
39
+ _RULE = "─" * 72
40
+
41
+
42
+ def _handoff_line(text: str, label: str):
43
+ """The `**Status:**` / `**NEXT:**` line out of a handoff, or None.
44
+
45
+ Read from the handoff rather than asked for on the command line, because the
46
+ wrap has just written them there and a value typed twice is a value that
47
+ disagrees with itself. The template pins both to the top two lines; this
48
+ tolerates them anywhere, since a session that moved them still meant them.
49
+ """
50
+ for raw in text.splitlines():
51
+ line = raw.strip()
52
+ if line.lower().startswith(f"**{label.lower()}:**"):
53
+ value = line[len(label) + 5:].strip()
54
+ # An unfilled template placeholder is not a status. Passing
55
+ # `<one line — where the task is right now.>` into the next session
56
+ # as fact is worse than passing nothing, which at least sends it to
57
+ # the handoff to look.
58
+ if value and not value.startswith("<"):
59
+ return value
60
+ return None
61
+
62
+
63
+ def _open_questions(task) -> list:
64
+ """Unanswered questions on the task — the same pairing `autonomy` does.
65
+
66
+ Imported through the module rather than duplicated: `asked:` is an
67
+ append-only transcript where an answer cancels the longest-waiting question,
68
+ and a second reader of that shape is a second place for it to be read wrong.
69
+ """
70
+ from .autonomy import _open_questions as read
71
+ return read(task)
72
+
73
+
74
+ def build_prompt(task, root: Path, next_step=None, watch=None) -> str:
75
+ """The prompt itself. Everything in it is derived except `watch`, which only
76
+ the session that just did the work can know."""
77
+ handoff = task.folder / "handoff.md"
78
+ text = handoff.read_text() if handoff.is_file() else ""
79
+ status = _handoff_line(text, "Status")
80
+ nxt = next_step or _handoff_line(text, "NEXT")
81
+
82
+ # **The first line is prose, and the skill comes second — deliberately.**
83
+ # Two reasons, both learned from using it. A message that OPENS with a slash
84
+ # command renders as a command invocation and takes over the session view, so
85
+ # the founder cannot see the conversation at all. And the opening line is what
86
+ # shows in session history: `Continue <task> — <title>` says which work this
87
+ # session is, where a bare command says only that a command ran.
88
+ #
89
+ # The cost is real and is paid on purpose: off line one it is no longer parsed
90
+ # as a slash command, so it must read as an INSTRUCTION the session acts on
91
+ # rather than as an invocation the client performs. That is what the line below
92
+ # is for, and why it says "before anything else" instead of just naming it.
93
+ # Do not move it back to the top.
94
+ lines = [f"Continue `{task.name}` — {task.title}.", ""]
95
+ # Silent when the repo names no command. The harness cannot derive one any more
96
+ # — it no longer lives inside the thing that owns slash commands — and a line
97
+ # telling a session to load something that resolves for nobody is worse than no
98
+ # line at all, in the one place whose whole job is loading the harness.
99
+ if SKILL:
100
+ lines.append(f"Load {SKILL} before anything else — it is the harness "
101
+ f"this repo runs on, and everything below is its board.")
102
+ lines.append("")
103
+
104
+ # Paths, not contents. The handoff is the carrier the wrap has just made
105
+ # current, and a prompt that inlined it would be a second copy going stale
106
+ # from the moment it was sent — while the session it is sent to can read the
107
+ # file itself, in the repo it is standing in.
108
+ if handoff.is_file():
109
+ lines.append(f" handoff {rel(handoff, root)} ← read this first; it holds "
110
+ f"what the last session learned")
111
+ else:
112
+ lines.append(" handoff NONE — this task has no handoff.md, so nothing "
113
+ "carried across but the brief")
114
+ lines.append(f" brief {rel(task.md, root)}")
115
+ epic = task.folder.parents[1] / "epic.md"
116
+ if epic.is_file():
117
+ lines.append(f" epic {rel(epic, root)} (§Plan — the calls the epic "
118
+ f"already settled)")
119
+ lines.append("")
120
+
121
+ if status:
122
+ lines.append(f"Status: {status}")
123
+ if nxt:
124
+ lines.append(f"Next: {nxt}")
125
+ if watch:
126
+ lines.append(f"Watch out for: {watch}")
127
+ if status or nxt or watch:
128
+ lines.append("")
129
+
130
+ open_qs = _open_questions(task)
131
+ if open_qs:
132
+ lines.append("This task has questions nobody has answered yet. They come "
133
+ "before the work:")
134
+ lines += [f" - {q}" for q in open_qs]
135
+ lines.append("")
136
+
137
+ lines.append("Before you write any code:")
138
+ lines.append(f" 1. Orient. Read the files above in that order, then `{cli()} list` "
139
+ "for the board around them.")
140
+ lines.append(" 2. Settle what is open. Answer it, or park it — "
141
+ f"`{cli()} ask {task.name} --question \"…\"` records a question and "
142
+ f"moves on; it never blocks.")
143
+ if GRAPH_MCP:
144
+ lines.append(f" 3. Map the blast radius through the `{GRAPH_MCP}` graph "
145
+ f"before proposing a non-trivial plan.")
146
+ else:
147
+ lines.append(" 3. Map the blast radius. No graph engine is configured here, "
148
+ "so the sweep is grep-wide — say so in the plan rather than "
149
+ "implying a map you did not get.")
150
+ held = (" and how it holds against " + " · ".join(SPINE)) if SPINE else ""
151
+ lines.append(" 4. State the plan back before writing anything: the shapes, "
152
+ "interfaces, types and signature changes it makes" + held + ". "
153
+ "Report it, then proceed.")
154
+ lines.append("")
155
+ # The founder's call, 2026-08-21, and it is the difference between this and a
156
+ # status-report prompt: the successor is picking the work UP. Left unsaid, a
157
+ # session handed a handoff tends to summarise it back and stop.
158
+ lines.append("Then do the work. You are picking this up, not reporting on it — "
159
+ "the plan is the pause, not the deliverable.")
160
+ return "\n".join(lines)
161
+
162
+
163
+ def session_name(task) -> str:
164
+ """What to call the session this prompt opens.
165
+
166
+ The picker, the prompt box and the terminal title all show it, and without
167
+ one Claude Code derives a name from the FOLDER — identical for every session
168
+ in a repo, so several runs are told apart only by an id nobody reads.
169
+
170
+ A run is numbered by how many have already worked the item, so the first is
171
+ just the title and the fifth says it is the fifth. The number leads because
172
+ that is the column a picker never truncates, and the count comes off
173
+ `sessions:` rather than being carried in the prompt: the wrap has just
174
+ recorded the run doing the wrapping, so the successor is the next one along
175
+ and nothing has to remember what number it is.
176
+ """
177
+ ordinal = len(task.sessions) + 1
178
+ return task.title if ordinal == 1 else f"{ordinal} · {task.title}"
179
+
180
+
181
+ def cmd_kickoff(args) -> int:
182
+ root = find_work_root()
183
+ task = locate(root, args["name"])
184
+ if not task:
185
+ die(f"no task named '{args['name']}' found")
186
+ prompt = build_prompt(task, root, args.get("next"), args.get("watch"))
187
+
188
+ # `--prompt-only` is the pipe. Everything else this command prints is
189
+ # instruction for the CALLER, and a caller that pipes wants none of it.
190
+ if args.get("prompt-only") == "true":
191
+ print(prompt)
192
+ return 0
193
+
194
+ repo = root.parent
195
+ print("KICKOFF PROMPT — pass verbatim as `prompt`, with nothing added:")
196
+ print(_RULE)
197
+ print(prompt)
198
+ print(_RULE)
199
+
200
+ if not SESSION_MCP:
201
+ # Not an error, and said as a mode rather than a lack. A repo with nothing
202
+ # that can start a session still gets everything above; it is carried
203
+ # across by a person, which is what the wrap always did.
204
+ print("no session starter configured (`session.mcp` is null) — hand the "
205
+ "block above to a person, or name the MCP server that can start a "
206
+ "session in this repo in .claude/work.config.json.")
207
+ return 0
208
+
209
+ if not SESSION_TOOL:
210
+ # Unreachable through a loaded config — `session.mcp` without
211
+ # `session.tool` is refused at load. Held anyway, because the line below
212
+ # composes a tool name a model is told to call, and one built around a
213
+ # missing half resolves to nothing while reading like a real instruction.
214
+ print(f"`session.mcp` names {SESSION_MCP!r} but `session.tool` is unset, so "
215
+ f"there is no tool to call — hand the block above to a person.")
216
+ return 0
217
+ tool = f"mcp__{SESSION_MCP}__{SESSION_TOOL}"
218
+ print(f"start it: {tool}")
219
+ print(f" prompt = the block above, verbatim")
220
+ print(f" cwd = {repo}")
221
+ print(f" name = {session_name(task)}")
222
+ # The one check that separates a session from a session id. A start that
223
+ # reports `outlivesThisConnection: false` ran in-process, as a child of the
224
+ # caller — so it dies with the very session that started it, while looking
225
+ # exactly like success: created, stamped, titled, and with zero turns ever
226
+ # taken. Measured on this machine 2026-08-21, not feared.
227
+ print(f"then: the answer must say `outlivesThisConnection: true`. FALSE "
228
+ f"means the session is a child of this one and dies with it — report "
229
+ f"that as a failure, not a start.")
230
+ print(f" record it: `{cli()} session {task.name} --id <sessionId>`")
231
+ return 0