@appchy/jarvis 0.1.36 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/bin/config-change.mjs +1 -1
  2. package/bin/jarvis.mjs +8 -12
  3. package/bin/pre-tool-use.mjs +2 -2
  4. package/bin/session-start.mjs +1 -1
  5. package/bin/stop.mjs +1 -1
  6. package/bin/user-prompt-submit.mjs +1 -1
  7. package/dist/bin.js +10104 -37174
  8. package/dist/bin.js.map +1 -1
  9. package/dist/data/backends.mjs +469 -0
  10. package/dist/data/chunk-7REP35VA.mjs +462 -0
  11. package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
  12. package/dist/data/chunk-AYOJSS2F.mjs +72 -0
  13. package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
  14. package/dist/data/chunk-YWSWQEJN.mjs +138 -0
  15. package/dist/data/embedders.mjs +80 -0
  16. package/dist/data/finders.mjs +35 -0
  17. package/dist/data/index.mjs +137 -0
  18. package/dist/data/linkers.mjs +83 -0
  19. package/dist/data/mcp.mjs +2624 -0
  20. package/dist/data/persistences.mjs +8 -0
  21. package/dist/data/rerankers.mjs +52 -0
  22. package/dist/data/stores.mjs +38 -0
  23. package/dist/hooks/config-change.js +7 -29
  24. package/dist/hooks/config-change.js.map +1 -1
  25. package/dist/hooks/pre-tool-use.js +129 -32
  26. package/dist/hooks/pre-tool-use.js.map +1 -1
  27. package/dist/hooks/session-start.js +72 -37
  28. package/dist/hooks/session-start.js.map +1 -1
  29. package/dist/hooks/stop.js +140 -32
  30. package/dist/hooks/stop.js.map +1 -1
  31. package/dist/hooks/user-prompt-submit.js +7 -29
  32. package/dist/hooks/user-prompt-submit.js.map +1 -1
  33. package/harness/assets/architecture-template.md +53 -0
  34. package/harness/assets/design-template.md +89 -0
  35. package/harness/assets/domains/architecture.md +43 -0
  36. package/harness/assets/domains/commercial.md +42 -0
  37. package/harness/assets/domains/design.md +42 -0
  38. package/harness/assets/domains/legal.md +43 -0
  39. package/harness/assets/domains/operations.md +39 -0
  40. package/harness/assets/domains/product.md +43 -0
  41. package/harness/assets/domains/quality.md +43 -0
  42. package/harness/assets/domains/security.md +44 -0
  43. package/harness/assets/domains/support.md +45 -0
  44. package/harness/assets/epic-template.md +85 -0
  45. package/harness/assets/feature-template.md +86 -0
  46. package/harness/assets/handoff-template.md +26 -0
  47. package/harness/assets/task-template.md +38 -0
  48. package/harness/assets/version-template.md +43 -0
  49. package/harness/harness/__init__.py +7 -0
  50. package/harness/harness/align.py +582 -0
  51. package/harness/harness/architecture.py +254 -0
  52. package/harness/harness/autonomy.py +374 -0
  53. package/harness/harness/branches.py +408 -0
  54. package/harness/harness/config.py +1482 -0
  55. package/harness/harness/coverage.py +199 -0
  56. package/harness/harness/epic.py +220 -0
  57. package/harness/harness/events.py +153 -0
  58. package/harness/harness/extend.py +99 -0
  59. package/harness/harness/frontmatter.py +218 -0
  60. package/harness/harness/gate.py +591 -0
  61. package/harness/harness/generate.py +267 -0
  62. package/harness/harness/git.py +775 -0
  63. package/harness/harness/ids.py +140 -0
  64. package/harness/harness/kickoff.py +231 -0
  65. package/harness/harness/lint.py +505 -0
  66. package/harness/harness/model.py +364 -0
  67. package/harness/harness/peers.py +187 -0
  68. package/harness/harness/product.py +29 -0
  69. package/harness/harness/registry.py +382 -0
  70. package/harness/harness/report.py +227 -0
  71. package/harness/harness/safety.py +387 -0
  72. package/harness/harness/scaffold.py +129 -0
  73. package/harness/harness/shard.py +63 -0
  74. package/harness/harness/shift.py +348 -0
  75. package/harness/harness/task.py +507 -0
  76. package/harness/harness/tree.py +258 -0
  77. package/harness/harness/version.py +305 -0
  78. package/harness/harness/wrap.py +217 -0
  79. package/harness/hooks/guard.py +259 -0
  80. package/harness/presets/appchy/PRESET.md +717 -0
  81. package/harness/presets/appchy/references/artifacts.md +539 -0
  82. package/harness/presets/appchy/references/graph.md +154 -0
  83. package/harness/presets/appchy/references/operations.md +444 -0
  84. package/harness/presets/appchy/references/research.md +216 -0
  85. package/harness/schema/work.config.schema.json +401 -0
  86. package/harness/test_work.py +5002 -0
  87. package/harness/work.py +534 -0
  88. package/package.json +34 -40
  89. package/bin/config-change.dev.mjs +0 -17
  90. package/bin/jarvis-dev.mjs +0 -30
  91. package/bin/pre-tool-use.dev.mjs +0 -23
  92. package/bin/session-start.dev.mjs +0 -17
  93. package/bin/stop.dev.mjs +0 -17
  94. package/bin/user-prompt-submit.dev.mjs +0 -17
  95. package/dev/bin.js +0 -38718
  96. package/dev/bin.js.map +0 -1
  97. package/dev/env.json +0 -1
  98. package/dev/hooks/config-change.js +0 -110
  99. package/dev/hooks/config-change.js.map +0 -1
  100. package/dev/hooks/pre-tool-use.js +0 -120
  101. package/dev/hooks/pre-tool-use.js.map +0 -1
  102. package/dev/hooks/session-start.js +0 -115
  103. package/dev/hooks/session-start.js.map +0 -1
  104. package/dev/hooks/stop.js +0 -112
  105. package/dev/hooks/stop.js.map +0 -1
  106. package/dev/hooks/user-prompt-submit.js +0 -111
  107. package/dev/hooks/user-prompt-submit.js.map +0 -1
  108. package/dist/env.json +0 -1
@@ -0,0 +1,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