@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.
- 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 +10104 -37174
- 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 -38718
- 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,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
|