@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,1482 @@
|
|
|
1
|
+
"""One canonical config — shipped defaults merged under the repo's committed
|
|
2
|
+
`.claude/work.config.json`.
|
|
3
|
+
|
|
4
|
+
**A committed file is the only path, not the preferred one.** Claude Code has
|
|
5
|
+
*deliberately* ignored `pluginConfigs` in a project's `.claude/settings.json` since
|
|
6
|
+
v2.1.207, because a cloned repo could otherwise inject values into hook commands
|
|
7
|
+
and MCP configs. That closes the option that would have been nicer and makes this
|
|
8
|
+
schema a security surface in its own right: a value here that reaches a shell
|
|
9
|
+
command is the same injection wearing different clothes.
|
|
10
|
+
|
|
11
|
+
So `verify.*` — the only values the harness ever suggests running — are validated
|
|
12
|
+
as plain command strings and are never interpolated into a shell by this module.
|
|
13
|
+
They are printed for a human or a session to run, never executed here.
|
|
14
|
+
|
|
15
|
+
`apply()` is the seam that makes the config real. Everything downstream reads a
|
|
16
|
+
module attribute rather than re-reading the file, and `apply()` is what sets those
|
|
17
|
+
attributes — once, before anything parses.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .tree import PAYLOAD_DIR, cli
|
|
25
|
+
|
|
26
|
+
#: What a repo gets with no config file at all. A fresh install must WORK, so every
|
|
27
|
+
#: default is a usable answer rather than a placeholder: no graph engine, the
|
|
28
|
+
#: shipped ledger prefix, the nine domains in reading order.
|
|
29
|
+
DEFAULTS = {
|
|
30
|
+
"ids": {
|
|
31
|
+
# The prefix this repo's durable rules carry — `D`, `G`, `ADR`, whatever.
|
|
32
|
+
"prefix": "D",
|
|
33
|
+
# Extra prefixes recognised on READ but never generated. A migration window
|
|
34
|
+
# stays readable without the lint going silent.
|
|
35
|
+
"recognised": [],
|
|
36
|
+
# Sibling repos whose ledger ids may legally appear here. Empty by default:
|
|
37
|
+
# a fresh repo has no siblings, and shipping somebody else's repo names as
|
|
38
|
+
# a default would silently excuse a real dangling citation in every
|
|
39
|
+
# consumer.
|
|
40
|
+
"foreign": [],
|
|
41
|
+
# Read `D12` as well as `D-12`. Off by default: a short prefix collides
|
|
42
|
+
# with ordinary data — `priority: P0` was read as a citation to `P-0`.
|
|
43
|
+
"undashed": False,
|
|
44
|
+
},
|
|
45
|
+
"domains": {
|
|
46
|
+
# Reading order of the org-domain map, not alphabetical: what it does → how
|
|
47
|
+
# it's built → how it looks → how we know it's good → what keeps it safe →
|
|
48
|
+
# how we run it → how users get unblocked → how we reach and earn → what
|
|
49
|
+
# binds us. A domain not on the list still scans; it just sorts last.
|
|
50
|
+
"order": ["product", "architecture", "design", "quality", "security",
|
|
51
|
+
"operations", "support", "commercial", "legal"],
|
|
52
|
+
},
|
|
53
|
+
"spine": {
|
|
54
|
+
# The conventions a plan must fit, read before planning. Repo-relative.
|
|
55
|
+
"standards": None,
|
|
56
|
+
"conventions": [],
|
|
57
|
+
},
|
|
58
|
+
"plans": {
|
|
59
|
+
# Where an approved plan file lives, if this repo keeps them. `jarvis work plan`
|
|
60
|
+
# records a POINTER into it, never the body.
|
|
61
|
+
#
|
|
62
|
+
# Null by default, and null is a full mode rather than a missing setting:
|
|
63
|
+
# one consumer keeps `work/plans/` as an archival snapshot, another deleted
|
|
64
|
+
# the directory outright on the grounds that a design reachable from a side
|
|
65
|
+
# directory is a second home for it. Neither is more general than the other,
|
|
66
|
+
# so the harness ships neither — null means `plan` requires an explicit
|
|
67
|
+
# `--file`, and a repo that wants the default names its own directory.
|
|
68
|
+
# (Founder call, 2026-08-02.)
|
|
69
|
+
"dir": None,
|
|
70
|
+
},
|
|
71
|
+
"product": {
|
|
72
|
+
# `flat` — a feature is `product/<feature>.md` and its bare name is its whole
|
|
73
|
+
# address. The only shape the harness implements today; the key exists so a
|
|
74
|
+
# repo that grows an app tier has somewhere to say so rather than forking.
|
|
75
|
+
"tiers": "flat",
|
|
76
|
+
},
|
|
77
|
+
# How this repo's guidance is composed. The harness ships an ENGINE (the tree,
|
|
78
|
+
# the ids, the buckets, the delivery gate — what the harness actually enforces)
|
|
79
|
+
# and, separately, a PRESET: one organisation's methodology written on top of it.
|
|
80
|
+
#
|
|
81
|
+
# The split exists because they are different KINDS of fact. Contradict the
|
|
82
|
+
# engine and the tool refuses you; contradict the preset and you have simply
|
|
83
|
+
# chosen to work differently, which is not an error. Shipping them fused made
|
|
84
|
+
# every consumer inherit one org's opinions as though they were mechanics —
|
|
85
|
+
# the same defect as the tag vocabulary above, one layer up.
|
|
86
|
+
#
|
|
87
|
+
# This is the shareable-config pattern (eslint's engine vs `eslint-config-*`):
|
|
88
|
+
# the engine never enumerates its consumers, and whoever holds an opinion
|
|
89
|
+
# publishes it themselves.
|
|
90
|
+
"instructions": {
|
|
91
|
+
# A shipped preset by name, a repo-relative path to your own
|
|
92
|
+
# (`.claude/presets/ours.md`), or None for the bare engine.
|
|
93
|
+
#
|
|
94
|
+
# NONE BY DEFAULT. It shipped naming this author's own preset, which meant
|
|
95
|
+
# a repo that installed the harness and said nothing inherited one
|
|
96
|
+
# organisation's methodology as though it came with the mechanics. Naming
|
|
97
|
+
# it out loud in every session's first line made that honest but did not
|
|
98
|
+
# make it chosen. A preset is somebody's opinion, and whose opinion runs in
|
|
99
|
+
# a repo is that repo's call — so the engine ships and the methodology is
|
|
100
|
+
# one key away. (Founder call, 2026-08-21, applying the same rule as
|
|
101
|
+
# `session.tool` and `hygiene.*`: where nothing is defined and there is no
|
|
102
|
+
# honest default, do nothing rather than guess.)
|
|
103
|
+
"preset": None,
|
|
104
|
+
# Repo-relative files layered ON TOP, in order. Later wins on conflict:
|
|
105
|
+
# engine < preset < extend[0] < extend[1] … Layering rather than named
|
|
106
|
+
# section overrides, so the harness owes no stable slug API to anybody —
|
|
107
|
+
# rename a section here and no consumer breaks.
|
|
108
|
+
"extend": [],
|
|
109
|
+
# What a session runs to LOAD this repo's method — the line every kickoff
|
|
110
|
+
# prompt opens with, because a handed-over prompt that does not load the
|
|
111
|
+
# harness gets a session working without it.
|
|
112
|
+
#
|
|
113
|
+
# NONE BY DEFAULT, and the default is the whole reason this is config. It
|
|
114
|
+
# used to be derived from the payload's own location: the harness lived
|
|
115
|
+
# inside a Claude Code plugin, so it could read that plugin's manifest and
|
|
116
|
+
# answer `/<plugin>:<skill>`. The payload ships inside the CLI now and can
|
|
117
|
+
# see no manifest, and a guess here is worse than silence — a prompt naming
|
|
118
|
+
# a command that resolves for nobody is the one line whose entire job is
|
|
119
|
+
# loading the harness. A repo that has a method names how to load it;
|
|
120
|
+
# every other consumer gets a prompt with no such line.
|
|
121
|
+
"skill": None,
|
|
122
|
+
},
|
|
123
|
+
# Area tags a task's `tags:` field may carry, for grouping the board by
|
|
124
|
+
# capability or market. EMPTY BY DEFAULT, and empty means "no vocabulary
|
|
125
|
+
# configured" — any tag is accepted.
|
|
126
|
+
#
|
|
127
|
+
# It is config rather than a shipped tuple because an area vocabulary is the
|
|
128
|
+
# most repo-specific thing a task carries: one consumer groups by engine and
|
|
129
|
+
# game, another by service. The tuple that used to live in `tree.py` shipped
|
|
130
|
+
# one consumer's product names to every other repo AND refused their own, which
|
|
131
|
+
# is a default doing real damage rather than merely being wrong.
|
|
132
|
+
#
|
|
133
|
+
# A repo that wants typos caught names its list; then the vocabulary is closed
|
|
134
|
+
# and an unknown tag is refused against it. Compare `code:`, whose vocabulary is
|
|
135
|
+
# DERIVED from the systems in `work/architecture/*.md` — a fact that can be
|
|
136
|
+
# derived is never written, and this one cannot be.
|
|
137
|
+
"tags": {
|
|
138
|
+
"allowed": [],
|
|
139
|
+
},
|
|
140
|
+
"research": {
|
|
141
|
+
# The subagent that runs external research for this repo — a Codex agent, a
|
|
142
|
+
# Claude one, or anything else the session can address. None means none is
|
|
143
|
+
# configured.
|
|
144
|
+
#
|
|
145
|
+
# It lives HERE rather than in the skill because which model researches is
|
|
146
|
+
# a repo's own preference, and a vendor named in shared guidance ships that
|
|
147
|
+
# preference to every repo that loads the plugin — the same reason `verify`
|
|
148
|
+
# names no test command. What does NOT vary is the procedure and its output
|
|
149
|
+
# contract: verbatim `sources/`, raw findings, a take filed under the
|
|
150
|
+
# researcher's own name, and Claude's synthesis on top.
|
|
151
|
+
#
|
|
152
|
+
# A Claude agent is a legal value, with one trade stated openly in the
|
|
153
|
+
# procedure: it is not an independent read, so its take and Claude's
|
|
154
|
+
# synthesis agree with each other for free.
|
|
155
|
+
#
|
|
156
|
+
# None is fully supported. The session picks a researcher, follows the same
|
|
157
|
+
# contract, and SAYS which one it used, so no finding is ever traceable to
|
|
158
|
+
# an unnamed reader.
|
|
159
|
+
"agent": None,
|
|
160
|
+
},
|
|
161
|
+
# Commands run to verify work — by `jarvis work verify`, and by the completion gate
|
|
162
|
+
# that will not let a task finish without a passing record. Free-form because
|
|
163
|
+
# toolchains differ.
|
|
164
|
+
#
|
|
165
|
+
# These now reach EXECUTION, which is why `_validate` below is stricter than it
|
|
166
|
+
# looks: the value is split with `shlex` and run with `shell=False`, never
|
|
167
|
+
# interpolated. A repo may run anything it likes; what it may not do is get a
|
|
168
|
+
# `;` to mean `;`.
|
|
169
|
+
"verify": {},
|
|
170
|
+
"autonomy": {
|
|
171
|
+
# The highest `tier:` an unattended run acts on ALONE. Default 2 — a
|
|
172
|
+
# scheduled shift does ordinary work and stops at the irreversible.
|
|
173
|
+
#
|
|
174
|
+
# 3 means "decide everything, including money, secrets, personal data and
|
|
175
|
+
# the law". That is a repo's call to make explicitly, and never a shipped
|
|
176
|
+
# default: a harness that ships with no ceiling is a harness whose safety
|
|
177
|
+
# depends on nobody having configured it.
|
|
178
|
+
"ceiling": 2,
|
|
179
|
+
# How long an instance's claim on a task is good for. Long enough that real
|
|
180
|
+
# work finishes inside it; short enough that a crashed shift's task is
|
|
181
|
+
# takeable the same day.
|
|
182
|
+
"lease_minutes": 240,
|
|
183
|
+
},
|
|
184
|
+
"graph": {
|
|
185
|
+
# The MCP server serving this repo's governance↔code graph, or null. Null is
|
|
186
|
+
# a fully supported mode: the planning loop degrades to grep-wide and SAYS
|
|
187
|
+
# SO. It never implies a complete map it did not get.
|
|
188
|
+
"mcp": None,
|
|
189
|
+
},
|
|
190
|
+
# Starting the NEXT session — the wrap's last act, where a repo has something
|
|
191
|
+
# that can start one.
|
|
192
|
+
#
|
|
193
|
+
# A SEPARATE key from `graph.mcp` even where the same server answers both, and
|
|
194
|
+
# that is the point: serving a graph and starting a session are different
|
|
195
|
+
# capabilities, and a repo can have either without the other. Riding on the
|
|
196
|
+
# graph's key would mean a repo that installed a map silently gained the power
|
|
197
|
+
# to spawn sessions.
|
|
198
|
+
"session": {
|
|
199
|
+
# The MCP server whose `start` tool opens a session in this repo, or null.
|
|
200
|
+
# Null is the default and a supported mode: the wrap prints the kickoff
|
|
201
|
+
# prompt for a person to carry across, which is what it always did.
|
|
202
|
+
"mcp": None,
|
|
203
|
+
# The tool on that server. NO DEFAULT, and that is the point: the harness
|
|
204
|
+
# ships no knowledge of any particular server, and a default here would be
|
|
205
|
+
# one vendor's tool name compiled into a repo-agnostic payload — shipped
|
|
206
|
+
# to every consumer as though it were a fact about how sessions start.
|
|
207
|
+
# Naming a server therefore obliges you to name its tool; the alternative
|
|
208
|
+
# is the harness guessing, and a wrong guess is a tool call that resolves
|
|
209
|
+
# to nothing in the last minute of somebody's session.
|
|
210
|
+
"tool": None,
|
|
211
|
+
},
|
|
212
|
+
# The hook points this plugin OWNS, and what a repo may do to each.
|
|
213
|
+
#
|
|
214
|
+
# The declaration in `hooks/hooks.json` is the plugin's and a consumer cannot
|
|
215
|
+
# change it — nor should it have to. What a consumer needs is to change what is
|
|
216
|
+
# SAID at those points, which is this: name commands, the harness runs them,
|
|
217
|
+
# and their lines are folded into its own message.
|
|
218
|
+
#
|
|
219
|
+
# Extend, replace and off are ONE mechanic. `enabled: false` silences the
|
|
220
|
+
# harness's own lines and `extend` adds the repo's, so together they replace,
|
|
221
|
+
# `extend` alone adds, and `enabled: false` alone switches the point off. A
|
|
222
|
+
# third `replace:` key would be another spelling of the same two facts.
|
|
223
|
+
#
|
|
224
|
+
# Keyed by Claude Code's OWN event names rather than by what the harness happens
|
|
225
|
+
# to do at each one. An installer wiring "the SessionStart hook" then writes the
|
|
226
|
+
# key it already has a name for, and renaming the harness's behaviour later
|
|
227
|
+
# breaks nobody's config.
|
|
228
|
+
"hooks": {
|
|
229
|
+
# The standing obligations a fresh session is given.
|
|
230
|
+
"session_start": {"enabled": True, "extend": []},
|
|
231
|
+
# The end-of-turn wrap reminder. Note this is the HOOK; `wrap.at_percent`
|
|
232
|
+
# is when it fires, and the two are separate because a repo can want the
|
|
233
|
+
# threshold and not the harness's wording, or the reverse.
|
|
234
|
+
"stop": {"enabled": True, "extend": []},
|
|
235
|
+
# What arrives when a session is about to write a file: the judgements no
|
|
236
|
+
# gate can catch, and which part of the code it has just walked into. A rule
|
|
237
|
+
# read at the top of a session is a rule forgotten by the time it bites.
|
|
238
|
+
"pre_tool_use": {"enabled": True, "extend": []},
|
|
239
|
+
},
|
|
240
|
+
# Whether a board write lands in git, and what it carries when it does.
|
|
241
|
+
#
|
|
242
|
+
# OFF BY DEFAULT, for the same reason the wrap hook is: this harness is shared,
|
|
243
|
+
# and a consumer that never asked for it must not suddenly start committing on
|
|
244
|
+
# its own. A repo that wants the guarantee — that a work item cannot exist
|
|
245
|
+
# outside git, which is a failure that has already cost real work — says so
|
|
246
|
+
# here, and everything below switches on together.
|
|
247
|
+
"git": {
|
|
248
|
+
# Commit every board write, the moment it happens. No debounce and no
|
|
249
|
+
# window: the window is the bug. With this on, the commit trailers ARE the
|
|
250
|
+
# activity record and no `.events.jsonl` is written.
|
|
251
|
+
"commit": False,
|
|
252
|
+
# …and pull before, push after. Needs `commit`, because a push with nothing
|
|
253
|
+
# committed has nothing to send. Off separately so a repo with no shared
|
|
254
|
+
# origin still gets the half that makes a write unloseable.
|
|
255
|
+
"push": False,
|
|
256
|
+
"remote": "origin",
|
|
257
|
+
# What a board write commits. Everything under `work/` by default; a repo
|
|
258
|
+
# that keeps its tree elsewhere, or wants a narrower slice, names its own.
|
|
259
|
+
# Paths are repo-relative and reach a subprocess argv, so `_validate`
|
|
260
|
+
# refuses anything that could point outside the repo.
|
|
261
|
+
"paths": ["work"],
|
|
262
|
+
},
|
|
263
|
+
"coverage": {
|
|
264
|
+
# Where runners drop their shards. Gitignored on purpose — evidence is a
|
|
265
|
+
# fresh run, not a file somebody checked in.
|
|
266
|
+
"shard": ".work/coverage",
|
|
267
|
+
},
|
|
268
|
+
# The passes a session runs at a checkpoint and before completing — a bug pass
|
|
269
|
+
# and a quality pass, named by the repo.
|
|
270
|
+
#
|
|
271
|
+
# BOTH NULL, and null means the step does not happen. The shipped defaults used
|
|
272
|
+
# to name two commands, which was wrong twice over: nothing in the harness read
|
|
273
|
+
# the keys, so a repo that set its own was silently ignored, while the prose
|
|
274
|
+
# named those two commands to every consumer regardless of what it had. A
|
|
275
|
+
# default that no code reads is not a default, it is a claim — and a harness
|
|
276
|
+
# that tells a session to run a command the repo never named is guessing about
|
|
277
|
+
# somebody else's toolchain, which is the same mistake `verify.*` exists to
|
|
278
|
+
# avoid.
|
|
279
|
+
"hygiene": {
|
|
280
|
+
"review": None,
|
|
281
|
+
"simplify": None,
|
|
282
|
+
},
|
|
283
|
+
# When to finish a session cleanly, before the context window runs out.
|
|
284
|
+
#
|
|
285
|
+
# OFF by default, and that is deliberate rather than timid. The `Stop` hook that
|
|
286
|
+
# implements this speaks up at the end of a turn, and a hook that interrupts
|
|
287
|
+
# somebody's session is theirs to switch on — the same reasoning that keeps
|
|
288
|
+
# `hooks/guard.py` inert and unregistered. A repo that wants it names a
|
|
289
|
+
# percentage; every other consumer sees no change at all.
|
|
290
|
+
"wrap": {
|
|
291
|
+
# Share of the context window past which the session is reminded to wrap up:
|
|
292
|
+
# write the handoff, bring the task and epic docs current, move statuses, and
|
|
293
|
+
# leave the next session a kickoff prompt. Null is off.
|
|
294
|
+
"at_percent": None,
|
|
295
|
+
# The window that percentage is measured against. NO DEFAULT: a hook cannot
|
|
296
|
+
# ask Claude Code how large the context is, so any number here is a guess
|
|
297
|
+
# about somebody else's session — and the guess is what breaks it. 200000
|
|
298
|
+
# nagged a long-context consumer from its first turn; 1000000, which
|
|
299
|
+
# replaced it, would let a standard 200k session reach 100% having been
|
|
300
|
+
# told it was at 20%. Both are silent, and both make the reminder useless
|
|
301
|
+
# in exactly the repos that asked for it. So `at_percent` requires this,
|
|
302
|
+
# and a repo states the window it actually runs. Usage itself is never
|
|
303
|
+
# estimated — it is read from the API's own `usage` record.
|
|
304
|
+
"context_tokens": None,
|
|
305
|
+
# What to run to wrap up, named in the reminder. Null and the reminder says
|
|
306
|
+
# to wrap without naming a command — same reasoning as `instructions.skill`:
|
|
307
|
+
# the harness no longer sits inside the thing that owns slash commands, so
|
|
308
|
+
# it cannot derive one, and naming a command a repo does not have sends the
|
|
309
|
+
# reader somewhere that does not exist.
|
|
310
|
+
"command": None,
|
|
311
|
+
},
|
|
312
|
+
# Extra generated/vendored directories to skip, beyond the built-in rule (any
|
|
313
|
+
# dot-directory except `.claude`, plus the standard build dirs).
|
|
314
|
+
"skip_dirs": [],
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
CONFIG_PATH = Path(".claude") / "work.config.json"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def payload_root() -> Path:
|
|
321
|
+
"""The shipped payload — this file's own directory tree, and nothing else.
|
|
322
|
+
|
|
323
|
+
It used to consult `CLAUDE_PLUGIN_ROOT` first, because the payload was a Claude
|
|
324
|
+
Code plugin and an installed one lives in a version-hash directory. It ships
|
|
325
|
+
inside the CLI now, so the modules and the assets travel together and the
|
|
326
|
+
location is a fact about this file rather than about how it was reached.
|
|
327
|
+
"""
|
|
328
|
+
return PAYLOAD_DIR
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def preset_dir() -> Path:
|
|
332
|
+
"""Where shipped presets live."""
|
|
333
|
+
return payload_root() / "presets"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def resolve_instructions(cfg: dict, repo: Path) -> dict:
|
|
337
|
+
"""What this repo's guidance actually is, resolved to files on disk.
|
|
338
|
+
|
|
339
|
+
Returns `{"preset": (label, path|None), "extend": [(label, path|None), …]}` —
|
|
340
|
+
a path of None means the file is MISSING, reported rather than skipped. A
|
|
341
|
+
preset that silently fails to load is the worst outcome available: the session
|
|
342
|
+
proceeds on the bare engine, behaves unlike every other session in the repo,
|
|
343
|
+
and nothing anywhere says why.
|
|
344
|
+
"""
|
|
345
|
+
out = {"preset": None, "extend": []}
|
|
346
|
+
name = cfg["instructions"]["preset"]
|
|
347
|
+
if name:
|
|
348
|
+
if "/" in name or name.endswith(".md"):
|
|
349
|
+
# A repo-relative path — the repo's own preset, versioned with the repo.
|
|
350
|
+
out["preset"] = (name, _existing(Path(repo) / name))
|
|
351
|
+
else:
|
|
352
|
+
# A shipped preset, by name.
|
|
353
|
+
out["preset"] = (name, _existing(preset_dir() / name / "PRESET.md"))
|
|
354
|
+
for rel in cfg["instructions"]["extend"]:
|
|
355
|
+
out["extend"].append((rel, _existing(Path(repo) / rel)))
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _existing(p: Path):
|
|
360
|
+
return p if p.is_file() else None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class ConfigError(Exception):
|
|
364
|
+
"""A config that cannot be trusted. Raised rather than warned: a silently
|
|
365
|
+
half-applied config is worse than none, because every downstream check then
|
|
366
|
+
reports against a model the repo did not choose."""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
#: Config sections whose KEYS are the repo's to invent — a command name under
|
|
370
|
+
#: `verify` is data, not schema. Everything else is closed, so a typo raises
|
|
371
|
+
#: instead of silently doing nothing.
|
|
372
|
+
_OPEN_MAPS = {"verify"}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _merge(base: dict, over: dict, path: str = "") -> dict:
|
|
376
|
+
"""Two levels deep, which is the whole schema. A dict merges key-by-key; any
|
|
377
|
+
other value REPLACES. A list is a value — a repo setting `domains.order` means
|
|
378
|
+
that order, not that order appended to the default."""
|
|
379
|
+
if path in _OPEN_MAPS:
|
|
380
|
+
return dict(over)
|
|
381
|
+
out = dict(base)
|
|
382
|
+
for k, v in over.items():
|
|
383
|
+
where = f"{path}.{k}" if path else k
|
|
384
|
+
if k not in base:
|
|
385
|
+
raise ConfigError(f"unknown config key '{where}'")
|
|
386
|
+
if isinstance(base[k], dict) and isinstance(v, dict):
|
|
387
|
+
out[k] = _merge(base[k], v, where)
|
|
388
|
+
elif isinstance(base[k], dict) != isinstance(v, dict):
|
|
389
|
+
raise ConfigError(f"config key '{where}' expects "
|
|
390
|
+
f"{'an object' if isinstance(base[k], dict) else 'a value'}")
|
|
391
|
+
else:
|
|
392
|
+
out[k] = v
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _validate(cfg: dict) -> None:
|
|
397
|
+
ids = cfg["ids"]
|
|
398
|
+
if not isinstance(ids["prefix"], str) or not ids["prefix"].strip():
|
|
399
|
+
raise ConfigError("ids.prefix must be a non-empty string, e.g. \"D\"")
|
|
400
|
+
if not ids["prefix"].strip("-").isalnum():
|
|
401
|
+
raise ConfigError(f"ids.prefix {ids['prefix']!r} must be alphanumeric — it is "
|
|
402
|
+
f"compiled into a regex, so punctuation would change what "
|
|
403
|
+
f"counts as an id")
|
|
404
|
+
for key in ("recognised", "foreign"):
|
|
405
|
+
if not isinstance(ids[key], list) or any(not isinstance(x, str) for x in ids[key]):
|
|
406
|
+
raise ConfigError(f"ids.{key} must be a list of strings")
|
|
407
|
+
if not isinstance(cfg["domains"]["order"], list):
|
|
408
|
+
raise ConfigError("domains.order must be a list of domain names")
|
|
409
|
+
# Checked because the failure it prevents is INVISIBLE. `session_pointers` joins
|
|
410
|
+
# these into one line, so a list where a string belongs raises inside the
|
|
411
|
+
# SessionStart hook — and that hook exits 0 on every failure path by design, so
|
|
412
|
+
# the repo simply stops receiving its standing obligations and nothing says so.
|
|
413
|
+
# One consumer ran that way with `standards: ["STANDARDS.md"]` while `doctor`
|
|
414
|
+
# reported the config valid.
|
|
415
|
+
standards = cfg["spine"]["standards"]
|
|
416
|
+
if standards is not None and not isinstance(standards, str):
|
|
417
|
+
raise ConfigError(f"spine.standards must be a single path or null, not "
|
|
418
|
+
f"{type(standards).__name__} — put additional documents in "
|
|
419
|
+
f"spine.conventions, which is the list")
|
|
420
|
+
conventions = cfg["spine"]["conventions"]
|
|
421
|
+
if not isinstance(conventions, list) or any(not isinstance(c, str) for c in conventions):
|
|
422
|
+
raise ConfigError("spine.conventions must be a list of repo-relative paths")
|
|
423
|
+
plans_dir = cfg["plans"]["dir"]
|
|
424
|
+
if plans_dir is not None and (not isinstance(plans_dir, str) or not plans_dir.strip()):
|
|
425
|
+
raise ConfigError("plans.dir must be a repo-relative directory or null")
|
|
426
|
+
if cfg["product"]["tiers"] != "flat":
|
|
427
|
+
raise ConfigError(f"product.tiers {cfg['product']['tiers']!r} is not supported — "
|
|
428
|
+
f"'flat' is the only shape the harness implements")
|
|
429
|
+
inst = cfg["instructions"]
|
|
430
|
+
preset = inst["preset"]
|
|
431
|
+
if preset is not None and (not isinstance(preset, str) or not preset.strip()):
|
|
432
|
+
raise ConfigError("instructions.preset must be a shipped preset name, a "
|
|
433
|
+
"repo-relative path to one, or null for the bare engine")
|
|
434
|
+
if not isinstance(inst["extend"], list) or any(
|
|
435
|
+
not isinstance(x, str) or not x.strip() for x in inst["extend"]):
|
|
436
|
+
raise ConfigError("instructions.extend must be a list of repo-relative "
|
|
437
|
+
"paths, layered in order (later wins)")
|
|
438
|
+
skill = inst["skill"]
|
|
439
|
+
if skill is not None and (not isinstance(skill, str) or not skill.strip()):
|
|
440
|
+
raise ConfigError("instructions.skill must be the command that loads this "
|
|
441
|
+
"repo's method into a session, or null for a kickoff "
|
|
442
|
+
"prompt that names none")
|
|
443
|
+
allowed = cfg["tags"]["allowed"]
|
|
444
|
+
if not isinstance(allowed, list) or any(not isinstance(t, str) or not t.strip()
|
|
445
|
+
for t in allowed):
|
|
446
|
+
raise ConfigError("tags.allowed must be a list of non-empty tag names, or [] "
|
|
447
|
+
"to accept any tag")
|
|
448
|
+
agent = cfg["research"]["agent"]
|
|
449
|
+
if agent is not None and (not isinstance(agent, str) or not agent.strip()):
|
|
450
|
+
raise ConfigError("research.agent must be a subagent name or null")
|
|
451
|
+
for name, cmd in cfg["verify"].items():
|
|
452
|
+
if not isinstance(cmd, str):
|
|
453
|
+
raise ConfigError(f"verify.{name} must be a command string")
|
|
454
|
+
# These are EXECUTED now, so a value that only makes sense to a shell is a
|
|
455
|
+
# value that will not do what its author expects. Refusing at load is the
|
|
456
|
+
# honest moment to say so — the alternative is a gate that reports PASS
|
|
457
|
+
# because `&&` was passed to the first binary as an argument.
|
|
458
|
+
for meta in ("&&", "||", ";", "|", ">", "<", "$(", "`"):
|
|
459
|
+
if meta in cmd:
|
|
460
|
+
raise ConfigError(
|
|
461
|
+
f"verify.{name} contains {meta!r} — verify commands are run "
|
|
462
|
+
f"without a shell (shell=False), so shell operators would be "
|
|
463
|
+
f"passed as literal arguments. Put the composition in a script "
|
|
464
|
+
f"and call that.")
|
|
465
|
+
ceiling = cfg["autonomy"]["ceiling"]
|
|
466
|
+
if not isinstance(ceiling, int) or isinstance(ceiling, bool) or not 0 <= ceiling <= 3:
|
|
467
|
+
raise ConfigError("autonomy.ceiling must be an integer 0–3 (the highest "
|
|
468
|
+
"task tier an unattended run may act on alone)")
|
|
469
|
+
lease = cfg["autonomy"]["lease_minutes"]
|
|
470
|
+
if not isinstance(lease, int) or isinstance(lease, bool) or lease < 1:
|
|
471
|
+
raise ConfigError("autonomy.lease_minutes must be a positive integer")
|
|
472
|
+
mcp = cfg["graph"]["mcp"]
|
|
473
|
+
if mcp is not None and not isinstance(mcp, str):
|
|
474
|
+
raise ConfigError("graph.mcp must be a server name or null")
|
|
475
|
+
starter = cfg["session"]["mcp"]
|
|
476
|
+
if starter is not None and (not isinstance(starter, str) or not starter.strip()):
|
|
477
|
+
raise ConfigError("session.mcp must be the name of an MCP server that can "
|
|
478
|
+
"start a session in this repo, or null")
|
|
479
|
+
tool = cfg["session"]["tool"]
|
|
480
|
+
if starter and not tool:
|
|
481
|
+
raise ConfigError(
|
|
482
|
+
f"session.mcp names {starter!r} but session.tool is unset — naming a "
|
|
483
|
+
f"server does not name its tool, and the harness ships no default for "
|
|
484
|
+
f"it. Set both together: jarvis work config set session "
|
|
485
|
+
f"'{{\"mcp\": \"{starter}\", \"tool\": \"<the tool>\"}}' --json")
|
|
486
|
+
if tool is not None and (not isinstance(tool, str) or not tool.strip()):
|
|
487
|
+
raise ConfigError("session.tool must be the name of the tool that starts a "
|
|
488
|
+
"session on session.mcp, or null — an empty string is "
|
|
489
|
+
"neither a name nor an absence")
|
|
490
|
+
# Both halves reach the model as one `mcp__<server>__<tool>` name it is told to
|
|
491
|
+
# call. A server or tool name is an identifier, so anything that is not one is
|
|
492
|
+
# refused HERE — where it is one message — rather than becoming a tool call
|
|
493
|
+
# that fails in the last minute of somebody's session.
|
|
494
|
+
for key, value in (("mcp", starter), ("tool", tool)):
|
|
495
|
+
if value is not None and not re.fullmatch(r"[A-Za-z0-9_-]+", str(value)):
|
|
496
|
+
raise ConfigError(f"session.{key} must be a plain name "
|
|
497
|
+
f"([A-Za-z0-9_-]), got {value!r} — it is composed "
|
|
498
|
+
f"into the tool name `mcp__<server>__<tool>`")
|
|
499
|
+
for point, spec in cfg["hooks"].items():
|
|
500
|
+
if not isinstance(spec, dict) or set(spec) - {"enabled", "extend"}:
|
|
501
|
+
raise ConfigError(f"hooks.{point} takes `enabled` and `extend` only")
|
|
502
|
+
if not isinstance(spec.get("enabled", True), bool):
|
|
503
|
+
raise ConfigError(f"hooks.{point}.enabled must be true or false")
|
|
504
|
+
cmds = spec.get("extend", [])
|
|
505
|
+
if not isinstance(cmds, list):
|
|
506
|
+
raise ConfigError(f"hooks.{point}.extend must be a list of commands the "
|
|
507
|
+
f"harness runs and folds into this hook's output")
|
|
508
|
+
for cmd in cmds:
|
|
509
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
510
|
+
raise ConfigError(f"hooks.{point}.extend entries must be command "
|
|
511
|
+
f"strings")
|
|
512
|
+
# Executed, and from a hook — so the same rule `verify.*` lives under,
|
|
513
|
+
# for the same reason. A repo may run anything; what it may not do is
|
|
514
|
+
# get a `;` to mean `;`.
|
|
515
|
+
for meta in ("&&", "||", ";", "|", ">", "<", "$(", "`"):
|
|
516
|
+
if meta in cmd:
|
|
517
|
+
raise ConfigError(
|
|
518
|
+
f"hooks.{point}.extend entry contains {meta!r} — extensions "
|
|
519
|
+
f"are run without a shell (shell=False), so shell operators "
|
|
520
|
+
f"would be passed as literal arguments. Put the composition "
|
|
521
|
+
f"in a script and name that.")
|
|
522
|
+
skip = cfg["skip_dirs"]
|
|
523
|
+
# A STRING here is the whole bug: `set("node_modules")` is a set of eleven
|
|
524
|
+
# letters, so every scan then skips any directory called `n`, `o`, `d`… The
|
|
525
|
+
# config loads clean, nothing warns, and `align`, `doctor` and the registry
|
|
526
|
+
# quietly stop seeing most of the tree.
|
|
527
|
+
if not isinstance(skip, list) or any(
|
|
528
|
+
not isinstance(d, str) or not d.strip() or "/" in d for d in skip):
|
|
529
|
+
raise ConfigError("skip_dirs must be a list of directory NAMES, e.g. "
|
|
530
|
+
'["node_modules", "dist"] — not a string, and not a path')
|
|
531
|
+
|
|
532
|
+
g = cfg["git"]
|
|
533
|
+
for key in ("commit", "push"):
|
|
534
|
+
if not isinstance(g[key], bool):
|
|
535
|
+
raise ConfigError(f"git.{key} must be true or false")
|
|
536
|
+
if g["push"] and not g["commit"]:
|
|
537
|
+
raise ConfigError("git.push needs git.commit — a push with nothing committed "
|
|
538
|
+
"has nothing to send")
|
|
539
|
+
if not isinstance(g["remote"], str) or not g["remote"].strip():
|
|
540
|
+
raise ConfigError("git.remote must be the name of a remote, e.g. \"origin\"")
|
|
541
|
+
paths = g["paths"]
|
|
542
|
+
if not isinstance(paths, list) or not paths or any(
|
|
543
|
+
not isinstance(p, str) or not p.strip() for p in paths):
|
|
544
|
+
raise ConfigError("git.paths must be a non-empty list of repo-relative paths "
|
|
545
|
+
"whose changes a board write commits")
|
|
546
|
+
for p in paths:
|
|
547
|
+
# These become pathspecs on a `git commit`, so a path that escapes the repo
|
|
548
|
+
# would commit files nobody asked about. Refused at load, where it is one
|
|
549
|
+
# message, rather than at the commit, where it is a surprise in somebody's
|
|
550
|
+
# history.
|
|
551
|
+
if Path(p).is_absolute() or ".." in Path(p).parts:
|
|
552
|
+
raise ConfigError(f"git.paths entry {p!r} must be repo-relative and stay "
|
|
553
|
+
f"inside the repo")
|
|
554
|
+
at = cfg["wrap"]["at_percent"]
|
|
555
|
+
# 100 is refused along with 0: a reminder that arrives once the window is
|
|
556
|
+
# already full has nowhere to write the handoff it is asking for.
|
|
557
|
+
if at is not None and (not isinstance(at, int) or isinstance(at, bool)
|
|
558
|
+
or not 1 <= at <= 99):
|
|
559
|
+
raise ConfigError("wrap.at_percent must be an integer 1–99 (the share of the "
|
|
560
|
+
"context window past which a session is reminded to wrap "
|
|
561
|
+
"up), or null to switch the reminder off")
|
|
562
|
+
window = cfg["wrap"]["context_tokens"]
|
|
563
|
+
if at and window is None:
|
|
564
|
+
raise ConfigError("wrap.at_percent is set but wrap.context_tokens is not — "
|
|
565
|
+
"a percentage needs the window it is a percentage OF, and "
|
|
566
|
+
"the harness cannot ask Claude Code how large yours is. "
|
|
567
|
+
"State the window you actually run: 200000 for a standard "
|
|
568
|
+
"session, 1000000 for a long-context one.")
|
|
569
|
+
if window is not None and (not isinstance(window, int) or isinstance(window, bool)
|
|
570
|
+
or window < 1):
|
|
571
|
+
raise ConfigError("wrap.context_tokens must be a positive integer — the "
|
|
572
|
+
"window wrap.at_percent is measured against")
|
|
573
|
+
for key in ("review", "simplify"):
|
|
574
|
+
pass_cmd = cfg["hygiene"][key]
|
|
575
|
+
if pass_cmd is not None and (not isinstance(pass_cmd, str)
|
|
576
|
+
or not pass_cmd.strip()):
|
|
577
|
+
raise ConfigError(f"hygiene.{key} must be the command this repo runs for "
|
|
578
|
+
f"that pass, or null to skip it")
|
|
579
|
+
wrap_cmd = cfg["wrap"]["command"]
|
|
580
|
+
if wrap_cmd is not None and (not isinstance(wrap_cmd, str) or not wrap_cmd.strip()):
|
|
581
|
+
raise ConfigError("wrap.command must be the command this repo runs to wrap a "
|
|
582
|
+
"session up, or null to remind without naming one")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def load(repo: Path) -> dict:
|
|
586
|
+
"""The merged config for a repo. Missing file → the defaults, which is a
|
|
587
|
+
working configuration and not an error: a fresh install must run."""
|
|
588
|
+
p = Path(repo) / CONFIG_PATH
|
|
589
|
+
if not p.is_file():
|
|
590
|
+
return dict(DEFAULTS)
|
|
591
|
+
try:
|
|
592
|
+
raw = json.loads(p.read_text())
|
|
593
|
+
except json.JSONDecodeError as e:
|
|
594
|
+
raise ConfigError(f"{CONFIG_PATH} is not valid JSON: {e}") from None
|
|
595
|
+
if not isinstance(raw, dict):
|
|
596
|
+
raise ConfigError(f"{CONFIG_PATH} must contain a JSON object")
|
|
597
|
+
raw.pop("$schema", None)
|
|
598
|
+
cfg = _merge(DEFAULTS, raw)
|
|
599
|
+
_validate(cfg)
|
|
600
|
+
return cfg
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def apply(cfg: dict) -> None:
|
|
604
|
+
"""Bind the config into the modules that read it.
|
|
605
|
+
|
|
606
|
+
Imported HERE rather than at module scope: `ids` must be reconfigured before
|
|
607
|
+
anything builds a pattern from it, and a top-level import would create a cycle
|
|
608
|
+
through `registry` → `ids`.
|
|
609
|
+
"""
|
|
610
|
+
from . import (align, autonomy, coverage, gate, git, ids, kickoff, lint, registry,
|
|
611
|
+
shift, task, tree)
|
|
612
|
+
ids.configure(cfg["ids"]["prefix"], tuple(cfg["ids"]["recognised"]),
|
|
613
|
+
bool(cfg["ids"]["undashed"]))
|
|
614
|
+
git.GIT = dict(cfg["git"])
|
|
615
|
+
lint.GRAPH_MCP = cfg["graph"]["mcp"]
|
|
616
|
+
kickoff.GRAPH_MCP = cfg["graph"]["mcp"]
|
|
617
|
+
kickoff.SESSION_MCP = cfg["session"]["mcp"]
|
|
618
|
+
kickoff.SESSION_TOOL = cfg["session"]["tool"]
|
|
619
|
+
# The same list `session_pointers` prints as the hard pre-step, composed the
|
|
620
|
+
# same way — a plan is held against a repo's standards whether the session was
|
|
621
|
+
# started by a person or by the last one.
|
|
622
|
+
kickoff.SKILL = cfg["instructions"]["skill"]
|
|
623
|
+
kickoff.SPINE = [s for s in ([cfg["spine"]["standards"]] if cfg["spine"]["standards"]
|
|
624
|
+
else []) + list(cfg["spine"]["conventions"]) if s]
|
|
625
|
+
align.FOREIGN_REPOS = tuple(cfg["ids"]["foreign"])
|
|
626
|
+
registry.DOMAIN_ORDER = tuple(cfg["domains"]["order"])
|
|
627
|
+
# From the SHIPPED set each time, never from whatever the last `apply` left
|
|
628
|
+
# behind. Unioning into the live value made a second call in one process keep the
|
|
629
|
+
# first config's additions — so a repo's skip list leaked into the next repo's,
|
|
630
|
+
# and the ~20 tests that reset with `apply(DEFAULTS)` were resetting nothing.
|
|
631
|
+
tree.SKIP_DIRS = tree.SHIPPED_SKIP_DIRS | set(cfg["skip_dirs"])
|
|
632
|
+
tree.TASK_TAGS_OK = tuple(cfg["tags"]["allowed"])
|
|
633
|
+
coverage.VERIFY = dict(cfg["verify"])
|
|
634
|
+
gate.VERIFY = dict(cfg["verify"])
|
|
635
|
+
task.PLANS_DIR = cfg["plans"]["dir"]
|
|
636
|
+
autonomy.CEILING = cfg["autonomy"]["ceiling"]
|
|
637
|
+
shift.LEASE_MINUTES = cfg["autonomy"]["lease_minutes"]
|
|
638
|
+
# `shift` reads the ceiling it was imported with, so bind it there too — a
|
|
639
|
+
# module-level `from … import CEILING` captures the value, not the name.
|
|
640
|
+
shift.CEILING = cfg["autonomy"]["ceiling"]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def resolve(repo: Path) -> dict:
|
|
644
|
+
"""`load` + `apply` — the one call an entry point makes."""
|
|
645
|
+
cfg = load(repo)
|
|
646
|
+
apply(cfg)
|
|
647
|
+
return cfg
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
#: A rule heading in somebody's standards file. The separator VARIES by repo and the
|
|
651
|
+
#: id may carry a letter suffix — measured across five consuming repos on 2026-09-04:
|
|
652
|
+
#: `## S-1. Function signatures`, `## S-1 · Function signatures`, `### S-2a. …`. A
|
|
653
|
+
#: heading with no id is skipped rather than listed, because the id is the whole
|
|
654
|
+
#: point: it is what a session cites and what `where` resolves.
|
|
655
|
+
_STANDARD = re.compile(r"^#{2,4}\s+([A-Za-z]{1,3}-\d+[a-z]?)\s*[.·:]?\s+(.+?)\s*$",
|
|
656
|
+
re.MULTILINE)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def standards_index(cfg: dict, repo) -> list:
|
|
660
|
+
"""`[(id, title)]` for the rules in THIS repo's own standards file.
|
|
661
|
+
|
|
662
|
+
Names only, never bodies. The full file is 74,000 bytes in this repo and 21,000
|
|
663
|
+
in the smallest consumer, and injecting it would cost ~18,000 tokens of every
|
|
664
|
+
session — which is not the fix for rules being skipped, it is the same problem
|
|
665
|
+
with a bigger wall in front of it. The names cost 61 to 229 tokens across the
|
|
666
|
+
five repos measured, and they buy the thing a bare pointer never did: a session
|
|
667
|
+
about to name a file has already been told a file-naming rule exists and what to
|
|
668
|
+
call it, so it can go and read that ONE rule.
|
|
669
|
+
|
|
670
|
+
Read from `spine.standards`, so what a session is told is the standards of the
|
|
671
|
+
repo it is standing in. A shipped harness has no business knowing what any
|
|
672
|
+
particular repo's rules are called.
|
|
673
|
+
"""
|
|
674
|
+
name = cfg["spine"]["standards"]
|
|
675
|
+
if not name or repo is None:
|
|
676
|
+
return []
|
|
677
|
+
md = Path(repo) / name
|
|
678
|
+
try:
|
|
679
|
+
return _STANDARD.findall(md.read_text())
|
|
680
|
+
except OSError:
|
|
681
|
+
# A configured pointer to a file that is not there is worth saying out loud,
|
|
682
|
+
# and the caller says it — but never by failing the hook that says it.
|
|
683
|
+
return []
|
|
684
|
+
#: Width a listed line wraps at. By WIDTH rather than by count, because rule titles
|
|
685
|
+
#: vary from three words to fifteen and a fixed count produced lines four times
|
|
686
|
+
#: longer than each other.
|
|
687
|
+
_WRAP_AT = 96
|
|
688
|
+
|
|
689
|
+
#: A rule whose title opens this way is HISTORY, and injecting it as a live decision
|
|
690
|
+
#: is worse than omitting it — a session told `J-14` governs the thing it is about to
|
|
691
|
+
#: build goes and honours a rule the repo retired.
|
|
692
|
+
_PAST = re.compile(r"^\s*(RETIRED|SUPERSEDED|WITHDRAWN)\b", re.IGNORECASE)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _listing(head: str, items: list) -> str:
|
|
696
|
+
lines, row = [], ""
|
|
697
|
+
for item in items:
|
|
698
|
+
if row and len(row) + len(item) + 3 > _WRAP_AT:
|
|
699
|
+
lines.append(row)
|
|
700
|
+
row = ""
|
|
701
|
+
row = f"{row} · {item}" if row else item
|
|
702
|
+
if row:
|
|
703
|
+
lines.append(row)
|
|
704
|
+
return head + "\n" + "\n".join(" " + line for line in lines)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _method(cfg: dict, repo) -> list:
|
|
708
|
+
"""The configured instruction layers, in full and in order.
|
|
709
|
+
|
|
710
|
+
Injected rather than pointed at. It was a Claude Code skill a session was told
|
|
711
|
+
to LOAD, which is an affordance one vendor's agent has and nobody else does —
|
|
712
|
+
and a session that skipped the line worked the board without the method at all.
|
|
713
|
+
A hook is the one surface a session cannot decline, so what has to be read
|
|
714
|
+
arrives there.
|
|
715
|
+
|
|
716
|
+
Order is engine < preset < extend[0] < extend[1] …, and later wins, so a repo
|
|
717
|
+
contradicting a shipped line is read as the answer rather than as a second
|
|
718
|
+
opinion. A layer that is MISSING is reported by the caller and skipped here —
|
|
719
|
+
never silently dropped, because a session behaving unlike every other session in
|
|
720
|
+
the repo with nothing saying why is the worst outcome available.
|
|
721
|
+
"""
|
|
722
|
+
if repo is None:
|
|
723
|
+
return []
|
|
724
|
+
res = resolve_instructions(cfg, repo)
|
|
725
|
+
layered = ([res["preset"]] if res["preset"] else []) + list(res["extend"])
|
|
726
|
+
out = []
|
|
727
|
+
for label, path in layered:
|
|
728
|
+
if path is None:
|
|
729
|
+
continue
|
|
730
|
+
try:
|
|
731
|
+
body = _injectable(path)
|
|
732
|
+
except OSError:
|
|
733
|
+
continue
|
|
734
|
+
out.append(f"--- {label} — follow this; it is how work is done here ---\n"
|
|
735
|
+
+ body)
|
|
736
|
+
return out
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _method_pointer(cfg: dict, repo) -> list:
|
|
740
|
+
"""One line naming the method, in place of the method itself.
|
|
741
|
+
|
|
742
|
+
**The body used to be injected here and it was 86% of the block.** Measured in this
|
|
743
|
+
repo: 51,365 bytes at session start, of which the `appchy` preset was 44,368 —
|
|
744
|
+
~11,000 tokens in front of every session, in every repo, whether or not that session
|
|
745
|
+
ever planned anything. The derived facts around it, which is what the injection call
|
|
746
|
+
was actually about, are ~670.
|
|
747
|
+
|
|
748
|
+
So the names, the ids and the obligations still arrive unasked; the prose is fetched.
|
|
749
|
+
The line has to read as an INSTRUCTION rather than a mention, because a session that
|
|
750
|
+
treats it as a footnote works without the method — which is the failure the
|
|
751
|
+
injection was chosen to prevent, and the reason the wording here is not decoration.
|
|
752
|
+
"""
|
|
753
|
+
if repo is None:
|
|
754
|
+
return []
|
|
755
|
+
res = resolve_instructions(cfg, repo)
|
|
756
|
+
layered = ([res["preset"]] if res["preset"] else []) + list(res["extend"])
|
|
757
|
+
named = [label for label, path in layered if path is not None]
|
|
758
|
+
if not named:
|
|
759
|
+
return []
|
|
760
|
+
return [f"the method — how work is actually done here, and NOT optional: "
|
|
761
|
+
f"{' + '.join(f'`{n}`' for n in named)}. You have not read it. Get it with "
|
|
762
|
+
f"`{cli()} method` (or the `work_start` tool with `method: true`) BEFORE "
|
|
763
|
+
f"planning or editing anything."]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
#: A markdown link's target — the `x` of `[text](x)`.
|
|
767
|
+
_LINK = re.compile(r"(?<=\]\()([^)\s]+)(?=\))")
|
|
768
|
+
|
|
769
|
+
#: A leading YAML frontmatter block, and nothing else: it has to start the file.
|
|
770
|
+
_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n.*?\r?\n---[ \t]*\r?\n", re.DOTALL)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _injectable(path) -> str:
|
|
774
|
+
"""One instruction layer, as a session can actually ACT on it.
|
|
775
|
+
|
|
776
|
+
Two things a file cannot say about itself, both of which stop being harmless the
|
|
777
|
+
moment it is injected rather than opened.
|
|
778
|
+
|
|
779
|
+
Its frontmatter is metadata for whatever loads the file, and reads as an
|
|
780
|
+
instruction once it is pasted into a session — this preset announced itself as a
|
|
781
|
+
Claude Code skill, in the words "should be used when the user wants to", to
|
|
782
|
+
sessions that had already been handed it and could not have loaded it if they
|
|
783
|
+
tried.
|
|
784
|
+
|
|
785
|
+
And its links are relative to the file, which lives inside an installed package.
|
|
786
|
+
`references/graph.md` resolves to nothing from the repo a session is standing in,
|
|
787
|
+
so every document the method sends a reader to was one they could not open. Only
|
|
788
|
+
a target that actually EXISTS beside the layer is rewritten, so a link to
|
|
789
|
+
something in the reader's own repo is left to resolve there as it always did.
|
|
790
|
+
|
|
791
|
+
Done on the way through rather than to the file, so the layer stays a document
|
|
792
|
+
somebody can read, edit and diff on its own terms.
|
|
793
|
+
"""
|
|
794
|
+
body = _FRONTMATTER.sub("", path.read_text(), count=1)
|
|
795
|
+
|
|
796
|
+
def beside(match):
|
|
797
|
+
target = path.parent / match.group(0)
|
|
798
|
+
return str(target.resolve()) if target.is_file() else match.group(0)
|
|
799
|
+
|
|
800
|
+
return _LINK.sub(beside, body).strip()
|
|
801
|
+
def _what_this_repo_has_decided(cfg: dict, repo) -> list:
|
|
802
|
+
"""The house rules, the durable decisions and what each release is for — by NAME.
|
|
803
|
+
|
|
804
|
+
All three are derived from the repo being stood in, never from anything the
|
|
805
|
+
harness ships. A shipped harness has no business knowing what any particular
|
|
806
|
+
repo's rules are called, and a line that names somebody else's rules is worse
|
|
807
|
+
than no line at all.
|
|
808
|
+
"""
|
|
809
|
+
if repo is None:
|
|
810
|
+
return []
|
|
811
|
+
from . import ids, model, registry
|
|
812
|
+
root = Path(repo) / "work"
|
|
813
|
+
out = []
|
|
814
|
+
|
|
815
|
+
named = standards_index(cfg, repo)
|
|
816
|
+
if named:
|
|
817
|
+
out.append(_listing(
|
|
818
|
+
f"this repo's own standards — the id is how you cite one, and you READ "
|
|
819
|
+
f"the rule before writing code it governs ({cfg['spine']['standards']}):",
|
|
820
|
+
[f"{i} {t}" for i, t in named]))
|
|
821
|
+
|
|
822
|
+
if root.is_dir():
|
|
823
|
+
try:
|
|
824
|
+
rules = registry.hosted_rules(root)
|
|
825
|
+
except OSError:
|
|
826
|
+
rules = []
|
|
827
|
+
live = sorted(((i, ti) for _, i, ti in rules if not _PAST.match(ti)),
|
|
828
|
+
key=lambda r: ids.sort_key(r[0]))
|
|
829
|
+
if live:
|
|
830
|
+
out.append(_listing(
|
|
831
|
+
f"decisions this repo has ALREADY taken — `{cli()} where <id>` opens "
|
|
832
|
+
f"one in full. Contradicting one is a thing you say out loud and get "
|
|
833
|
+
f"agreed, never a thing a plan does quietly:",
|
|
834
|
+
[f"{i} {ti}" for i, ti in live]))
|
|
835
|
+
|
|
836
|
+
try:
|
|
837
|
+
versions = model.scan(root)["versions"]
|
|
838
|
+
except (OSError, KeyError):
|
|
839
|
+
versions = []
|
|
840
|
+
cuts = [f"{v.name} ({v.status()}) — {v.outcome}" for v in versions if v.outcome]
|
|
841
|
+
if cuts:
|
|
842
|
+
# The releases, from `version.md`'s own `outcome:` rather than from
|
|
843
|
+
# ROADMAP.md — a ninth of the tokens, and it cannot go stale, because
|
|
844
|
+
# `version.md` OWNS what a cut delivers and the roadmap only orders them.
|
|
845
|
+
out.append("what the releases are for, so a plan does not win this week "
|
|
846
|
+
"and cost the next one:\n " + "\n ".join(cuts))
|
|
847
|
+
return out
|
|
848
|
+
#: What the block is, said once, above every line of it. It names no repo and makes
|
|
849
|
+
#: no claim a config could contradict — which is what lets it be the same sentence
|
|
850
|
+
#: wherever the block is served.
|
|
851
|
+
HEADING = "work harness — standing obligations for this repo:"
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def session_pointers(cfg: dict, repo=None) -> list:
|
|
855
|
+
"""The lines a `SessionStart` hook injects — **every one of them derived from
|
|
856
|
+
the config**, never authored prose about some particular repo.
|
|
857
|
+
|
|
858
|
+
A hook is the one surface a session cannot choose not to read, so what it says
|
|
859
|
+
has to be true in the repo it fires in. The shipped harness knows nothing about
|
|
860
|
+
which files hold a repo's standards, whether a graph engine is installed, or
|
|
861
|
+
what its verify commands are called — so the hook states what config says and
|
|
862
|
+
nothing more. Where a key is unset it says *that*, because "no standards
|
|
863
|
+
configured" is an actionable fact and an invented pointer is a lie the next
|
|
864
|
+
session acts on.
|
|
865
|
+
"""
|
|
866
|
+
# Named through `cli()`, because this is the one message every session reads and
|
|
867
|
+
# a machine without the plugin has no `work` on its PATH at all — only the CLI
|
|
868
|
+
# the payload ships inside. Naming a command the reader cannot run, in the block
|
|
869
|
+
# that tells them how to work here, is the paper cut that costs every session.
|
|
870
|
+
out = [f"the board: tasks live under `work/` — `{cli()} list` shows what is "
|
|
871
|
+
f"moving, `{cli()} where <id>` resolves any rule id to its home."]
|
|
872
|
+
|
|
873
|
+
# The guidance stack, ANNOUNCED. What ships is an engine plus somebody's
|
|
874
|
+
# methodology, and a session that cannot tell which is which will cite the
|
|
875
|
+
# preset's reasoning as though the tool enforced it. Saying it also makes an
|
|
876
|
+
# override visible: a repo working differently is a choice somebody made, not a
|
|
877
|
+
# drift nobody noticed — this harness bans repo names from the payload for the
|
|
878
|
+
# same reason, and an unannounced preset is that fork wearing a config key.
|
|
879
|
+
if repo is not None:
|
|
880
|
+
res = resolve_instructions(cfg, repo)
|
|
881
|
+
layers, missing = [], []
|
|
882
|
+
if res["preset"]:
|
|
883
|
+
label, path = res["preset"]
|
|
884
|
+
layers.append(f"preset `{label}`")
|
|
885
|
+
if path is None:
|
|
886
|
+
missing.append(label)
|
|
887
|
+
for label, path in res["extend"]:
|
|
888
|
+
layers.append(f"`{label}`")
|
|
889
|
+
if path is None:
|
|
890
|
+
missing.append(label)
|
|
891
|
+
if layers:
|
|
892
|
+
# ANNOUNCED here, and FETCHED below. Which layers are in force is a fact
|
|
893
|
+
# a session cannot discover and costs a line; their bodies are ~11,000
|
|
894
|
+
# tokens and are asked for. The instruction to go and read them is the
|
|
895
|
+
# next line, worded as one — a session that reads it as a footnote works
|
|
896
|
+
# the board without the method, which is the whole risk of this shape.
|
|
897
|
+
out.append("guidance: engine + " + " + ".join(layers) +
|
|
898
|
+
" — later layers win, and the engine alone is mechanics, "
|
|
899
|
+
"not method.")
|
|
900
|
+
else:
|
|
901
|
+
# No layers is a real state and must read as one: the engine enforces
|
|
902
|
+
# the tree, and nobody has said how this repo works on top of it.
|
|
903
|
+
out.append(f"guidance: the bare engine — no method is configured for "
|
|
904
|
+
f"this repo. Set `instructions.preset` in "
|
|
905
|
+
f".claude/work.config.json, or work from `{cli()} --help` and "
|
|
906
|
+
f"accept that the conventions here are undocumented.")
|
|
907
|
+
if missing:
|
|
908
|
+
out.append("instructions MISSING and therefore not applied: " +
|
|
909
|
+
", ".join(f"`{m}`" for m in missing) +
|
|
910
|
+
" — say so rather than proceeding as if this repo had none.")
|
|
911
|
+
|
|
912
|
+
spine = [s for s in ([cfg["spine"]["standards"]] if cfg["spine"]["standards"] else [])
|
|
913
|
+
+ list(cfg["spine"]["conventions"]) if s]
|
|
914
|
+
if spine:
|
|
915
|
+
out.append("read before planning (hard pre-step): " + " · ".join(spine))
|
|
916
|
+
else:
|
|
917
|
+
out.append("read before planning: no `spine.standards`/`spine.conventions` "
|
|
918
|
+
"configured — set them in .claude/work.config.json so plans are "
|
|
919
|
+
"held to this repo's conventions.")
|
|
920
|
+
|
|
921
|
+
# The NAMES of what this repo has already decided — its house rules, its durable
|
|
922
|
+
# decisions, and what each release is for. Names and ids only, never bodies: the
|
|
923
|
+
# standards file alone is ~18,000 tokens here, and a wall of text at the top of a
|
|
924
|
+
# session is how a rule gets skipped rather than how it gets read.
|
|
925
|
+
#
|
|
926
|
+
# What the names buy is the thing a bare pointer never did. A session about to
|
|
927
|
+
# name a file has already been told a file-naming rule exists and what it is
|
|
928
|
+
# called, so it can go and read that ONE rule; a session that was only told
|
|
929
|
+
# "read STANDARDS.md" does not know there is anything in there about naming.
|
|
930
|
+
out += _what_this_repo_has_decided(cfg, repo)
|
|
931
|
+
out += _method_pointer(cfg, repo)
|
|
932
|
+
|
|
933
|
+
mcp = cfg["graph"]["mcp"]
|
|
934
|
+
if mcp:
|
|
935
|
+
out.append(f"blast radius: map through the `{mcp}` graph before proposing a "
|
|
936
|
+
f"non-trivial plan.")
|
|
937
|
+
else:
|
|
938
|
+
out.append("blast radius: no graph engine configured (`graph.mcp` is null) — "
|
|
939
|
+
"the sweep is grep-wide, and the plan must SAY so rather than "
|
|
940
|
+
"imply a complete map.")
|
|
941
|
+
|
|
942
|
+
# Named only where a repo named them. A session told to run a pass this repo
|
|
943
|
+
# never configured goes looking for a command that may not exist — and the
|
|
944
|
+
# harness has no business knowing which one it would be.
|
|
945
|
+
passes = [f"`{cfg['hygiene'][k]}`" for k in ("review", "simplify")
|
|
946
|
+
if cfg["hygiene"][k]]
|
|
947
|
+
if passes:
|
|
948
|
+
out.append("hygiene passes configured here: " + " and ".join(passes) +
|
|
949
|
+
" — run them at a checkpoint and before completing.")
|
|
950
|
+
|
|
951
|
+
if cfg["verify"]:
|
|
952
|
+
# Names only. The commands themselves are printed by `coverage`, which is
|
|
953
|
+
# where a session goes to run them; repeating them here would be a second
|
|
954
|
+
# copy to drift, and this line only has to say what exists.
|
|
955
|
+
out.append("verify commands available: " + ", ".join(sorted(cfg["verify"])))
|
|
956
|
+
|
|
957
|
+
# Status is the bucket, so a task only says what is true if somebody MOVES it —
|
|
958
|
+
# and the move is the step a session skips, because the code already works and
|
|
959
|
+
# the board is not where the work feels like it lives. Saying it here costs one
|
|
960
|
+
# line and removes the sentence a founder otherwise types every session.
|
|
961
|
+
out.append(f"status is a move, not a note: `{cli()} move <task> in-progress` when "
|
|
962
|
+
f"you pick it up, `complete` when it lands. The bucket IS the status — "
|
|
963
|
+
f"nothing else records it.")
|
|
964
|
+
|
|
965
|
+
at = cfg["wrap"]["at_percent"]
|
|
966
|
+
if at:
|
|
967
|
+
# Phrased as "you will be TOLD" rather than "wrap up past N%", and the
|
|
968
|
+
# difference is the whole point. A session cannot measure its own context —
|
|
969
|
+
# it has no reading to compare against a threshold — so an instruction to act
|
|
970
|
+
# at N% asks for a judgement it can only guess at, and the guess runs early
|
|
971
|
+
# because a model that has been working a while feels full long before it is.
|
|
972
|
+
# Stating the number without the means to read it produced exactly that: a
|
|
973
|
+
# wrap-up at perhaps a quarter of the window, session after session. The hook
|
|
974
|
+
# holds the only real reading, so it holds the decision.
|
|
975
|
+
out.append(f"wrapping up: a hook measures the context and will TELL you when "
|
|
976
|
+
f"to wrap up (at {at}%). Never judge the level yourself and never "
|
|
977
|
+
f"wrap up early — you cannot see the reading, and the guess is "
|
|
978
|
+
f"always high. Until it fires or the user asks, keep working"
|
|
979
|
+
+ (f"; then run `{cfg['wrap']['command']}`." if cfg["wrap"]["command"]
|
|
980
|
+
else "."))
|
|
981
|
+
return out
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cmd_context(cfg: dict, args=None, repo=None) -> int:
|
|
985
|
+
"""Print the SessionStart block. Separate from `config` on purpose: `config` is
|
|
986
|
+
the machine view (one JSON object, the whole merged model) and this is the
|
|
987
|
+
human/session view (a handful of obligations). One is parsed, the other read.
|
|
988
|
+
|
|
989
|
+
`hooks.session_start` decides whose lines these are: the harness's, the repo's,
|
|
990
|
+
or both. The repo's come LAST — later layers win here exactly as they do in the
|
|
991
|
+
instruction stack, so an extension can contradict a shipped line and be read as
|
|
992
|
+
the answer rather than as a second opinion.
|
|
993
|
+
|
|
994
|
+
The heading is printed HERE and not by whoever runs this. Several doors serve
|
|
995
|
+
the same block now — a hook, a tool — and a sentence each of them types itself
|
|
996
|
+
is a sentence they can disagree about. Nothing left of a door but stdout.
|
|
997
|
+
|
|
998
|
+
Nothing at all when there is nothing to say: a repo that switched the block off
|
|
999
|
+
gets silence, not a heading over an empty list."""
|
|
1000
|
+
from . import extend
|
|
1001
|
+
spec = cfg["hooks"]["session_start"]
|
|
1002
|
+
lines = session_pointers(cfg, repo) if spec.get("enabled", True) else []
|
|
1003
|
+
lines += extend.run(spec.get("extend"), repo if repo is not None else Path.cwd())
|
|
1004
|
+
if not lines:
|
|
1005
|
+
return 0
|
|
1006
|
+
print(HEADING)
|
|
1007
|
+
for line in lines:
|
|
1008
|
+
print(f" {line}")
|
|
1009
|
+
return 0
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def cmd_method(cfg: dict, args=None, repo=None) -> int:
|
|
1013
|
+
"""Print the configured instruction layers in full — the method, on request.
|
|
1014
|
+
|
|
1015
|
+
It is the same bytes `session_pointers` used to inject, from the same reader, so
|
|
1016
|
+
there is one account of how work is done here and no way for a session to have read
|
|
1017
|
+
a different one. What changed is only WHEN it arrives.
|
|
1018
|
+
"""
|
|
1019
|
+
repo = Path(repo) if repo is not None else Path.cwd()
|
|
1020
|
+
body = _method(cfg, repo)
|
|
1021
|
+
if not body:
|
|
1022
|
+
# Said rather than answered empty: a session that asked how work is done here
|
|
1023
|
+
# and got silence concludes there is nothing to know, and proceeds.
|
|
1024
|
+
print("no method is configured for this repo — set `instructions.preset` in "
|
|
1025
|
+
".claude/work.config.json, or work from `" + cli() + " --help` and accept "
|
|
1026
|
+
"that the conventions here are undocumented.")
|
|
1027
|
+
return 0
|
|
1028
|
+
for layer in body:
|
|
1029
|
+
print(layer)
|
|
1030
|
+
return 0
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
#: What the method wraps around the judgements that no gate can catch. A fence rather
|
|
1034
|
+
#: than a heading: a heading is prose somebody will reword, and the block would empty
|
|
1035
|
+
#: itself silently the day they did. A test asserts both markers are still there.
|
|
1036
|
+
APPLIES_FENCE = ("<!-- applies:begin -->", "<!-- applies:end -->")
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _fenced(text: str) -> str:
|
|
1040
|
+
"""What sits between the fence markers, or nothing at all."""
|
|
1041
|
+
start = text.find(APPLIES_FENCE[0])
|
|
1042
|
+
if start < 0:
|
|
1043
|
+
return ""
|
|
1044
|
+
end = text.find(APPLIES_FENCE[1], start)
|
|
1045
|
+
if end < 0:
|
|
1046
|
+
return ""
|
|
1047
|
+
return text[start + len(APPLIES_FENCE[0]):end].strip()
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _judgement(cfg: dict, repo) -> str:
|
|
1051
|
+
"""The method's own fenced section — the rules a machine cannot check for you.
|
|
1052
|
+
|
|
1053
|
+
Read out of the SAME layers `method` serves, so there is one statement of these
|
|
1054
|
+
and no way for a session to be handed a version of them the method contradicts.
|
|
1055
|
+
Later layers win, exactly as they do everywhere else in the instruction stack: a
|
|
1056
|
+
repo that fences its own block is answered with that one rather than with both.
|
|
1057
|
+
"""
|
|
1058
|
+
found = ""
|
|
1059
|
+
for layer in _method(cfg, repo):
|
|
1060
|
+
block = _fenced(layer)
|
|
1061
|
+
if block:
|
|
1062
|
+
found = block
|
|
1063
|
+
return found
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _session_state(cfg: dict, repo) -> Path:
|
|
1067
|
+
"""Where per-machine, per-session markers live — beside the coverage shard, which
|
|
1068
|
+
is already the gitignored root for facts that are about this checkout and not
|
|
1069
|
+
about the repo. One reader, because the hook that replays these has the default
|
|
1070
|
+
baked in and a second answer here would silently stop matching it."""
|
|
1071
|
+
return repo / Path(cfg["coverage"]["shard"]).parent
|
|
1072
|
+
def _remember_systems(cfg: dict, repo, session, systems, off=False) -> None:
|
|
1073
|
+
"""Leave this repo's file-to-system declarations where the caller can replay them.
|
|
1074
|
+
|
|
1075
|
+
The hook that asks this question fires before EVERY tool call, and asking here
|
|
1076
|
+
costs a Python start — measured at a quarter-second, which is not a price to pay
|
|
1077
|
+
on every edit for an answer that is almost always "nothing". So the declarations
|
|
1078
|
+
are written down once and the caller matches against them itself, spending a
|
|
1079
|
+
process only when it finds something unclaimed.
|
|
1080
|
+
|
|
1081
|
+
Per session, so a run that starts after the declarations changed reads the new
|
|
1082
|
+
ones. Best-effort: a caller that cannot find this file simply asks again, which
|
|
1083
|
+
is slower and never wrong.
|
|
1084
|
+
"""
|
|
1085
|
+
target = _session_state(cfg, repo) / "applies" / "map" / f"{session}.json"
|
|
1086
|
+
try:
|
|
1087
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1088
|
+
target.write_text(json.dumps({
|
|
1089
|
+
"systems": [{"name": name, "paths": paths} for name, paths in systems],
|
|
1090
|
+
# Said out loud rather than faked with a spent claim: a repo that switched
|
|
1091
|
+
# this off must stop being asked, and it must not have to be lied to about
|
|
1092
|
+
# having already been told in order to get there.
|
|
1093
|
+
**({"off": True} if off else {}),
|
|
1094
|
+
}))
|
|
1095
|
+
except OSError:
|
|
1096
|
+
pass
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def cmd_applies(cfg: dict, args=None, repo=None) -> int:
|
|
1100
|
+
"""What a session has to be told, now that it is about to write a particular file.
|
|
1101
|
+
|
|
1102
|
+
**The measurement is the caller's and the judgement is this command's**, the same
|
|
1103
|
+
split the wrap reminder uses and for the same reason: which file and which session
|
|
1104
|
+
is a thing only the client knows, while the once-rule, the wording and whatever the
|
|
1105
|
+
repo adds are the same whoever is asking.
|
|
1106
|
+
|
|
1107
|
+
Two things, each claimed at most once. The METHOD's judgements, on the first write
|
|
1108
|
+
of a session — the plan-first, ask-don't-settle, park-the-question rules that no
|
|
1109
|
+
gate detects and that stopped arriving when the method stopped being injected. And
|
|
1110
|
+
the AREA, the first time a session writes under one — which system's rules it has
|
|
1111
|
+
just walked into, by the globs that system declares about itself.
|
|
1112
|
+
|
|
1113
|
+
Prints the block or nothing at all. Silent without a session, because a claim that
|
|
1114
|
+
cannot be proven once is a claim that fires every turn and gets switched off; silent
|
|
1115
|
+
for a file under no system, and for a repo whose systems declare no paths, because
|
|
1116
|
+
naming the wrong rules is worse than naming none.
|
|
1117
|
+
"""
|
|
1118
|
+
from . import extend, ids
|
|
1119
|
+
from .registry import scan_systems, system_for_path, system_paths
|
|
1120
|
+
|
|
1121
|
+
args = args or {}
|
|
1122
|
+
spec = cfg["hooks"]["pre_tool_use"]
|
|
1123
|
+
session = args.get("session")
|
|
1124
|
+
if not session:
|
|
1125
|
+
return 0
|
|
1126
|
+
repo = Path(repo) if repo is not None else Path.cwd()
|
|
1127
|
+
root = repo / "work"
|
|
1128
|
+
|
|
1129
|
+
# Read once and handed to both questions below. Each helper can find the tree
|
|
1130
|
+
# for itself, and letting them would re-parse every architecture doc twice per
|
|
1131
|
+
# fire on the one path this whole design is trying to keep cheap.
|
|
1132
|
+
on = spec.get("enabled", True)
|
|
1133
|
+
systems = scan_systems(root) if (root / "architecture").is_dir() else []
|
|
1134
|
+
# Written on EVERY run, including the run that finds nothing to say and the run
|
|
1135
|
+
# that is switched off. The caller decides whether to ask by reading this file, so
|
|
1136
|
+
# a repo that never produced one would be asked on every write forever — the exact
|
|
1137
|
+
# cost it exists to avoid, in the repos least likely to notice it.
|
|
1138
|
+
_remember_systems(cfg, repo, session, system_paths(root, systems), off=not on)
|
|
1139
|
+
|
|
1140
|
+
# An extension still runs with the shipped half off: off, extend and replace are
|
|
1141
|
+
# one mechanic here as they are on every other hook, so a repo that switched the
|
|
1142
|
+
# shipped wording off and supplied its own gets its own.
|
|
1143
|
+
out = []
|
|
1144
|
+
if on:
|
|
1145
|
+
if _first_time(cfg, repo, session, "applies/method"):
|
|
1146
|
+
body = _judgement(cfg, repo)
|
|
1147
|
+
if body:
|
|
1148
|
+
out.append(body)
|
|
1149
|
+
|
|
1150
|
+
system = (system_for_path(root, (args.get("file") or "").strip(), systems)
|
|
1151
|
+
if systems else None)
|
|
1152
|
+
# History is filtered out by the same reader the session block uses. A session
|
|
1153
|
+
# told a retired rule governs what it is about to build goes and honours it,
|
|
1154
|
+
# which is worse than being told nothing at all.
|
|
1155
|
+
live = sorted(
|
|
1156
|
+
(i for i in (system.rules if system else {})
|
|
1157
|
+
if not _PAST.match(system.titles.get(i, ""))),
|
|
1158
|
+
key=ids.sort_key)
|
|
1159
|
+
if live and _first_time(cfg, repo, session, f"applies/system/{system.name}"):
|
|
1160
|
+
out.append(
|
|
1161
|
+
f"You are editing `{system.name}` — {len(live)} rule(s) govern "
|
|
1162
|
+
f"this part of the code: {', '.join(live)}. Read the one your change "
|
|
1163
|
+
f"touches before you write it; a title is not the rule.")
|
|
1164
|
+
|
|
1165
|
+
out += extend.run(spec.get("extend"), repo)
|
|
1166
|
+
if not out:
|
|
1167
|
+
return 0
|
|
1168
|
+
print("\n\n".join(out))
|
|
1169
|
+
return 0
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def cmd_remind(cfg: dict, args=None, repo=None) -> int:
|
|
1173
|
+
"""Say whether a session this full should be wrapping up, and what that means here.
|
|
1174
|
+
|
|
1175
|
+
**It is handed a MEASUREMENT, never a transcript.** How many tokens a session is
|
|
1176
|
+
holding is a thing only its client can answer, and every client answers it its own
|
|
1177
|
+
way — so measuring is the client's half and it stays there. What is left is the
|
|
1178
|
+
part that is the same for all of them: the threshold, the window, the wording, the
|
|
1179
|
+
once-per-session rule, and whatever this repo adds. A harness command that took one
|
|
1180
|
+
vendor's log file would be a harness that only works for that vendor.
|
|
1181
|
+
|
|
1182
|
+
Prints one JSON object, or nothing at all. A caller wraps `headline` and `note` in
|
|
1183
|
+
whatever its own surface expects; nothing here knows what that looks like.
|
|
1184
|
+
|
|
1185
|
+
Silent on every path where it cannot be sure. A reminder that arrives at the wrong
|
|
1186
|
+
moment is worse than one that never arrives — it teaches its reader to ignore the
|
|
1187
|
+
next one, and there is only ever one that matters.
|
|
1188
|
+
"""
|
|
1189
|
+
args = args or {}
|
|
1190
|
+
at = cfg["wrap"]["at_percent"]
|
|
1191
|
+
window = cfg["wrap"]["context_tokens"]
|
|
1192
|
+
try:
|
|
1193
|
+
used = int(args.get("used") or 0)
|
|
1194
|
+
except (TypeError, ValueError):
|
|
1195
|
+
used = 0
|
|
1196
|
+
if not at or not window or used <= 0:
|
|
1197
|
+
return 0
|
|
1198
|
+
percent = used / window * 100
|
|
1199
|
+
if percent < at:
|
|
1200
|
+
return 0
|
|
1201
|
+
|
|
1202
|
+
repo = Path(repo) if repo is not None else Path.cwd()
|
|
1203
|
+
if not _first_time(cfg, repo, args.get("session")):
|
|
1204
|
+
return 0
|
|
1205
|
+
|
|
1206
|
+
from . import extend
|
|
1207
|
+
from .tree import cli
|
|
1208
|
+
spec = cfg["hooks"]["stop"]
|
|
1209
|
+
extra = extend.run(spec.get("extend"), repo)
|
|
1210
|
+
if not spec.get("enabled", True):
|
|
1211
|
+
if not extra:
|
|
1212
|
+
return 0
|
|
1213
|
+
print(json.dumps({"headline": "\n".join(extra), "note": "\n".join(extra)}))
|
|
1214
|
+
return 0
|
|
1215
|
+
|
|
1216
|
+
# Null when the repo names none, and then the reminder says to wrap without naming
|
|
1217
|
+
# a command — better than sending the reader to one that resolves for nobody.
|
|
1218
|
+
command = cfg["wrap"]["command"]
|
|
1219
|
+
# What the last step IS depends on whether this repo has something that can start a
|
|
1220
|
+
# session. Telling a repo with no starter to start one would be the harness
|
|
1221
|
+
# inventing a capability; telling one that has a starter to leave a prompt for a
|
|
1222
|
+
# person would be it withholding what was just wired up.
|
|
1223
|
+
if cfg["session"]["mcp"]:
|
|
1224
|
+
last = (f"`{cli()} kickoff <task>` and START the next session with it — the "
|
|
1225
|
+
"prompt is derived, never hand-written, because a hand-written one "
|
|
1226
|
+
"never invoked the method.")
|
|
1227
|
+
else:
|
|
1228
|
+
last = (f"`{cli()} kickoff <task>` — the prompt that opens the next session on "
|
|
1229
|
+
"this work, with the method actually loaded.")
|
|
1230
|
+
note = (
|
|
1231
|
+
f"Context is {percent:.0f}% full ({used:,} of {window:,} tokens; the threshold "
|
|
1232
|
+
f"is {at}%). Finish this session cleanly while there is still room to do it "
|
|
1233
|
+
f"well:\n"
|
|
1234
|
+
f" 1. `{cli()} handoff <task>` — status, next step, and what you learned.\n"
|
|
1235
|
+
f" 2. Bring the task and epic docs to current — a stale brief is a trap for "
|
|
1236
|
+
f"the next session.\n"
|
|
1237
|
+
f" 3. Move anything finished: `{cli()} move <task> complete`.\n"
|
|
1238
|
+
f" 4. Park anything still open: `{cli()} ask <task> --question \"…\"`.\n"
|
|
1239
|
+
f" 5. {last}\n"
|
|
1240
|
+
+ (f"`{command}` runs all five. " if command else "")
|
|
1241
|
+
+ "Say so before you start, and if the user is mid-thought, finish their point "
|
|
1242
|
+
"first — this is a reminder, not a stop."
|
|
1243
|
+
+ ("\n" + "\n".join(extra) if extra else "")
|
|
1244
|
+
)
|
|
1245
|
+
headline = (f"work: {percent:.0f}% of context used ({used // 1000}k/"
|
|
1246
|
+
f"{window // 1000}k) — time to wrap up"
|
|
1247
|
+
+ (f" (`{command}`)." if command else "."))
|
|
1248
|
+
print(json.dumps({"headline": headline, "note": note}))
|
|
1249
|
+
return 0
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _first_time(cfg: dict, repo, session, what="wrap-reminded") -> bool:
|
|
1253
|
+
"""Claim one of the things this session is told once, or say it is already spent.
|
|
1254
|
+
|
|
1255
|
+
Once, or it fires at the end of every remaining turn and becomes the thing people
|
|
1256
|
+
switch off. The marker is per-machine, per-session state and lives under the
|
|
1257
|
+
gitignored coverage root — never a repo fact.
|
|
1258
|
+
|
|
1259
|
+
`what` names WHICH once — the wrap reminder, the method, one system's rules — so
|
|
1260
|
+
several things can each arrive once without any of them spending another's claim.
|
|
1261
|
+
|
|
1262
|
+
False when the claim cannot be PROVEN, not when it fails: unable to write the
|
|
1263
|
+
marker means unable to promise this happens once, and a reminder repeated every
|
|
1264
|
+
turn costs more than a reminder missed.
|
|
1265
|
+
"""
|
|
1266
|
+
marker = _session_state(cfg, repo) / what / str(session or "unknown")
|
|
1267
|
+
try:
|
|
1268
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
1269
|
+
# Created exclusively, never checked-then-written. A turn issuing two tool
|
|
1270
|
+
# calls at once runs two of these at once, and a look followed by a write
|
|
1271
|
+
# lets both of them believe they were first — which is the one outcome the
|
|
1272
|
+
# whole claim exists to prevent.
|
|
1273
|
+
with open(marker, "x"):
|
|
1274
|
+
pass
|
|
1275
|
+
return True
|
|
1276
|
+
except FileExistsError:
|
|
1277
|
+
return False
|
|
1278
|
+
except OSError:
|
|
1279
|
+
return False
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def cmd_config(cfg: dict, args=None) -> int:
|
|
1283
|
+
"""Print the merged config as one JSON object — shipped defaults under the
|
|
1284
|
+
repo's committed file. This is what the skill reads, so what it prints IS the
|
|
1285
|
+
configuration; there is no second view of it to drift.
|
|
1286
|
+
|
|
1287
|
+
`set` and `unset` write it. They exist because this file has rules — which keys
|
|
1288
|
+
exist, which sections are open, what a value may contain — and those rules
|
|
1289
|
+
belong to whoever owns the format. Every installer that merges the JSON itself
|
|
1290
|
+
carries a second copy of them, and a stale copy writes a config the loader then
|
|
1291
|
+
refuses. One writer, and it validates before it saves."""
|
|
1292
|
+
args = args or {}
|
|
1293
|
+
verb = args.get("_verb")
|
|
1294
|
+
if verb in ("set", "unset"):
|
|
1295
|
+
try:
|
|
1296
|
+
return _cmd_config_write(args)
|
|
1297
|
+
except ConfigError as e:
|
|
1298
|
+
# `die`, not a traceback. This is the command an installer shells out
|
|
1299
|
+
# to, and a stack trace in its output is a refusal nobody can act on.
|
|
1300
|
+
from .tree import die
|
|
1301
|
+
die(f"{e}")
|
|
1302
|
+
print(json.dumps(cfg, indent=2, sort_keys=True))
|
|
1303
|
+
return 0
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _key_path(dotted: str) -> list:
|
|
1307
|
+
"""Split and CHECK a dotted key against the shipped shape.
|
|
1308
|
+
|
|
1309
|
+
Refused here rather than at the next load. A key nobody recognises is fatal to
|
|
1310
|
+
every command in the repo — so an installer that writes one has not configured
|
|
1311
|
+
something, it has broken the harness for whoever runs it next, and it finds out
|
|
1312
|
+
from them.
|
|
1313
|
+
"""
|
|
1314
|
+
parts = [seg for seg in dotted.split(".") if seg]
|
|
1315
|
+
if not parts:
|
|
1316
|
+
raise ConfigError("name a key to write, e.g. session.mcp")
|
|
1317
|
+
node, walked = DEFAULTS, ""
|
|
1318
|
+
for i, seg in enumerate(parts):
|
|
1319
|
+
if walked in _OPEN_MAPS:
|
|
1320
|
+
# The repo's to invent — a command name under `verify` is data, not
|
|
1321
|
+
# schema, and checking it against the defaults would refuse every
|
|
1322
|
+
# legal one.
|
|
1323
|
+
return parts
|
|
1324
|
+
if not isinstance(node, dict) or seg not in node:
|
|
1325
|
+
known = ", ".join(sorted(node)) if isinstance(node, dict) else "(a value)"
|
|
1326
|
+
raise ConfigError(f"unknown config key '{dotted}' — "
|
|
1327
|
+
f"'{'.'.join(parts[:i + 1])}' is not one of: {known}")
|
|
1328
|
+
node = node[seg]
|
|
1329
|
+
walked = f"{walked}.{seg}" if walked else seg
|
|
1330
|
+
if not isinstance(node, dict) and i < len(parts) - 1:
|
|
1331
|
+
raise ConfigError(f"'{walked}' is a value, so '{dotted}' cannot exist "
|
|
1332
|
+
f"under it")
|
|
1333
|
+
return parts
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _parse_value(raw: str, force_json: bool):
|
|
1337
|
+
"""A value from a command line. JSON when it parses as JSON, else the string.
|
|
1338
|
+
|
|
1339
|
+
`jarvis work config set ids.prefix P` should not need quoting, and
|
|
1340
|
+
`jarvis work config set hooks.stop.enabled false` must not write the STRING "false" —
|
|
1341
|
+
which is truthy, and would switch on the thing it was typed to switch off.
|
|
1342
|
+
"""
|
|
1343
|
+
if force_json:
|
|
1344
|
+
try:
|
|
1345
|
+
return json.loads(raw)
|
|
1346
|
+
except json.JSONDecodeError as e:
|
|
1347
|
+
raise ConfigError(f"--json given but {raw!r} is not JSON: {e}") from None
|
|
1348
|
+
try:
|
|
1349
|
+
return json.loads(raw)
|
|
1350
|
+
except json.JSONDecodeError:
|
|
1351
|
+
return raw
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
#: Written where a value would be, and not values themselves — so they are never
|
|
1355
|
+
#: JSON-quoted into looking like one. `"(unset)"` is a string somebody could set.
|
|
1356
|
+
_UNSET = "(unset)"
|
|
1357
|
+
_DEFAULTED = "(the shipped default)"
|
|
1358
|
+
_REMOVED = "(removed — the schema has no such key, so there is no default to fall back to)"
|
|
1359
|
+
_MARKERS = (_UNSET, _DEFAULTED, _REMOVED)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _show(v) -> str:
|
|
1363
|
+
return v if isinstance(v, str) and v in _MARKERS else json.dumps(v)
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _present(node, parts) -> bool:
|
|
1367
|
+
"""Is this dotted path actually IN the file? Used to decide whether an `unset`
|
|
1368
|
+
of a key the schema does not know is a removal or a typo."""
|
|
1369
|
+
for seg in parts:
|
|
1370
|
+
if not isinstance(node, dict) or seg not in node:
|
|
1371
|
+
return False
|
|
1372
|
+
node = node[seg]
|
|
1373
|
+
return True
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _why_invalid(raw: dict):
|
|
1377
|
+
"""The ConfigError this file would raise, or None. Deep-copied through JSON
|
|
1378
|
+
because `_merge` and `_validate` are free to look at anything."""
|
|
1379
|
+
try:
|
|
1380
|
+
_validate(_merge(DEFAULTS, json.loads(json.dumps(raw))))
|
|
1381
|
+
except ConfigError as e:
|
|
1382
|
+
return e
|
|
1383
|
+
return None
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _cmd_config_write(args) -> int:
|
|
1387
|
+
if args.get("branch"):
|
|
1388
|
+
raise ConfigError("`config set` writes this checkout's own config — it "
|
|
1389
|
+
"cannot write another branch's. Check that branch out "
|
|
1390
|
+
"and run it there.")
|
|
1391
|
+
repo = Path(args["project_root"])
|
|
1392
|
+
dotted = args.get("key") or ""
|
|
1393
|
+
p = repo / CONFIG_PATH
|
|
1394
|
+
|
|
1395
|
+
raw, was = {}, ""
|
|
1396
|
+
if p.is_file():
|
|
1397
|
+
try:
|
|
1398
|
+
was = p.read_text()
|
|
1399
|
+
raw = json.loads(was)
|
|
1400
|
+
except json.JSONDecodeError as e:
|
|
1401
|
+
raise ConfigError(f"{CONFIG_PATH} is not valid JSON, so it cannot be "
|
|
1402
|
+
f"written safely: {e}") from None
|
|
1403
|
+
if not isinstance(raw, dict):
|
|
1404
|
+
raise ConfigError(f"{CONFIG_PATH} must contain a JSON object")
|
|
1405
|
+
schema = raw.pop("$schema", None)
|
|
1406
|
+
|
|
1407
|
+
unknown = False
|
|
1408
|
+
try:
|
|
1409
|
+
parts = _key_path(dotted)
|
|
1410
|
+
except ConfigError:
|
|
1411
|
+
# An `unset` may remove a key the shipped schema does not know, and that is
|
|
1412
|
+
# not a loophole — it is the repair. A typo'd key, or one written by a
|
|
1413
|
+
# different version of the plugin, is fatal to every command in the repo,
|
|
1414
|
+
# and refusing to remove it would leave the only tool that can fix it
|
|
1415
|
+
# refusing on the grounds that the thing being fixed is broken.
|
|
1416
|
+
parts = [seg for seg in dotted.split(".") if seg]
|
|
1417
|
+
if args["_verb"] != "unset" or not _present(raw, parts):
|
|
1418
|
+
raise
|
|
1419
|
+
unknown = True
|
|
1420
|
+
|
|
1421
|
+
# What was wrong with this file BEFORE the write, so a refusal can tell the two
|
|
1422
|
+
# apart. Without it, `config set ids.prefix P` on a file with an unrelated bad
|
|
1423
|
+
# key fails with a message about a key the caller never touched.
|
|
1424
|
+
before_err = _why_invalid(raw)
|
|
1425
|
+
|
|
1426
|
+
node = raw
|
|
1427
|
+
for seg in parts[:-1]:
|
|
1428
|
+
nxt = node.get(seg)
|
|
1429
|
+
if not isinstance(nxt, dict):
|
|
1430
|
+
nxt = {}
|
|
1431
|
+
node[seg] = nxt
|
|
1432
|
+
node = nxt
|
|
1433
|
+
before = node.get(parts[-1], _UNSET)
|
|
1434
|
+
if args["_verb"] == "unset":
|
|
1435
|
+
node.pop(parts[-1], None)
|
|
1436
|
+
# An override removed from a nested section leaves `{}` behind, which is a
|
|
1437
|
+
# section a reader would take for a deliberate empty. Prune back up.
|
|
1438
|
+
trail, cur = [], raw
|
|
1439
|
+
for seg in parts[:-1]:
|
|
1440
|
+
trail.append((cur, seg))
|
|
1441
|
+
cur = cur[seg]
|
|
1442
|
+
for parent, seg in reversed(trail):
|
|
1443
|
+
if parent[seg] == {}:
|
|
1444
|
+
parent.pop(seg)
|
|
1445
|
+
after = _REMOVED if unknown else _DEFAULTED
|
|
1446
|
+
else:
|
|
1447
|
+
after = _parse_value(args.get("value", ""), args.get("json") == "true")
|
|
1448
|
+
node[parts[-1]] = after
|
|
1449
|
+
|
|
1450
|
+
# Validated as a WHOLE before anything is saved. A key can be legal on its own
|
|
1451
|
+
# and illegal beside its neighbours — `session.mcp` without `session.tool` is
|
|
1452
|
+
# exactly that — and the alternative is writing a file that makes every later
|
|
1453
|
+
# command in the repo fail.
|
|
1454
|
+
after_err = _why_invalid(raw)
|
|
1455
|
+
if after_err:
|
|
1456
|
+
if before_err and str(before_err) == str(after_err):
|
|
1457
|
+
raise ConfigError(
|
|
1458
|
+
f"{CONFIG_PATH} was ALREADY invalid before this change and still "
|
|
1459
|
+
f"is — your write is not the cause and has not been saved. The "
|
|
1460
|
+
f"problem is: {after_err}\n"
|
|
1461
|
+
f" Fix that first. `config unset <key>` drops an override, "
|
|
1462
|
+
f"and it will remove a key this schema does not recognise.")
|
|
1463
|
+
raise after_err
|
|
1464
|
+
|
|
1465
|
+
if schema is not None:
|
|
1466
|
+
raw = {"$schema": schema, **raw}
|
|
1467
|
+
# This writes ONE canonical shape, so a file that was formatted differently
|
|
1468
|
+
# comes back reformatted. That is the right trade for a file two installers may
|
|
1469
|
+
# both write — they cannot fight over layout — but it is somebody else's diff,
|
|
1470
|
+
# so it is announced rather than discovered.
|
|
1471
|
+
reflowed = was.strip() and json.dumps(json.loads(was), indent=2) + "\n" != was
|
|
1472
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1473
|
+
# Written beside the target and renamed: a half-written config is a repo where
|
|
1474
|
+
# nothing runs, and an interrupted write is not a hypothetical on a laptop.
|
|
1475
|
+
tmp = p.with_suffix(".json.tmp")
|
|
1476
|
+
tmp.write_text(json.dumps(raw, indent=2) + "\n")
|
|
1477
|
+
tmp.replace(p)
|
|
1478
|
+
print(f"{dotted}: {_show(before)} -> {_show(after)}")
|
|
1479
|
+
print(f"wrote {CONFIG_PATH}" + (" — and reformatted it to canonical 2-space "
|
|
1480
|
+
"JSON, so the diff is larger than this one key"
|
|
1481
|
+
if reflowed else ""))
|
|
1482
|
+
return 0
|