@popovych.co/witness 0.19.4 → 0.20.0
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/README.md +55 -8
- package/dist/cli.js +13 -4
- package/dist/config.js +23 -0
- package/dist/context.js +108 -0
- package/dist/drive.js +25 -7
- package/dist/evidence.js +15 -3
- package/dist/gate.js +17 -10
- package/dist/gates/implement.js +3 -1
- package/dist/harness.js +19 -9
- package/dist/install.js +37 -0
- package/dist/journal.js +20 -1
- package/dist/reviewer.js +3 -1
- package/dist/ship.js +20 -1
- package/dist/slice.js +185 -0
- package/dist/steps.js +149 -0
- package/dist/verbs/check.js +45 -2
- package/dist/verbs/decide.js +12 -1
- package/dist/verbs/drive.js +12 -1
- package/dist/verbs/evidence.js +65 -7
- package/dist/verbs/init.js +9 -1
- package/dist/verbs/log.js +18 -2
- package/dist/verbs/next.js +96 -14
- package/dist/verbs/start.js +21 -11
- package/dist/verbs/step-close.js +80 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +7 -5
- package/plugin/hooks/context-core.mjs +230 -0
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/hooks/witness-pi.ts +74 -12
- package/plugin/skills/witness-brainstorm/SKILL.md +2 -1
- package/plugin/skills/witness-decompose/SKILL.md +3 -2
- package/plugin/skills/witness-design/SKILL.md +3 -2
- package/plugin/skills/witness-implement/SKILL.md +45 -15
- package/plugin/skills/witness-plan/SKILL.md +3 -2
- package/plugin/skills/witness-ship/SKILL.md +3 -2
package/README.md
CHANGED
|
@@ -63,12 +63,30 @@ witness is two halves: **six skills**, distributed through the
|
|
|
63
63
|
**engine + guard + dashboard**, which no skills installer can place — `witness init
|
|
64
64
|
--agent <name>` writes those and commits them.
|
|
65
65
|
|
|
66
|
-
**Claude Code** —
|
|
66
|
+
**Claude Code** — the native path:
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
/plugin marketplace add popovych-co/witness # skills, engine, guard and dashboard together
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
**Claude Code, on either route** (the marketplace plugin above, or `init --agent claude-code`),
|
|
73
|
+
also needs the `witness-context` plugin, installed once per machine — then start a new session:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude plugin marketplace add popovych-co/witness && claude plugin install witness-context@witness
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It puts the session's live context reading into every Bash command, which is what lets a
|
|
80
|
+
session hand over at `session.contextLimit` (see Run economics below). The reading comes from
|
|
81
|
+
a hooks module, and only a plugin can carry one. `init --agent claude-code` also declares the
|
|
82
|
+
plugin in `.claude/settings.json`, the way a project names a plugin it needs, but the
|
|
83
|
+
declaration alone does not load it in a headless session (`claude -p`, `witness drive`). A
|
|
84
|
+
session loads the plugin when it starts, so one already running when you install it does not:
|
|
85
|
+
start a new one. The install is a copy: after upgrading witness, `claude plugin update
|
|
86
|
+
witness-context@witness` refreshes it, for the sessions started afterwards. Without the plugin
|
|
87
|
+
witness still runs, on the step budget, and says so; `witness check`, run from a session, reports
|
|
88
|
+
whether that session sees the reading.
|
|
89
|
+
|
|
72
90
|
**Pi** (and any other agent the installer supports):
|
|
73
91
|
|
|
74
92
|
```bash
|
|
@@ -148,6 +166,8 @@ either.
|
|
|
148
166
|
|---|---|
|
|
149
167
|
| `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
|
|
150
168
|
| `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
|
|
169
|
+
| `session.contextLimit` | the share of a session's context window, in percent, at which witness hands the work to a fresh session (integer 1–100, default 20). The harness measures the fill; the CLI decides where the journal carries the position — at a step close, after a revise, at a `witness ship` exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context: unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`, and nothing else hands over (DESIGN.md row 191) |
|
|
170
|
+
| `implement.stepsPerDispatch` | the step budget: steps an implement session closes before it hands over when its fill cannot be read (default 3) — the fallback for `session.contextLimit` |
|
|
151
171
|
| `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
|
|
152
172
|
| `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
|
|
153
173
|
| `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
|
|
@@ -198,7 +218,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
198
218
|
| `next` | the one next action across every effort |
|
|
199
219
|
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"]` | runs every session itself · under autonomy it needs no terminal and reports when nothing else can move. Up to `drive.parallel` targets at once, one session per target; usage limits are waited out; a target past a cap is parked. `--detach` (autonomy only) runs it in the background into `.witness/drive.log`; `--ask` queues an ask first; `--manual` runs serially and asks you at every stop. One drive per repo. A session drive spawned is refused — agents are what drive spawns, never what spawns drive |
|
|
200
220
|
| `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
|
|
201
|
-
| `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
|
|
221
|
+
| `test-evidence <plan-id> --phase red\|green [--step <id>]` / `verify-red <plan-id> [--base <ref>] [--step <id>]` | journal red/green criteria evidence from a worktree; `--step` names the plan step the cycle closes — required before the plan's first implement gate (on a plan with a criteria step), left off for a fix round after it. A green that closes a step prints `context:`, `steps:` and `slice:` |
|
|
222
|
+
| `step-close <plan-id> --step <id>` | close a scaffolding step, which has no red→green to witness; prints `context:`, `steps:` and `slice:` as a closing green does, and closing it again changes nothing |
|
|
202
223
|
| `adopt <path>` | absolve a finished hand-edit into the journal |
|
|
203
224
|
| `abandon <plan-id \| effort-slug…>` | wind a plan/effort down; reverts only specs that effort itself wrote — several efforts go as one act, so a split or an amendment they share goes back together |
|
|
204
225
|
| `dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n>` | journal a session slice's telemetry |
|
|
@@ -236,15 +257,41 @@ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by tw
|
|
|
236
257
|
|
|
237
258
|
## Run economics (operator notes)
|
|
238
259
|
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
A witness session ends at a configured share of its context window:
|
|
261
|
+
`session.contextLimit`, 20% by default (DESIGN.md row 191). The harness measures the
|
|
262
|
+
fill — the `witness-context` plugin on Claude Code and the witness extension on Pi put the
|
|
263
|
+
live reading into every shell command's environment — and witness counts no tokens of its
|
|
264
|
+
own. The CLI decides only where the journal already carries the position: at a step
|
|
265
|
+
close (`slice: end`), after a revise, at a `witness ship` exit and at `next`, by
|
|
266
|
+
printing a `relay:` line. A session never hands over mid-red. Once per session, at the
|
|
267
|
+
limit, one line follows a command's result, so a long step learns it is over:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
witness: context 21% of 1M has reached the 20% limit — finish what you are in (implement: the red→green), then hand over
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Where nothing supplies the reading, witness says so —
|
|
274
|
+
`context: unmeasured (no adapter) — step budget 3` — and an implement session hands over
|
|
275
|
+
after `implement.stepsPerDispatch` steps instead; nothing else hands over.
|
|
276
|
+
|
|
277
|
+
Operator notes, non-load-bearing — the design depends on none of them:
|
|
241
278
|
|
|
279
|
+
- **Mind the window.** The limit is a share, so one warm-up weighs differently by model:
|
|
280
|
+
51k tokens is 5% of a 1M window and 25% of a 200k one. A session that opens over the
|
|
281
|
+
limit is warned (`warn: this session opens at …`) and works one step per slice; on a
|
|
282
|
+
small window, raise `session.contextLimit` or accept a warm-up per step. `witness drive`'s
|
|
283
|
+
defaults were set when a slice was three steps: `drive.sessionTimeoutMs` (1 h) and
|
|
284
|
+
`drive.caps.spawns` (24). A measured slice on a large window can run past an hour, and on a
|
|
285
|
+
200k window every slice is one step, so a plan takes more sessions; raise either if drive
|
|
286
|
+
times out or caps a plan that is still moving.
|
|
242
287
|
- **Keep the host awake.** A suspend longer than an hour mid-run expires the prompt
|
|
243
288
|
cache; on wake the full agent context is re-written at cache-write price. On macOS:
|
|
244
289
|
`witness drive` holds `caffeinate -ims` for its own lifetime (a closed lid on battery
|
|
245
290
|
still sleeps); for a chat session, `caffeinate -dims`, or plug in and disable sleep.
|
|
246
291
|
- **`CLAUDE_CODE_AUTO_COMPACT_WINDOW`** (set at CLI launch) lowers the harness's
|
|
247
|
-
auto-compaction threshold as belt-and-suspenders under the
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
292
|
+
auto-compaction threshold, as belt-and-suspenders under the fill relay. Compaction
|
|
293
|
+
stays the harness's continuity mechanism, never witness's; the variable is documented
|
|
294
|
+
for main sessions only — whether subagents honor it is unverified.
|
|
295
|
+
- The designed mechanisms are the fill relay (`session.contextLimit`, with
|
|
296
|
+
`implement.stepsPerDispatch` for sessions it cannot measure), the loop-width protocol,
|
|
297
|
+
and `dispatch-report` telemetry — see DESIGN.md rows 79–81 and 191.
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { configPath } from './config.js';
|
|
3
|
+
import { journalStamp } from './context.js';
|
|
3
4
|
import { stateFloor } from './floor.js';
|
|
4
5
|
import { primaryRoot } from './gitio.js';
|
|
6
|
+
import { withJournalCtx } from './journal.js';
|
|
5
7
|
import { renderRefusal, v } from './refusal.js';
|
|
6
8
|
import { NPX_LATEST, compareTriple, version } from './version.js';
|
|
7
9
|
export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
@@ -24,6 +26,8 @@ const VERBS = {
|
|
|
24
26
|
'test-evidence': () => import('./verbs/evidence.js'),
|
|
25
27
|
'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
|
|
26
28
|
'dispatch-report': () => import('./verbs/dispatch.js'),
|
|
29
|
+
// Row 191 §4.3. The close of a scaffolding step, the one plan step with no evidence to witness.
|
|
30
|
+
'step-close': () => import('./verbs/step-close.js'),
|
|
27
31
|
adopt: () => import('./verbs/adopt.js'),
|
|
28
32
|
gate: () => import('./verbs/gate.js'),
|
|
29
33
|
decide: () => import('./verbs/decide.js'),
|
|
@@ -77,10 +81,11 @@ const VERB_USAGE = {
|
|
|
77
81
|
ship: 'witness ship <plan-id> [--manual]',
|
|
78
82
|
start: 'witness start <plan-id>',
|
|
79
83
|
status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
|
|
84
|
+
'step-close': 'witness step-close <plan-id> --step <id>',
|
|
80
85
|
sync: 'witness sync',
|
|
81
86
|
trust: 'witness trust <artifact-id> [--yes]',
|
|
82
|
-
'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
|
|
83
|
-
'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
|
|
87
|
+
'test-evidence': 'witness test-evidence <plan-id> --phase red|green [--step <id>]',
|
|
88
|
+
'verify-red': 'witness verify-red <plan-id> [--base <ref>] [--step <id>]',
|
|
84
89
|
};
|
|
85
90
|
// Row 116 moved the body to version.ts, which journal.ts can import without closing a
|
|
86
91
|
// cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
|
|
@@ -132,11 +137,15 @@ export async function main(ctx, argv) {
|
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
}
|
|
140
|
+
// Row 191 §4.8. Every line this command journals carries the session it runs in, read once
|
|
141
|
+
// from this invocation's own env: an in-process `main` (drive's decisions, a test's CLI) is
|
|
142
|
+
// an invocation of its own, with its own env and so its own stamp.
|
|
143
|
+
const stamp = journalStamp(ctx.env);
|
|
135
144
|
if (!verb || verb === 'help' || verb === '--help') {
|
|
136
145
|
if (!verb) {
|
|
137
146
|
const rootRes = primaryRoot(ctx.cwd);
|
|
138
147
|
if (rootRes.ok && existsSync(configPath(rootRes.value))) {
|
|
139
|
-
return (await import('./verbs/dashboard.js')).run(ctx, []);
|
|
148
|
+
return withJournalCtx(stamp, async () => (await import('./verbs/dashboard.js')).run(ctx, []));
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
151
|
ctx.out(usage());
|
|
@@ -153,7 +162,7 @@ export async function main(ctx, argv) {
|
|
|
153
162
|
return EXIT.REFUSED;
|
|
154
163
|
}
|
|
155
164
|
try {
|
|
156
|
-
return await (await load()).run(ctx, rest);
|
|
165
|
+
return await withJournalCtx(stamp, async () => (await load()).run(ctx, rest));
|
|
157
166
|
}
|
|
158
167
|
catch (e) {
|
|
159
168
|
// Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
|
package/dist/config.js
CHANGED
|
@@ -99,6 +99,25 @@ export function resolveImplement(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
return ok({ stepsPerDispatch: val });
|
|
101
101
|
}
|
|
102
|
+
// Row 191 §4.2. The default 20 is the row's premise (§1): once a session's context is more
|
|
103
|
+
// than about 20% full, its output gets worse. A repo decision, so its one home is
|
|
104
|
+
// witness.config.yaml, never config.local.yaml (row 90).
|
|
105
|
+
export const DEFAULT_CONTEXT_LIMIT = 20;
|
|
106
|
+
export function resolveSession(raw) {
|
|
107
|
+
const conf = raw.session;
|
|
108
|
+
if (conf === undefined)
|
|
109
|
+
return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
|
|
110
|
+
if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
|
|
111
|
+
return refuse([v('session', 'invalid', String(conf), 'a map of session settings')]);
|
|
112
|
+
}
|
|
113
|
+
const val = conf.contextLimit;
|
|
114
|
+
if (val === undefined)
|
|
115
|
+
return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
|
|
116
|
+
if (typeof val !== 'number' || !Number.isInteger(val) || val < 1 || val > 100) {
|
|
117
|
+
return refuse([v('session.contextLimit', 'invalid', String(val), 'an integer from 1 to 100 — % of the session\'s context window at which witness hands over to a fresh session')]);
|
|
118
|
+
}
|
|
119
|
+
return ok({ contextLimit: val });
|
|
120
|
+
}
|
|
102
121
|
// D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
|
|
103
122
|
// and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
|
|
104
123
|
// once, a starved one kills a working e2e run and hands the human a false `spawn-timeout`
|
|
@@ -356,6 +375,9 @@ export function loadConfig(root) {
|
|
|
356
375
|
const implement = resolveImplement(obj);
|
|
357
376
|
if (!implement.ok)
|
|
358
377
|
return refuse(implement.violations);
|
|
378
|
+
const session = resolveSession(obj);
|
|
379
|
+
if (!session.ok)
|
|
380
|
+
return refuse(session.violations);
|
|
359
381
|
const gates = resolveGates(obj);
|
|
360
382
|
if (!gates.ok)
|
|
361
383
|
return refuse(gates.violations);
|
|
@@ -375,6 +397,7 @@ export function loadConfig(root) {
|
|
|
375
397
|
paths: paths.value,
|
|
376
398
|
docs: docs.value,
|
|
377
399
|
implement: implement.value,
|
|
400
|
+
session: session.value,
|
|
378
401
|
gates: gates.value,
|
|
379
402
|
drive: drive.value,
|
|
380
403
|
autonomy: autonomy.value && !uncommitted,
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { HARNESSES, loadHarness, resolveDriver } from './harness.js';
|
|
3
|
+
// Row 191 §4.1. Before every shell command the harness adapter puts the session's live fill
|
|
4
|
+
// here, as the harness itself measures it:
|
|
5
|
+
// {"session":"<harness session id>","tokens":31627,"window":1000000,"percent":3}
|
|
6
|
+
// The CLI counts nothing and keeps no table of windows (§2: a second ledger would drift from
|
|
7
|
+
// what the harness shows), and it ignores `percent`, so the two harnesses cannot round apart.
|
|
8
|
+
export const CONTEXT_ENV = 'WITNESS_CONTEXT';
|
|
9
|
+
// The session is a fact about the process running this command, not about the repo, so it
|
|
10
|
+
// comes from the driver ladder without the config rung: detection, else the default harness.
|
|
11
|
+
// The variable is the registry's `session` facet, never spelled here.
|
|
12
|
+
export function sessionIdOf(env) {
|
|
13
|
+
const driver = resolveDriver(env, {});
|
|
14
|
+
if (!driver.ok)
|
|
15
|
+
return undefined;
|
|
16
|
+
const id = env[driver.value.harness.session];
|
|
17
|
+
return id === undefined || id === '' ? undefined : id;
|
|
18
|
+
}
|
|
19
|
+
// Row 191 §4.8: the journal is committed, so it carries this hash and never the id itself.
|
|
20
|
+
export function sessionHash(id) {
|
|
21
|
+
return createHash('sha256').update(id).digest('hex').slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
const unmeasured = (reason) => ({ measured: false, reason });
|
|
24
|
+
const isInt = (x) => typeof x === 'number' && Number.isInteger(x);
|
|
25
|
+
// Row 191 §4.1. Trusted only when it parses, carries integer counts with tokens ≤ window, and
|
|
26
|
+
// names the session the harness names. That last check is what makes an inherited value
|
|
27
|
+
// harmless: a drive launched from a chat session's shell carries the chat's reading, and its
|
|
28
|
+
// child runs as a session of its own. The order is the reasons' precedence: a broken value is
|
|
29
|
+
// malformed whoever wrote it, and only this session's own value can say it has no reading yet.
|
|
30
|
+
export function readFill(env) {
|
|
31
|
+
const raw = env[CONTEXT_ENV];
|
|
32
|
+
if (raw === undefined || raw === '')
|
|
33
|
+
return unmeasured('no adapter');
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return unmeasured('malformed');
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
42
|
+
return unmeasured('malformed');
|
|
43
|
+
const { session, tokens, window } = parsed;
|
|
44
|
+
if (typeof session !== 'string' || session === '')
|
|
45
|
+
return unmeasured('malformed');
|
|
46
|
+
if (!isInt(window) || window < 1)
|
|
47
|
+
return unmeasured('malformed');
|
|
48
|
+
if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || tokens > window)) {
|
|
49
|
+
return unmeasured('malformed');
|
|
50
|
+
}
|
|
51
|
+
if (session !== sessionIdOf(env))
|
|
52
|
+
return unmeasured('stale');
|
|
53
|
+
// P7: Claude Code sends no `tokens` before the session's first response, Pi sends 0 then,
|
|
54
|
+
// and Pi sends null right after a compaction. Each is a reading not taken yet.
|
|
55
|
+
if (!isInt(tokens) || tokens === 0)
|
|
56
|
+
return unmeasured('no reading yet');
|
|
57
|
+
return { measured: true, reading: { session, tokens, window } };
|
|
58
|
+
}
|
|
59
|
+
// Row 191 §4.8: what this invocation's journal lines carry. The session whenever the harness
|
|
60
|
+
// names one, even unmeasured, because the step-budget fallback counts a session's steps by
|
|
61
|
+
// it; the reading only when it is trusted. Outside a harness session, nothing.
|
|
62
|
+
export function journalStamp(env) {
|
|
63
|
+
const id = sessionIdOf(env);
|
|
64
|
+
if (id === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const session = sessionHash(id);
|
|
67
|
+
const fill = readFill(env);
|
|
68
|
+
return fill.measured ? { session, tokens: fill.reading.tokens, window: fill.reading.window } : { session };
|
|
69
|
+
}
|
|
70
|
+
// Row 191 §4.9. Drive is a scheduler, not a session. Started from a chat session's shell, it
|
|
71
|
+
// inherits that session's reading and the harness's session variable, and neither is a fact
|
|
72
|
+
// about drive or about the sessions it spawns: each harness names its own session on its own
|
|
73
|
+
// commands (P1, and pi's bash tool). Every harness's variable goes, whichever drive spawns,
|
|
74
|
+
// read from the registry so none is spelled here.
|
|
75
|
+
export function withoutSession(env) {
|
|
76
|
+
const out = { ...env };
|
|
77
|
+
delete out[CONTEXT_ENV];
|
|
78
|
+
for (const name of HARNESSES) {
|
|
79
|
+
const harness = loadHarness(name);
|
|
80
|
+
if (harness.ok)
|
|
81
|
+
delete out[harness.value.session];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
// Row 191 §4.5. The decision is `reached`, in integer tokens; the display is floored, so the
|
|
86
|
+
// two agree: a printed 19% never hands over and a printed 20% always does. Rounding would
|
|
87
|
+
// print 20% beside a session that carries on.
|
|
88
|
+
//
|
|
89
|
+
// pctOf and windowLabel have a twin in plugin/hooks/context-core.mjs, for the adapters' warning:
|
|
90
|
+
// the CLI must not import plugin/ at runtime, so tests/context-core.test.ts holds the two equal.
|
|
91
|
+
export function pctOf(tokens, window) {
|
|
92
|
+
return Math.floor(tokens * 100 / window);
|
|
93
|
+
}
|
|
94
|
+
export function reached(tokens, window, limit) {
|
|
95
|
+
return tokens * 100 >= limit * window;
|
|
96
|
+
}
|
|
97
|
+
// The window a fill is a share of, the way people say it: 1M, 200k. Display only: a window that
|
|
98
|
+
// is not round reads approximately (1048576 is 1M), and the percentage still uses the exact one.
|
|
99
|
+
// Thousands are rounded first, so a window that rounds up to a thousand of them reads 1M, never
|
|
100
|
+
// 1000k.
|
|
101
|
+
export function windowLabel(window) {
|
|
102
|
+
if (window < 1_000)
|
|
103
|
+
return String(window);
|
|
104
|
+
const thousands = Math.round(window / 1_000);
|
|
105
|
+
if (thousands < 1_000)
|
|
106
|
+
return `${thousands}k`;
|
|
107
|
+
return `${Math.round(window / 100_000) / 10}M`;
|
|
108
|
+
}
|
package/dist/drive.js
CHANGED
|
@@ -4,7 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { EXIT, main } from './cli.js';
|
|
6
6
|
import { loadConfig, loadLocalConfig } from './config.js';
|
|
7
|
-
import {
|
|
7
|
+
import { withoutSession } from './context.js';
|
|
8
|
+
import { detectionVariables, resolveJudge } from './harness.js';
|
|
8
9
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
9
10
|
import { findById, loadCanon } from './scan.js';
|
|
10
11
|
import { resolveSpawn } from './spawn.js';
|
|
@@ -16,6 +17,7 @@ import { unmetNeeds } from './needs.js';
|
|
|
16
17
|
import { capHit, parkAtCap } from './caps.js';
|
|
17
18
|
import { effortStreams, readStream } from './journal.js';
|
|
18
19
|
import { effortOf } from './reviewed.js';
|
|
20
|
+
import { vacuousEvidence } from './steps.js';
|
|
19
21
|
import { pendingTxn } from './txn.js';
|
|
20
22
|
import { duration, limitIn, realClock, sessionLimit, SINGLE_WAIT_MAX_MS, TOTAL_WAIT_MAX_MS, waitFor, } from './usage.js';
|
|
21
23
|
import { keepAwake } from './awake.js';
|
|
@@ -145,12 +147,26 @@ export async function spawnSession(step, cfg, ctx, spawnN, opts = {}) {
|
|
|
145
147
|
// a scripted fake that performs real CLI acts. Never documented for users — a spawn
|
|
146
148
|
// binary chosen by ambient env is exactly the identity confusion row 90 removed.
|
|
147
149
|
const bin = ctx.env.WITNESS_DRIVE_AGENT_BIN ?? cmd;
|
|
148
|
-
// Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
|
|
149
150
|
// Row 180: the marker is the child's target and its recursion guard in one.
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
//
|
|
152
|
+
// Row 191 §4.9: a drive started from a chat session's shell inherits that session's reading
|
|
153
|
+
// and session variable, and the child is a session of its own that its harness names itself.
|
|
154
|
+
// The session check already makes a stale reading harmless; leaving both out is the second
|
|
155
|
+
// guard. The verb drops them on entry too; this holds for any other caller.
|
|
156
|
+
//
|
|
157
|
+
// Every harness's detection variable goes too, from the child alone (final review minor 5): its
|
|
158
|
+
// harness sets its own, and an inherited one named the chat's harness to a child of another — a
|
|
159
|
+
// claude child of a drive typed in a Pi chat detected Pi, read its own reading as stale and
|
|
160
|
+
// relayed with /new. Drive keeps them: the judge above, whose worker this is, may be detection's.
|
|
161
|
+
const inherited = withoutSession(ctx.env);
|
|
162
|
+
for (const variable of detectionVariables())
|
|
163
|
+
delete inherited[variable];
|
|
164
|
+
const childEnv = {
|
|
165
|
+
...inherited, ...env, WITNESS_BIN: localBin(ctx), [DRIVE_ENV]: step.target ?? TARGETLESS,
|
|
152
166
|
[DRIVE_DEADLINE_ENV]: String(t0 + cfg.drive.sessionTimeoutMs),
|
|
153
|
-
}
|
|
167
|
+
};
|
|
168
|
+
// Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
|
|
169
|
+
const run = resolveSpawn(bin, args, childEnv);
|
|
154
170
|
if (!run.ok) {
|
|
155
171
|
renderRefusal(run.violations).forEach((l) => ctx.err(l));
|
|
156
172
|
return end('spawn-failed');
|
|
@@ -259,8 +275,10 @@ const STREAM_PREFIX = /^\[\d+ [^\]]*\] /;
|
|
|
259
275
|
// What `mergeOwnPr` prints when it leaves the merge to a human (ship.ts), and nothing else.
|
|
260
276
|
const MERGE_LINE = /^ship: "?merge PR #(\d+) on GitHub when ready/;
|
|
261
277
|
const streamLines = (root, id) => readStream(root, id).length;
|
|
262
|
-
// A refused write is the CLI saying no: it moves nothing (F5).
|
|
263
|
-
|
|
278
|
+
// A refused write is the CLI saying no: it moves nothing (F5). Nor does a vacuous red (row 191,
|
|
279
|
+
// final review I3): a session that only re-runs one has done nothing, and counted, it read as
|
|
280
|
+
// progress, so the target was respawned until its spawn cap.
|
|
281
|
+
const moved = (e) => e.t !== 'write-refused' && !vacuousEvidence(e);
|
|
264
282
|
// Row 181. Did anything happen for THIS target? A whole-journal count is satisfied by any
|
|
265
283
|
// other lane's progress, so a stalled flow beside a busy one would spawn until the ceiling.
|
|
266
284
|
// A target's key is its line, its stream, and the entries in its owning effort's stream that
|
package/dist/evidence.js
CHANGED
|
@@ -86,6 +86,8 @@ export async function runSpecTests(runRoot, ctx, parentId, trustRoot, opts = {})
|
|
|
86
86
|
}
|
|
87
87
|
return ok({ runner: 'full-suite', tests, allOk: tests.every((t) => t.ok) });
|
|
88
88
|
}
|
|
89
|
+
// Row 191 §4.3. `step` is the plan step this cycle closes; absent on a cycle that is no plan
|
|
90
|
+
// step's (a fix round after a gate).
|
|
89
91
|
export function journalEvidence(stateRoot, planId, phase, run, extra = {}) {
|
|
90
92
|
const entry = { t: 'test-evidence', artifact: planId, phase, runner: run.runner, tests: run.tests, ...extra };
|
|
91
93
|
const marker = {
|
|
@@ -169,7 +171,10 @@ export function changedFiles(runRoot, base) {
|
|
|
169
171
|
return [...new Set([...tracked, ...untracked])].sort();
|
|
170
172
|
}
|
|
171
173
|
const inCommit = (runRoot, ref, rel) => tryGit(runRoot, 'cat-file', '-e', `${ref}:${rel}`).ok;
|
|
172
|
-
export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base) {
|
|
174
|
+
export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base, step) {
|
|
175
|
+
// Row 191 §4.3: a reconstructed cycle closes a plan step as a witnessed one does, so both of
|
|
176
|
+
// its entries name the step.
|
|
177
|
+
const extra = { reconstructed: true, ...(step !== undefined ? { step } : {}) };
|
|
173
178
|
const files = changedFiles(runRoot, base);
|
|
174
179
|
if (!files.some(isTestPath)) {
|
|
175
180
|
return refuse([v('diff', 'no-test-changes', `${files.length} changed files, none are tests`, 'a diff that adds or modifies tagged tests')]);
|
|
@@ -210,7 +215,7 @@ export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base)
|
|
|
210
215
|
else if (existsSync(join(runRoot, rel)))
|
|
211
216
|
rmSync(join(runRoot, rel));
|
|
212
217
|
}
|
|
213
|
-
red = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'red',
|
|
218
|
+
red = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'red', extra);
|
|
214
219
|
}
|
|
215
220
|
finally {
|
|
216
221
|
for (const rel of trackedNonTest) {
|
|
@@ -232,7 +237,14 @@ export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base)
|
|
|
232
237
|
}
|
|
233
238
|
if (!red.ok)
|
|
234
239
|
return red;
|
|
235
|
-
|
|
240
|
+
// Final review I3. A vacuous red is a finding, not a cycle: the green still runs, since it is
|
|
241
|
+
// what confirms the restore, but journals nothing. Journaled, it read as an act of the session
|
|
242
|
+
// and as drive's progress, so a session that opened over the limit and only re-ran this handed
|
|
243
|
+
// over forever, and drive respawned it to its cap. A step an earlier genuine red opened is
|
|
244
|
+
// closed by the green `next` routes for that open red, never by this reconstruction.
|
|
245
|
+
const green = red.value.allOk
|
|
246
|
+
? await runSpecTests(runRoot, ctx, parentId, stateRoot)
|
|
247
|
+
: await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'green', extra);
|
|
236
248
|
if (!green.ok)
|
|
237
249
|
return green;
|
|
238
250
|
return ok({ redOk: !red.value.allOk, greenOk: green.value.allOk, vacuous: red.value.allOk });
|
package/dist/gate.js
CHANGED
|
@@ -24,6 +24,7 @@ import { gateSettled, owedRun } from './verbs/next.js';
|
|
|
24
24
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
25
25
|
import { recommend, renderDecision } from './recommend.js';
|
|
26
26
|
import { autonomyLines } from './autonomy.js';
|
|
27
|
+
import { reportHandover, slicesLine } from './slice.js';
|
|
27
28
|
const GATES = new Map();
|
|
28
29
|
export function registerGate(spec) { GATES.set(spec.gate, spec); }
|
|
29
30
|
export function gateSpec(name) { return GATES.get(name); }
|
|
@@ -114,7 +115,7 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
|
|
|
114
115
|
// nothing-pending and the exits line's single re-gate act is the whole live set (D131).
|
|
115
116
|
function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
|
|
116
117
|
// D154. Resolved here rather than threaded through four call sites, on this file's own
|
|
117
|
-
// precedent (`
|
|
118
|
+
// precedent (`printSliceShape` loads canon itself so the entry-renderers stay
|
|
118
119
|
// pure formatters). Best-effort: a root that will not resolve costs the trust variants,
|
|
119
120
|
// never the block.
|
|
120
121
|
const rootR = primaryRoot(ctx.cwd);
|
|
@@ -127,10 +128,11 @@ function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
|
|
|
127
128
|
else
|
|
128
129
|
ctx.out(cmd('help', liveExits(gate, target, entries, stale, upstream)));
|
|
129
130
|
}
|
|
130
|
-
// Row 79: the human approving a plan sees the run's shape before it starts.
|
|
131
|
-
//
|
|
131
|
+
// Row 79: the human approving a plan sees the run's shape before it starts. Row 191 §4.10:
|
|
132
|
+
// under a measured harness that shape is the rule that will cut the run into slices, not a
|
|
133
|
+
// count of them. Display only — no lens, no threshold; loads config/canon itself so the
|
|
132
134
|
// entry-renderers stay pure formatters.
|
|
133
|
-
export function
|
|
135
|
+
export function printSliceShape(ctx, root, gateName, target) {
|
|
134
136
|
if (gateName !== 'plan')
|
|
135
137
|
return;
|
|
136
138
|
const cfg = loadConfig(root);
|
|
@@ -139,9 +141,7 @@ export function printDispatchArithmetic(ctx, root, gateName, target) {
|
|
|
139
141
|
const doc = findById(loadCanon(root), target);
|
|
140
142
|
if (!doc || doc.meta.type !== 'plan')
|
|
141
143
|
return;
|
|
142
|
-
|
|
143
|
-
const steps = (doc.meta.steps ?? []).length;
|
|
144
|
-
ctx.out(kv('dispatches', `${steps} step(s) ≈ ${Math.ceil(steps / budget)} dispatch(es) at budget ${budget}`));
|
|
144
|
+
ctx.out(kv('slices', slicesLine(cfg.value)));
|
|
145
145
|
}
|
|
146
146
|
// Row 173. The gate, then — under `autonomy: on` and not a `--manual` run — the settling of
|
|
147
147
|
// the stop it produced or re-showed, in the same invocation, so a chat session and drive
|
|
@@ -161,7 +161,14 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
161
161
|
// A dynamic import: settle.ts reaches decide.ts, whose gates/index.js import would close a
|
|
162
162
|
// static cycle through this module — each gate's registerGate would run before GATES exists.
|
|
163
163
|
const { settleStop, settledCode } = await import('./settle.js');
|
|
164
|
-
|
|
164
|
+
const settled = await settleStop(ctx, rootR.value, cfgR.value, gateName, target);
|
|
165
|
+
// Row 191 §4.4. A revise settled here is handed to the session that ran the gate, so this is
|
|
166
|
+
// where a session at its limit hands it to a fresh one. Ship passes `handover: false`: its run
|
|
167
|
+
// hands over once, at its own exit (Plan's call 7).
|
|
168
|
+
const revised = settled.kind === 'decided' && (settled.decision === 'revise' || settled.decision === 'revise-upstream');
|
|
169
|
+
if (revised && flags.handover !== false)
|
|
170
|
+
reportHandover(ctx, rootR.value, cfgR.value, { guard: false });
|
|
171
|
+
return settledCode(code, settled);
|
|
165
172
|
}
|
|
166
173
|
async function judge(ctx, gateName, target, flags) {
|
|
167
174
|
const rootR = primaryRoot(ctx.cwd);
|
|
@@ -340,7 +347,7 @@ async function judge(ctx, gateName, target, flags) {
|
|
|
340
347
|
entries, upstream: upstreamId,
|
|
341
348
|
...(kind.entry.outcome === 'stopped' && leftToTail(entries) ? { help: false } : {}),
|
|
342
349
|
});
|
|
343
|
-
|
|
350
|
+
printSliceShape(ctx, root, spec.gate, target);
|
|
344
351
|
return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
|
|
345
352
|
}
|
|
346
353
|
if (kind.kind === 'changed-nothing') {
|
|
@@ -565,7 +572,7 @@ async function judge(ctx, gateName, target, flags) {
|
|
|
565
572
|
entries: after, upstream: upstreamId,
|
|
566
573
|
...(entry.outcome === 'stopped' && leftToTail(after) ? { help: false } : {}),
|
|
567
574
|
});
|
|
568
|
-
|
|
575
|
+
printSliceShape(ctx, root, spec.gate, target);
|
|
569
576
|
for (const d of discharged)
|
|
570
577
|
ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
|
|
571
578
|
if (input.repin)
|
package/dist/gates/implement.js
CHANGED
|
@@ -53,7 +53,9 @@ function resolveDesignCaptures(stateRoot, wt, planId) {
|
|
|
53
53
|
out.push({ name: c.name, path });
|
|
54
54
|
}
|
|
55
55
|
if (out.length === 0) {
|
|
56
|
-
|
|
56
|
+
// Row 191: refusing journals no gate run, so on a plan's first gate the re-run it names is
|
|
57
|
+
// still a step's cycle, and `test-evidence` requires the step there.
|
|
58
|
+
return refuse([v('screens', 'screens-matched-nothing', `0 verified captures for the latest green cycle of ${planId}`, 'a UI plan (design-from pinned) must witness ≥1 screenshot — name captures in the browser test, then re-run: witness test-evidence <plan> --phase green --step <id>')]);
|
|
57
59
|
}
|
|
58
60
|
return ok(out);
|
|
59
61
|
}
|
package/dist/harness.js
CHANGED
|
@@ -74,6 +74,7 @@ const REGISTRY = {
|
|
|
74
74
|
name: 'claude-code',
|
|
75
75
|
launch: 'claude',
|
|
76
76
|
relay: '/clear',
|
|
77
|
+
session: 'CLAUDE_CODE_SESSION_ID',
|
|
77
78
|
settings: '.claude/settings.json',
|
|
78
79
|
bundled: true,
|
|
79
80
|
payload: [
|
|
@@ -114,13 +115,15 @@ const REGISTRY = {
|
|
|
114
115
|
launch: 'pi',
|
|
115
116
|
defaultProvider: 'anthropic',
|
|
116
117
|
relay: '/new',
|
|
118
|
+
session: 'PI_SESSION_ID',
|
|
117
119
|
bundled: false,
|
|
118
120
|
payload: [
|
|
119
121
|
{ from: 'plugin/commands/witness.md', to: '.pi/prompts/witness.md' },
|
|
120
122
|
{ from: 'plugin/hooks/canon-guard.mjs', to: '.pi/extensions/canon-guard.mjs' },
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
123
|
+
{ from: 'plugin/hooks/context-core.mjs', to: '.pi/extensions/context-core.mjs' },
|
|
124
|
+
// Source-tree sibling of both cores, exactly as it is in .pi/extensions/: the
|
|
125
|
+
// adapter imports each with ONE static relative specifier, and a specifier that
|
|
126
|
+
// only resolves after install is a trap no unit test can hold.
|
|
124
127
|
{ from: 'plugin/hooks/witness-pi.ts', to: '.pi/extensions/witness.ts' },
|
|
125
128
|
{ from: 'plugin/hooks/session-dashboard.sh', to: '.pi/extensions/session-dashboard.sh' },
|
|
126
129
|
],
|
|
@@ -166,13 +169,20 @@ export function loadHarness(name) {
|
|
|
166
169
|
return ok(harness);
|
|
167
170
|
}
|
|
168
171
|
// Detection tests PRESENCE: neither CLAUDECODE=1 nor PI_CODING_AGENT=true is a documented
|
|
169
|
-
// value contract.
|
|
172
|
+
// value contract. Each harness sets its variable on the commands it runs; read in this order.
|
|
173
|
+
const DETECTION = [
|
|
174
|
+
{ variable: 'PI_CODING_AGENT', name: 'pi' },
|
|
175
|
+
{ variable: 'CLAUDECODE', name: 'claude-code' },
|
|
176
|
+
];
|
|
170
177
|
function detectedRung(env) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
const hit = DETECTION.find((d) => env[d.variable] !== undefined);
|
|
179
|
+
return hit === undefined ? undefined : { source: 'detected', name: hit.name };
|
|
180
|
+
}
|
|
181
|
+
// Row 191 §4.9 (final review minor 5). Every harness's detection variable: what a session drive
|
|
182
|
+
// spawns must not inherit, since its own harness sets its own on the commands it runs, and one
|
|
183
|
+
// inherited from the chat drive was typed in would name that chat's harness to a child of another.
|
|
184
|
+
export function detectionVariables() {
|
|
185
|
+
return DETECTION.map((d) => d.variable);
|
|
176
186
|
}
|
|
177
187
|
function declaredRung(raw) {
|
|
178
188
|
const configured = raw.harness;
|
package/dist/install.js
CHANGED
|
@@ -156,6 +156,28 @@ export function installAllHomes(root, harness) {
|
|
|
156
156
|
function hasCommand(entries, command) {
|
|
157
157
|
return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
|
|
158
158
|
}
|
|
159
|
+
// Row 191 §4.11. The context reading comes from a hooks module, and only a plugin carries one:
|
|
160
|
+
// witness-context, listed in witness's own GitHub marketplace beside the full plugin, which does
|
|
161
|
+
// not carry it (two copies would each latch and warn). P9 (2026-10-22): a headless session
|
|
162
|
+
// skipped a plugin the project's settings declared while its marketplace was not registered on
|
|
163
|
+
// the machine, and loaded one installed at user scope from the registered marketplace, at the
|
|
164
|
+
// root and in a worktree. So every machine installs it once: CONTEXT_INSTALL, which init and
|
|
165
|
+
// check print.
|
|
166
|
+
const WITNESS_REPO = 'popovych-co/witness';
|
|
167
|
+
export const WITNESS_MARKETPLACE = 'witness';
|
|
168
|
+
export const CONTEXT_PLUGIN = `witness-context@${WITNESS_MARKETPLACE}`;
|
|
169
|
+
// No comma: check prints it through toon's kv, which quotes a value holding one.
|
|
170
|
+
export const CONTEXT_INSTALL = `claude plugin marketplace add ${WITNESS_REPO} && claude plugin install ${CONTEXT_PLUGIN}`;
|
|
171
|
+
// The settings map `current` with `name` added, or undefined when it needs nothing: an entry of
|
|
172
|
+
// that name is the human's, and so is a value that is not a map at all — theirs to fix, never
|
|
173
|
+
// ours to replace.
|
|
174
|
+
function withEntry(current, name, value) {
|
|
175
|
+
if (current === undefined)
|
|
176
|
+
return { [name]: value };
|
|
177
|
+
if (typeof current !== 'object' || current === null || Array.isArray(current))
|
|
178
|
+
return undefined;
|
|
179
|
+
return Object.hasOwn(current, name) ? undefined : { ...current, [name]: value };
|
|
180
|
+
}
|
|
159
181
|
// Merge, never replace: .claude/settings.json is the human's file and may already carry
|
|
160
182
|
// their own hooks, model pin and permissions. We append only what is missing, matched
|
|
161
183
|
// by command string, and preserve every other key byte for byte.
|
|
@@ -199,10 +221,25 @@ export function mergeSettings(existing) {
|
|
|
199
221
|
changed = true;
|
|
200
222
|
}
|
|
201
223
|
}
|
|
224
|
+
// Row 191 §4.11. The repo declares the plugin its sessions read the fill from, the way Claude
|
|
225
|
+
// Code lets a project name a plugin it needs: its marketplace, and the plugin enabled. Headless
|
|
226
|
+
// the entry is skipped, harmlessly, until the machine has registered the marketplace (P9);
|
|
227
|
+
// whether a session's trust prompt offers the install from it is unprobed, owed with §6's
|
|
228
|
+
// chat-mode check. Append-what's-missing, as above: an entry the human has under either name
|
|
229
|
+
// is theirs — an explicit `false` turns the plugin off on purpose, and their `witness`
|
|
230
|
+
// marketplace may point at a local checkout.
|
|
231
|
+
const marketplaces = withEntry(doc.extraKnownMarketplaces, WITNESS_MARKETPLACE, { source: { source: 'github', repo: WITNESS_REPO } });
|
|
232
|
+
const plugins = withEntry(doc.enabledPlugins, CONTEXT_PLUGIN, true);
|
|
233
|
+
if (marketplaces !== undefined || plugins !== undefined)
|
|
234
|
+
changed = true;
|
|
202
235
|
if (!changed)
|
|
203
236
|
return { text: existing ?? '', changed: false };
|
|
204
237
|
doc.hooks = { ...hooks, PreToolUse: pre, SessionStart: start };
|
|
205
238
|
doc.permissions = { ...perms, allow };
|
|
239
|
+
if (marketplaces !== undefined)
|
|
240
|
+
doc.extraKnownMarketplaces = marketplaces;
|
|
241
|
+
if (plugins !== undefined)
|
|
242
|
+
doc.enabledPlugins = plugins;
|
|
206
243
|
return { text: `${JSON.stringify(doc, null, 2)}\n`, changed: true };
|
|
207
244
|
}
|
|
208
245
|
// The config rung of Decision 5's ladder, and rung ONE of Decision 105's judgment ladder.
|