@popovych.co/witness 0.4.1 → 0.5.1
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 +19 -6
- package/dist/calibrate.js +14 -14
- package/dist/config.js +69 -0
- package/dist/drift.js +14 -2
- package/dist/gate.js +12 -2
- package/dist/gates/ship.js +14 -10
- package/dist/gitio.js +28 -0
- package/dist/harness.js +26 -11
- package/dist/opener.js +7 -6
- package/dist/reviewer.js +3 -3
- package/dist/verbs/calibrate.js +16 -3
- package/dist/verbs/check.js +20 -2
- package/dist/verbs/design.js +8 -3
- package/dist/verbs/init.js +7 -6
- package/dist/verbs/next.js +37 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +2 -2
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -101,13 +101,26 @@ either.
|
|
|
101
101
|
|
|
102
102
|
| Key | Meaning |
|
|
103
103
|
|---|---|
|
|
104
|
-
| `harness: claude-code \| pi` | fallback used only when detection cannot answer. Order: `
|
|
104
|
+
| `harness: claude-code \| pi` | fallback used only when detection cannot answer. Order: `PI_CODING_AGENT` → `CLAUDECODE` → `harness:` → `claude-code` |
|
|
105
|
+
| `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
|
|
106
|
+
|
|
107
|
+
There is no `provider:` key. `witness gate` spawns the RESOLVED harness's headless
|
|
108
|
+
mode for every reviewer (Decision 88): claude-code renders bare Anthropic ids, pi
|
|
109
|
+
renders `provider/model[:thinking]` with the provider witness knows it needs. The pi
|
|
110
|
+
reviewer runs hermetic (`--no-extensions --no-skills --no-context-files`); machine
|
|
111
|
+
extensions it must keep — e.g. an OAuth adapter that supplies your Anthropic auth —
|
|
112
|
+
are declared in machine config, not env:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
# .witness/config.local.yaml — machine facts, gitignored, never committed
|
|
116
|
+
reviewerExtensions:
|
|
117
|
+
- /Users/you/.pi/agent/npm/node_modules/pi-claude-oauth-adapter
|
|
118
|
+
opener: xdg-open # optional; nonstandard desktops only
|
|
119
|
+
```
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
drives **both** those reviewers and the implement session's own model — so the pin must be
|
|
110
|
-
an Anthropic id, and a Pi implement session on a non-Anthropic model is not expressible.
|
|
121
|
+
Repo facts live in `witness.config.yaml` (committed); machine facts live in
|
|
122
|
+
`.witness/config.local.yaml` (gitignored). Every key has exactly one home — a key in
|
|
123
|
+
the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
111
124
|
|
|
112
125
|
## Verbs
|
|
113
126
|
|
package/dist/calibrate.js
CHANGED
|
@@ -114,14 +114,14 @@ export function distribute(samples, buckets) {
|
|
|
114
114
|
}
|
|
115
115
|
export const threshold = (samples) => Math.ceil(0.9 * samples);
|
|
116
116
|
export const injectSamples = (samples) => Math.max(2, Math.ceil(samples / 5));
|
|
117
|
-
export function runSample(ctx, harness, model, lens, suite, overlay) {
|
|
117
|
+
export function runSample(ctx, harness, model, lens, suite, overlay, extras) {
|
|
118
118
|
const { dir, files } = materialize(suite, overlay);
|
|
119
119
|
const { reviewed, context } = composeReviewed(suite, dir, files);
|
|
120
120
|
// same anchor-menu injection as the gate path — calibration must measure the
|
|
121
121
|
// prompt condition production reviewers actually see
|
|
122
122
|
const menu = anchorMenu(reviewed);
|
|
123
123
|
const prompt = `${lens}\n\n${menu ? `${menu}\n\n` : ''}${renderReviewed(reviewed, context)}\n`;
|
|
124
|
-
const invoked = invokeReviewer(ctx, harness, { cwd: dir, prompt, model });
|
|
124
|
+
const invoked = invokeReviewer(ctx, harness, { cwd: dir, prompt, model, ...extras });
|
|
125
125
|
if (!invoked.ok)
|
|
126
126
|
return invoked; // invocation-layer failure aborts the run
|
|
127
127
|
const raw = parseVerdictText(invoked.value.text);
|
|
@@ -139,7 +139,7 @@ export function runSample(ctx, harness, model, lens, suite, overlay) {
|
|
|
139
139
|
function side() {
|
|
140
140
|
return { ok: 0, total: 0 };
|
|
141
141
|
}
|
|
142
|
-
export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
|
|
142
|
+
export async function runReviewerSuite(ctx, harness, model, reviewer, samples, extras) {
|
|
143
143
|
const suiteR = loadReviewerSuite(reviewer);
|
|
144
144
|
if (!suiteR.ok)
|
|
145
145
|
return suiteR;
|
|
@@ -156,7 +156,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
|
|
|
156
156
|
const perSeed = distribute(samples, suite.seeds.length);
|
|
157
157
|
for (const [i, seed] of suite.seeds.entries()) {
|
|
158
158
|
for (let n = 0; n < perSeed[i]; n += 1) {
|
|
159
|
-
const s = runSample(ctx, harness, model, lens, suite, seed.overlay);
|
|
159
|
+
const s = runSample(ctx, harness, model, lens, suite, seed.overlay, extras);
|
|
160
160
|
if (!s.ok)
|
|
161
161
|
return s;
|
|
162
162
|
catches.total += 1;
|
|
@@ -166,7 +166,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
|
|
|
166
166
|
}
|
|
167
167
|
for (const seed of suite.injects) {
|
|
168
168
|
for (let n = 0; n < injectSamples(samples); n += 1) {
|
|
169
|
-
const s = runSample(ctx, harness, model, lens, suite, seed.overlay);
|
|
169
|
+
const s = runSample(ctx, harness, model, lens, suite, seed.overlay, extras);
|
|
170
170
|
if (!s.ok)
|
|
171
171
|
return s;
|
|
172
172
|
inject.total += 1;
|
|
@@ -175,7 +175,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
for (let n = 0; n < samples; n += 1) {
|
|
178
|
-
const s = runSample(ctx, harness, model, lens, suite);
|
|
178
|
+
const s = runSample(ctx, harness, model, lens, suite, undefined, extras);
|
|
179
179
|
if (!s.ok)
|
|
180
180
|
return s;
|
|
181
181
|
clean.total += 1;
|
|
@@ -185,13 +185,13 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
|
|
|
185
185
|
const pass = catches.ok >= threshold(catches.total) && clean.ok >= threshold(clean.total) && inject.ok === inject.total;
|
|
186
186
|
return ok({ reviewer, catches, clean, inject, pass });
|
|
187
187
|
}
|
|
188
|
-
export async function runReviewerSuites(ctx, harness, model, samples, only) {
|
|
188
|
+
export async function runReviewerSuites(ctx, harness, model, samples, only, extras) {
|
|
189
189
|
const names = [...PROMPT_NAMES].sort().filter((n) => only === undefined || n === only);
|
|
190
190
|
if (names.length === 0)
|
|
191
191
|
return refuse([v('--only', 'unknown-reviewer', only ?? '', PROMPT_NAMES.join(' '))]);
|
|
192
192
|
const scores = [];
|
|
193
193
|
for (const name of names) {
|
|
194
|
-
const r = await runReviewerSuite(ctx, harness, model, name, samples);
|
|
194
|
+
const r = await runReviewerSuite(ctx, harness, model, name, samples, extras);
|
|
195
195
|
if (!r.ok)
|
|
196
196
|
return r;
|
|
197
197
|
scores.push(r.value);
|
|
@@ -324,7 +324,7 @@ function firstCriterionTag(meta) {
|
|
|
324
324
|
const withTest = criteria.find((c) => typeof c.test === 'string');
|
|
325
325
|
return withTest ? String(withTest.test).replace(/^@spec:/, '') : '';
|
|
326
326
|
}
|
|
327
|
-
export async function runDecomposeSeed(ctx, harness, model, seed) {
|
|
327
|
+
export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
|
|
328
328
|
const { root } = seedScratchRepo(`decompose-${seed.id}`);
|
|
329
329
|
const canonDir = join(seed.dir, 'canon');
|
|
330
330
|
if (existsSync(canonDir)) {
|
|
@@ -348,7 +348,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed) {
|
|
|
348
348
|
`### witness index\n\n${indexH.out()}`,
|
|
349
349
|
NONINTERACTIVE_OVERRIDE,
|
|
350
350
|
].join('\n\n');
|
|
351
|
-
const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model });
|
|
351
|
+
const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model, ...extras });
|
|
352
352
|
if (!invoked.ok)
|
|
353
353
|
return invoked;
|
|
354
354
|
const raw = parseVerdictText(invoked.value.text);
|
|
@@ -413,7 +413,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed) {
|
|
|
413
413
|
return ok({ ok: false, why: `goals uncovered: ${uncovered.join(',')}` });
|
|
414
414
|
return ok({ ok: true, why: 'first-try valid' });
|
|
415
415
|
}
|
|
416
|
-
export async function runPlanSeed(ctx, harness, model, seed) {
|
|
416
|
+
export async function runPlanSeed(ctx, harness, model, seed, extras) {
|
|
417
417
|
const { root } = seedScratchRepo(`plan-${seed.id}`);
|
|
418
418
|
const recapText = readFileSync(join(seed.dir, 'recap.json'), 'utf8');
|
|
419
419
|
const recap = JSON.parse(recapText);
|
|
@@ -446,7 +446,7 @@ export async function runPlanSeed(ctx, harness, model, seed) {
|
|
|
446
446
|
`### witness diff ${parentId}\n\n${diffH.out()}`,
|
|
447
447
|
NONINTERACTIVE_OVERRIDE,
|
|
448
448
|
].join('\n\n');
|
|
449
|
-
const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model });
|
|
449
|
+
const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model, ...extras });
|
|
450
450
|
if (!invoked.ok)
|
|
451
451
|
return invoked;
|
|
452
452
|
const raw = parseVerdictText(invoked.value.text);
|
|
@@ -560,7 +560,7 @@ export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
|
|
|
560
560
|
let total = 0;
|
|
561
561
|
for (const [i, seed] of seeds.entries()) {
|
|
562
562
|
for (let n = 0; n < perSeed[i]; n += 1) {
|
|
563
|
-
const r = await runDecomposeSeed(ctx, harness, model, seed);
|
|
563
|
+
const r = await runDecomposeSeed(ctx, harness, model, seed, opts.extras);
|
|
564
564
|
if (!r.ok)
|
|
565
565
|
return r;
|
|
566
566
|
total += 1;
|
|
@@ -577,7 +577,7 @@ export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
|
|
|
577
577
|
let total = 0;
|
|
578
578
|
for (const [i, seed] of seeds.entries()) {
|
|
579
579
|
for (let n = 0; n < perSeed[i]; n += 1) {
|
|
580
|
-
const r = await runPlanSeed(ctx, harness, model, seed);
|
|
580
|
+
const r = await runPlanSeed(ctx, harness, model, seed, opts.extras);
|
|
581
581
|
if (!r.ok)
|
|
582
582
|
return r;
|
|
583
583
|
total += 1;
|
package/dist/config.js
CHANGED
|
@@ -98,6 +98,71 @@ export function resolveImplement(raw) {
|
|
|
98
98
|
}
|
|
99
99
|
return ok({ stepsPerDispatch: val });
|
|
100
100
|
}
|
|
101
|
+
export const DEFAULT_REVIEWER_TIMEOUT_MS = 600_000;
|
|
102
|
+
// gates.* is otherwise read loosely at its consumers (stagePin); the timeout is
|
|
103
|
+
// resolved here, typed, because a typo'd key silently defaulting to ten minutes is
|
|
104
|
+
// exactly the ambient-value class row 90 removed from env.
|
|
105
|
+
export function resolveGates(raw) {
|
|
106
|
+
const conf = raw.gates;
|
|
107
|
+
if (conf === undefined)
|
|
108
|
+
return ok({ reviewerTimeoutMs: DEFAULT_REVIEWER_TIMEOUT_MS });
|
|
109
|
+
if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
|
|
110
|
+
return refuse([v('gates', 'invalid', String(conf), 'a map of gate settings')]);
|
|
111
|
+
}
|
|
112
|
+
const val = conf.reviewerTimeoutMs;
|
|
113
|
+
if (val === undefined)
|
|
114
|
+
return ok({ reviewerTimeoutMs: DEFAULT_REVIEWER_TIMEOUT_MS });
|
|
115
|
+
if (typeof val !== 'number' || !Number.isInteger(val) || val < 1) {
|
|
116
|
+
return refuse([v('gates.reviewerTimeoutMs', 'invalid', String(val), 'an integer >= 1 — milliseconds each reviewer invocation may take')]);
|
|
117
|
+
}
|
|
118
|
+
return ok({ reviewerTimeoutMs: val });
|
|
119
|
+
}
|
|
120
|
+
const LOCAL_KEYS = ['reviewerExtensions', 'opener'];
|
|
121
|
+
export const localConfigPath = (root) => join(root, '.witness', 'config.local.yaml');
|
|
122
|
+
export function loadLocalConfig(root) {
|
|
123
|
+
const p = localConfigPath(root);
|
|
124
|
+
if (!existsSync(p))
|
|
125
|
+
return ok({ reviewerExtensions: [] });
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = parseYaml(readFileSync(p, 'utf8'));
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return refuse([v('.witness/config.local.yaml', 'yaml-parse', String(e.message).slice(0, 120), 'valid YAML')]);
|
|
132
|
+
}
|
|
133
|
+
const obj = (raw ?? {});
|
|
134
|
+
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
|
135
|
+
return refuse([v('.witness/config.local.yaml', 'invalid', String(obj), 'a map of machine-local keys')]);
|
|
136
|
+
}
|
|
137
|
+
const violations = [];
|
|
138
|
+
for (const key of Object.keys(obj)) {
|
|
139
|
+
if (!LOCAL_KEYS.includes(key)) {
|
|
140
|
+
violations.push(v(key, 'unknown-local-key', key, `a machine-local key (${LOCAL_KEYS.join(' | ')}) — repo facts belong in witness.config.yaml`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let reviewerExtensions = [];
|
|
144
|
+
const ext = obj.reviewerExtensions;
|
|
145
|
+
if (ext !== undefined) {
|
|
146
|
+
if (!Array.isArray(ext) || !ext.every((x) => typeof x === 'string' && x !== '')) {
|
|
147
|
+
violations.push(v('reviewerExtensions', 'invalid', JSON.stringify(ext), 'a list of extension paths handed to the harness reviewer spawn'));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
reviewerExtensions = ext;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let opener;
|
|
154
|
+
if (obj.opener !== undefined) {
|
|
155
|
+
if (typeof obj.opener !== 'string' || obj.opener === '') {
|
|
156
|
+
violations.push(v('opener', 'invalid', String(obj.opener), 'a command name or path'));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
opener = obj.opener;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (violations.length)
|
|
163
|
+
return refuse(violations);
|
|
164
|
+
return ok({ reviewerExtensions, ...(opener !== undefined ? { opener } : {}) });
|
|
165
|
+
}
|
|
101
166
|
// Lenient path resolution for callers without a loaded Config (canon scan, git
|
|
102
167
|
// state scoping): a missing or broken config falls back to the defaults — the
|
|
103
168
|
// verbs own the refusal messaging via loadConfig, which validates paths strictly.
|
|
@@ -144,12 +209,16 @@ export function loadConfig(root) {
|
|
|
144
209
|
const implement = resolveImplement(obj);
|
|
145
210
|
if (!implement.ok)
|
|
146
211
|
return refuse(implement.violations);
|
|
212
|
+
const gates = resolveGates(obj);
|
|
213
|
+
if (!gates.ok)
|
|
214
|
+
return refuse(gates.violations);
|
|
147
215
|
return ok({
|
|
148
216
|
schema,
|
|
149
217
|
raw: obj,
|
|
150
218
|
paths: paths.value,
|
|
151
219
|
docs: docs.value,
|
|
152
220
|
implement: implement.value,
|
|
221
|
+
gates: gates.value,
|
|
153
222
|
warning: schema < SCHEMA_VERSION ? `schema ${schema} < ${SCHEMA_VERSION} — run witness migrate (reserved)` : undefined,
|
|
154
223
|
});
|
|
155
224
|
}
|
package/dist/drift.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { EXIT } from './cli.js';
|
|
4
|
-
import { loadConfig } from './config.js';
|
|
4
|
+
import { loadConfig, loadLocalConfig } from './config.js';
|
|
5
5
|
import { runCriteria } from './criteria.js';
|
|
6
6
|
import { serializeDoc, writeDoc } from './fm.js';
|
|
7
7
|
import { acquireLock } from './lock.js';
|
|
@@ -125,8 +125,20 @@ export async function deepDrift(root, ctx, canon, specId) {
|
|
|
125
125
|
renderRefusal(hxR.violations).forEach(ctx.err);
|
|
126
126
|
return EXIT.REFUSED;
|
|
127
127
|
}
|
|
128
|
+
// Repo config may be broken here by design — the timeout falls to the default. A
|
|
129
|
+
// broken LOCAL file refuses instead: silently dropping declared extensions
|
|
130
|
+
// reproduces the exact misdiagnosed 400 this release exists to kill.
|
|
131
|
+
const localR = loadLocalConfig(root);
|
|
132
|
+
if (!localR.ok) {
|
|
133
|
+
renderRefusal(localR.violations).forEach(ctx.err);
|
|
134
|
+
return EXIT.REFUSED;
|
|
135
|
+
}
|
|
128
136
|
const prompt = `${lensR.value.contents}\n\n## Reviewed content\n\n### Spec: ${specId}\n${serializeDoc({ meta: doc.meta, body: doc.body })}\n\n(Deterministic lane: ${lane.ok ? lane.value.criteria.map((c) => `${c.id}:${c.ok ? 'ok' : 'fail'}`).join(' · ') : 'unrunnable'})\n`;
|
|
129
|
-
const invoked = invokeReviewer(ctx, hxR.value.harness, {
|
|
137
|
+
const invoked = invokeReviewer(ctx, hxR.value.harness, {
|
|
138
|
+
cwd: root, prompt,
|
|
139
|
+
timeoutMs: cfgR.ok ? cfgR.value.gates.reviewerTimeoutMs : undefined,
|
|
140
|
+
extensions: localR.value.reviewerExtensions,
|
|
141
|
+
});
|
|
130
142
|
if (!invoked.ok) {
|
|
131
143
|
renderRefusal(invoked.violations).forEach(ctx.err);
|
|
132
144
|
return EXIT.REFUSED;
|
package/dist/gate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { EXIT, version } from './cli.js';
|
|
3
|
-
import { loadConfig } from './config.js';
|
|
3
|
+
import { loadConfig, loadLocalConfig } from './config.js';
|
|
4
4
|
import { writeDoc } from './fm.js';
|
|
5
5
|
import { crashPoint, guardTxn, withTxn } from './txn.js';
|
|
6
6
|
import { acquireLock } from './lock.js';
|
|
@@ -169,6 +169,15 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
169
169
|
return EXIT.REFUSED;
|
|
170
170
|
}
|
|
171
171
|
const harness = hxR.value.harness;
|
|
172
|
+
const localR = loadLocalConfig(root);
|
|
173
|
+
if (!localR.ok) {
|
|
174
|
+
renderRefusal(localR.violations).forEach((l) => ctx.err(l));
|
|
175
|
+
return EXIT.REFUSED;
|
|
176
|
+
}
|
|
177
|
+
const extras = {
|
|
178
|
+
timeoutMs: cfgR.value.gates.reviewerTimeoutMs,
|
|
179
|
+
extensions: localR.value.reviewerExtensions,
|
|
180
|
+
};
|
|
172
181
|
const modelR = resolveModel(cfgR.value, loadMatrix(root, harness.name), spec.gate);
|
|
173
182
|
if (!modelR.ok) {
|
|
174
183
|
renderRefusal(modelR.violations).forEach((l) => ctx.err(l));
|
|
@@ -240,7 +249,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
240
249
|
let answered;
|
|
241
250
|
for (;;) {
|
|
242
251
|
const id = chain[rung];
|
|
243
|
-
const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id });
|
|
252
|
+
const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id, ...extras });
|
|
244
253
|
if (r.ok) {
|
|
245
254
|
answered = r.value.text;
|
|
246
255
|
model = id;
|
|
@@ -314,6 +323,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
314
323
|
round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
|
|
315
324
|
reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
|
|
316
325
|
witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
|
|
326
|
+
...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
|
|
317
327
|
...(cached ? { cached: true } : {}),
|
|
318
328
|
...(flags.manual ? { manual: true } : {}),
|
|
319
329
|
...(fallback.length ? { fallback } : {}),
|
package/dist/gates/ship.js
CHANGED
|
@@ -11,6 +11,7 @@ import { effortOf, worktreeTreeSha } from '../reviewed.js';
|
|
|
11
11
|
import { worktreePath } from '../worktree.js';
|
|
12
12
|
import { codePromptBody } from './implement.js';
|
|
13
13
|
import { registerGate } from '../gate.js';
|
|
14
|
+
import { gateSettled } from '../verbs/next.js';
|
|
14
15
|
import { lastGateRun } from '../rounds.js';
|
|
15
16
|
import { serializeDoc } from '../fm.js';
|
|
16
17
|
async function commandLane(wt, root, ctx, name, cmd) {
|
|
@@ -46,16 +47,19 @@ registerGate({
|
|
|
46
47
|
const checks = [];
|
|
47
48
|
const entries = readStream(root, planId);
|
|
48
49
|
const lastImplement = lastGateRun(entries, 'implement');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
// One settle predicate, not two. This block used to inline its own copy of
|
|
51
|
+
// gateSettled, which meant `ship` and `next` could answer "is implement settled?"
|
|
52
|
+
// differently for the same journal — the split-brain that reads as a deadlock when
|
|
53
|
+
// two sessions compare notes.
|
|
54
|
+
//
|
|
55
|
+
// The missing sha argument is DELIBERATE and load-bearing, not an oversight. D75's
|
|
56
|
+
// `worktreeTreeSha` covers the whole worktree, so ship's own `pr #N` stamp — pulled
|
|
57
|
+
// into the worktree by the watch-phase rebase — moves the tree and would lapse the
|
|
58
|
+
// very gate ship is checking: approve → pr → lapse → gate, the livelock D75/D77
|
|
59
|
+
// record. `next` re-arms on tree movement because authoring is what it routes to;
|
|
60
|
+
// ship asks only whether implement was ever settled. Passing a sha here re-opens
|
|
61
|
+
// that livelock — do not "fix" this by adding one.
|
|
62
|
+
const implementSettled = gateSettled(entries, 'implement');
|
|
59
63
|
checks.push({ name: 'implement-gate', ok: implementSettled,
|
|
60
64
|
detail: lastImplement
|
|
61
65
|
? `last implement round ${lastImplement.round}: ${lastImplement.outcome}`
|
package/dist/gitio.js
CHANGED
|
@@ -44,6 +44,12 @@ const LOCAL_STATE_FILES = new Set([
|
|
|
44
44
|
'.witness/txn.json',
|
|
45
45
|
'.witness/allow.json',
|
|
46
46
|
'.witness/calibration.local.yaml',
|
|
47
|
+
// Row 90's machine-config file is this tier too, and the exemption is load-bearing
|
|
48
|
+
// rather than belt-and-braces: init only gitignores it for repos scaffolded at
|
|
49
|
+
// >=0.5.0, so on every pre-0.5.0 repo the file is untracked-but-visible under a
|
|
50
|
+
// state dir — and an unrelated-dirty refusal there would brick the very gate the
|
|
51
|
+
// declared extensions exist to unblock.
|
|
52
|
+
'.witness/config.local.yaml',
|
|
47
53
|
]);
|
|
48
54
|
export function dirtyStatePaths(root) {
|
|
49
55
|
const res = tryGit(root, 'status', '--porcelain', '--untracked-files=all', '--', ...stateDirs(root));
|
|
@@ -57,13 +63,35 @@ export function dirtyStatePaths(root) {
|
|
|
57
63
|
.filter((p) => !LOCAL_STATE_FILES.has(p));
|
|
58
64
|
}
|
|
59
65
|
export const TRAILER = 'Witness-State: 1';
|
|
66
|
+
// Row 87's rule: nothing to COMMIT is the same legal silent success as nothing to write.
|
|
67
|
+
// It lives here, at the one choke point every state commit passes through, because git
|
|
68
|
+
// rejects BOTH empty cases fatally and a caller cannot tell them apart in advance:
|
|
69
|
+
// • no paths at all → `commit --only ... --` dies "No paths with --include/--only"
|
|
70
|
+
// (a drift sweep with no live specs journals nothing and stamps nothing);
|
|
71
|
+
// • paths with no diff → `commit --only` exits non-zero (a restamp that restores a
|
|
72
|
+
// file to exactly its HEAD content, or a pathspec the repo gitignores).
|
|
73
|
+
// Either way there is no state to record, so HEAD is already the answer. Implementing
|
|
74
|
+
// this at init.ts's call site alone left every other caller throwing out of gitio and
|
|
75
|
+
// surfacing through bin.ts as `unexpected-error` — "report a bug" on a healthy repo.
|
|
76
|
+
function headSha(root) {
|
|
77
|
+
const head = tryGit(root, 'rev-parse', 'HEAD');
|
|
78
|
+
return head.ok ? head.out : ''; // unborn HEAD: no commit exists to name
|
|
79
|
+
}
|
|
60
80
|
export function commitWithTrailer(root, files, subject) {
|
|
81
|
+
if (files.length === 0)
|
|
82
|
+
return ok({ sha: headSha(root) });
|
|
61
83
|
// tryGit, not git: a path already fully processed by a prior `git mv` (both sides
|
|
62
84
|
// of a rename staged atomically) is absent from working tree AND index, so `add`
|
|
63
85
|
// reports it unmatched — even though the rename itself is already staged. `commit
|
|
64
86
|
// --only` resolves its pathspecs against the index-vs-HEAD diff, where the rename
|
|
65
87
|
// is visible, so it still succeeds and is the real validation here.
|
|
66
88
|
tryGit(root, 'add', '--', ...files);
|
|
89
|
+
// Ask git what actually changed rather than assuming every file we touched is dirty.
|
|
90
|
+
// Runs AFTER the add, so a freshly staged untracked path reads as `A ` and commits;
|
|
91
|
+
// a staged rename reads as `R ` on whichever side the pathspec names.
|
|
92
|
+
if (tryGit(root, 'status', '--porcelain', '--', ...files).out === '') {
|
|
93
|
+
return ok({ sha: headSha(root) });
|
|
94
|
+
}
|
|
67
95
|
// --no-verify: state commits are machine-authored and scope-restricted to non-source
|
|
68
96
|
// paths (isStatePath), so a host lint/test hook has nothing to validate here — while
|
|
69
97
|
// its stash/restore step can destroy the human's unrelated dirty work, and a
|
package/dist/harness.js
CHANGED
|
@@ -39,7 +39,15 @@ function parsePiEnvelope(stdout) {
|
|
|
39
39
|
}
|
|
40
40
|
const assistant = end?.messages?.filter((m) => m.role === 'assistant').at(-1);
|
|
41
41
|
if (assistant?.stopReason === 'error') {
|
|
42
|
-
|
|
42
|
+
const msg = (assistant.errorMessage ?? 'provider error').slice(0, 200);
|
|
43
|
+
// Anthropic's extra-usage 400 against subscription OAuth is the hermetic spawn
|
|
44
|
+
// disabling an auth-supplying extension, NOT an auth/billing problem — the old
|
|
45
|
+
// want text cost a real user rounds of re-login (row 89 overturned row 88's
|
|
46
|
+
// billing-asymmetry residual on this evidence).
|
|
47
|
+
if (/Third-party apps|extra usage/i.test(msg)) {
|
|
48
|
+
return refuse([v('pi', 'reviewer-invocation', msg, 'a credential headless pi can use — the reviewer runs hermetic (--no-extensions); if your provider auth is supplied by a pi extension, declare its path in .witness/config.local.yaml reviewerExtensions')]);
|
|
49
|
+
}
|
|
50
|
+
return refuse([v('pi', 'reviewer-invocation', msg, 'a provider the pinned model can reach — check auth and billing for that provider')]);
|
|
43
51
|
}
|
|
44
52
|
const text = (assistant?.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n');
|
|
45
53
|
if (text === '') {
|
|
@@ -68,7 +76,7 @@ const REGISTRY = {
|
|
|
68
76
|
],
|
|
69
77
|
skills: { project: '.claude/skills', global: '.claude/skills' },
|
|
70
78
|
reviewer: {
|
|
71
|
-
spawn(pin) {
|
|
79
|
+
spawn(pin, _extensions) {
|
|
72
80
|
const args = ['-p', '--output-format', 'json'];
|
|
73
81
|
const env = {};
|
|
74
82
|
if (pin !== undefined) {
|
|
@@ -105,10 +113,20 @@ const REGISTRY = {
|
|
|
105
113
|
// Hermetic (Decision 88): every omitted flag here is a machine-local variable that
|
|
106
114
|
// would silently change reviewer behavior — this machine's `defaultThinkingLevel:
|
|
107
115
|
// xhigh` was the probable true cause of row 87's "stalls on long prompts".
|
|
116
|
+
//
|
|
117
|
+
// Declared reviewerExtensions (machine config) are the ONE sanctioned readmission —
|
|
118
|
+
// auth transport, journaled per gate-run, never part of the verdict-cache key. The
|
|
119
|
+
// worker below keeps full discovery on purpose: skills and context files are what
|
|
120
|
+
// the implement seed measures; auth extensions ride along with everything else there.
|
|
108
121
|
reviewer: {
|
|
109
|
-
spawn(pin) {
|
|
110
|
-
const args = ['-p', '--mode', 'json', '--no-session', '--no-extensions'
|
|
111
|
-
|
|
122
|
+
spawn(pin, extensions) {
|
|
123
|
+
const args = ['-p', '--mode', 'json', '--no-session', '--no-extensions'];
|
|
124
|
+
// Declared machine extensions ride INSIDE the hermetic set (row 89): pi's
|
|
125
|
+
// --no-extensions disables discovery but explicit -e paths still load, so
|
|
126
|
+
// auth-supplying adapters work without readmitting ambient machine state.
|
|
127
|
+
for (const e of extensions ?? [])
|
|
128
|
+
args.push('-e', e);
|
|
129
|
+
args.push('--no-skills', '--no-context-files', '--thinking', pin?.thinking ?? 'off');
|
|
112
130
|
if (pin !== undefined)
|
|
113
131
|
args.push('--model', `${pin.provider ?? 'anthropic'}/${pin.model}`);
|
|
114
132
|
return { cmd: 'pi', args, env: {} };
|
|
@@ -132,7 +150,6 @@ export function loadHarness(name) {
|
|
|
132
150
|
}
|
|
133
151
|
return ok(harness);
|
|
134
152
|
}
|
|
135
|
-
const relabel = (violations, field) => violations.map((x) => ({ ...x, field }));
|
|
136
153
|
// Decision 5. Detection is the authority; config is the fallback. A config-authority
|
|
137
154
|
// default in a fresh repo emits a runnable-LOOKING, unrunnable handoff behind a warning
|
|
138
155
|
// that gets scrolled past — bug B2's exact shape. Detection tests PRESENCE: neither
|
|
@@ -141,12 +158,10 @@ const relabel = (violations, field) => violations.map((x) => ({ ...x, field }));
|
|
|
141
158
|
// Deliberately NOT wired into loadConfig: every verb calls that, so an invalid
|
|
142
159
|
// `harness:` there would brick `witness check` on a key nothing read. Verbs that need
|
|
143
160
|
// a harness ask for one; `check` reports a malformed config value as a finding.
|
|
161
|
+
//
|
|
162
|
+
// Row 90 removed the WITNESS_HARNESS env rung: configuration has one home, and tests
|
|
163
|
+
// simulate harnesses by setting the detection vars production actually reads.
|
|
144
164
|
export function resolveHarness(env, raw) {
|
|
145
|
-
const override = env.WITNESS_HARNESS;
|
|
146
|
-
if (override !== undefined && override !== '') {
|
|
147
|
-
const r = loadHarness(override);
|
|
148
|
-
return r.ok ? ok({ harness: r.value, source: 'env' }) : refuse(relabel(r.violations, 'WITNESS_HARNESS'));
|
|
149
|
-
}
|
|
150
165
|
if (env.PI_CODING_AGENT !== undefined) {
|
|
151
166
|
const r = loadHarness('pi');
|
|
152
167
|
return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
|
package/dist/opener.js
CHANGED
|
@@ -16,12 +16,13 @@ function resolves(cmd) {
|
|
|
16
16
|
return probe.status === 0;
|
|
17
17
|
}
|
|
18
18
|
// Show a file to the human. Returns the resolved command alongside the outcome so the
|
|
19
|
-
// caller can journal WHAT was run — a degenerate opener (`
|
|
20
|
-
// then reads as a degenerate opener in the record instead
|
|
21
|
-
// screen. `
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
// caller can journal WHAT was run — a degenerate opener (`opener: /usr/bin/true` in
|
|
20
|
+
// .witness/config.local.yaml) then reads as a degenerate opener in the record instead
|
|
21
|
+
// of as a human looking at a screen. `opener` is the machine-config value (row 90 —
|
|
22
|
+
// nonstandard desktops declare it there; tests inject it as a parameter), a bare name
|
|
23
|
+
// off PATH or a path.
|
|
24
|
+
export function openArtifact(opener, absPath) {
|
|
25
|
+
const argv = opener !== undefined && opener !== '' ? [opener] : platformOpener();
|
|
25
26
|
const [cmd, ...pre] = argv;
|
|
26
27
|
// Probe before spawning: a detached spawn reports ENOENT asynchronously, and the
|
|
27
28
|
// caller needs the outcome synchronously to journal it in the same transaction.
|
package/dist/reviewer.js
CHANGED
|
@@ -94,8 +94,8 @@ export function invokeReviewer(ctx, harness, opts) {
|
|
|
94
94
|
return refuse(pinR.violations);
|
|
95
95
|
pin = pinR.value;
|
|
96
96
|
}
|
|
97
|
-
const { cmd, args, env } = harness.reviewer.spawn(pin);
|
|
98
|
-
const timeout =
|
|
97
|
+
const { cmd, args, env } = harness.reviewer.spawn(pin, opts.extensions);
|
|
98
|
+
const timeout = opts.timeoutMs ?? REVIEWER_TIMEOUT_MS;
|
|
99
99
|
for (let attempt = 0;; attempt += 1) {
|
|
100
100
|
const r = spawnSync(cmd, args, {
|
|
101
101
|
cwd: opts.cwd,
|
|
@@ -111,7 +111,7 @@ export function invokeReviewer(ctx, harness, opts) {
|
|
|
111
111
|
if (r.error.code === 'ETIMEDOUT') {
|
|
112
112
|
if (attempt < TIMEOUT_RETRIES)
|
|
113
113
|
continue;
|
|
114
|
-
return refuse([v(cmd, 'reviewer-timeout', `no response in ${timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise
|
|
114
|
+
return refuse([v(cmd, 'reviewer-timeout', `no response in ${timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise gates.reviewerTimeoutMs in witness.config.yaml if the model is simply slow')]);
|
|
115
115
|
}
|
|
116
116
|
return refuse([v(cmd, 'reviewer-invocation', String(r.error.message), `a runnable ${cmd} binary on PATH — gates invoke reviewers headlessly; witness check probes this`)]);
|
|
117
117
|
}
|
package/dist/verbs/calibrate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EXIT } from '../cli.js';
|
|
2
2
|
import { kv, rows } from '../toon.js';
|
|
3
3
|
import { renderRefusal, v } from '../refusal.js';
|
|
4
|
-
import { loadConfig } from '../config.js';
|
|
4
|
+
import { loadConfig, loadLocalConfig } from '../config.js';
|
|
5
5
|
import { resolveHarness } from '../harness.js';
|
|
6
6
|
import { MODEL_ALIASES } from '../model.js';
|
|
7
7
|
import { primaryRoot } from '../gitio.js';
|
|
@@ -78,13 +78,26 @@ export async function run(ctx, argv) {
|
|
|
78
78
|
return EXIT.REFUSED;
|
|
79
79
|
}
|
|
80
80
|
const harness = hxR.value.harness;
|
|
81
|
-
|
|
81
|
+
// Row 89: calibration measures through the SAME spawn as the gate battery, declared
|
|
82
|
+
// extensions included — a reviewer scored without its auth adapter is not the
|
|
83
|
+
// reviewer production runs.
|
|
84
|
+
const localR = loadLocalConfig(rootR.value);
|
|
85
|
+
if (!localR.ok) {
|
|
86
|
+
for (const line of renderRefusal(localR.violations))
|
|
87
|
+
ctx.err(line);
|
|
88
|
+
return EXIT.REFUSED;
|
|
89
|
+
}
|
|
90
|
+
const extras = {
|
|
91
|
+
timeoutMs: cfgR.ok ? cfgR.value.gates.reviewerTimeoutMs : undefined,
|
|
92
|
+
extensions: localR.value.reviewerExtensions,
|
|
93
|
+
};
|
|
94
|
+
const reviewers = runReviewers ? await runReviewerSuites(ctx, harness, model, flags.samples, reviewerOnly, extras) : undefined;
|
|
82
95
|
if (reviewers && !reviewers.ok) {
|
|
83
96
|
for (const line of renderRefusal(reviewers.violations))
|
|
84
97
|
ctx.err(line);
|
|
85
98
|
return EXIT.REFUSED;
|
|
86
99
|
}
|
|
87
|
-
const skills = runSkills ? await runSkillSuites(ctx, harness, model, flags.samples, { only: skillOnly }) : undefined;
|
|
100
|
+
const skills = runSkills ? await runSkillSuites(ctx, harness, model, flags.samples, { only: skillOnly, extras }) : undefined;
|
|
88
101
|
if (skills && !skills.ok) {
|
|
89
102
|
for (const line of renderRefusal(skills.violations))
|
|
90
103
|
ctx.err(line);
|
package/dist/verbs/check.js
CHANGED
|
@@ -4,10 +4,10 @@ import { adoptedCommits } from '../adopt.js';
|
|
|
4
4
|
import { EXIT, version } from '../cli.js';
|
|
5
5
|
import { HARNESSES, resolveHarness, skillsVisibility } from '../harness.js';
|
|
6
6
|
import { probe } from '../probe.js';
|
|
7
|
-
import { loadConfig } from '../config.js';
|
|
7
|
+
import { loadConfig, loadLocalConfig, localConfigPath } from '../config.js';
|
|
8
8
|
import { designPending } from '../design.js';
|
|
9
9
|
import { runDrift } from '../drift.js';
|
|
10
|
-
import { auditStateCommits, dirtyStatePaths, primaryRoot } from '../gitio.js';
|
|
10
|
+
import { auditStateCommits, dirtyStatePaths, primaryRoot, tryGit } from '../gitio.js';
|
|
11
11
|
import { contentAtSha } from '../history.js';
|
|
12
12
|
import { effortStreams, readStream } from '../journal.js';
|
|
13
13
|
import { sourceTags } from '../matcher.js';
|
|
@@ -37,6 +37,24 @@ export async function run(ctx, argv = []) {
|
|
|
37
37
|
cfg.violations.forEach((x) => findings.push(f('error', 'config', x.field, x.rule, x.got)));
|
|
38
38
|
else if (cfg.value.warning)
|
|
39
39
|
findings.push(f('warn', 'config', 'schema', 'older-schema', cfg.value.warning));
|
|
40
|
+
// Machine config is read here as FINDINGS, never a refusal — same doctrine that
|
|
41
|
+
// keeps harness resolution out of loadConfig (row 87): check must be able to
|
|
42
|
+
// report a broken machine file rather than brick on it.
|
|
43
|
+
const local = loadLocalConfig(root);
|
|
44
|
+
if (!local.ok) {
|
|
45
|
+
local.violations.forEach((x) => findings.push(f('error', 'local-config', x.field, x.rule, x.got)));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
for (const p of local.value.reviewerExtensions) {
|
|
49
|
+
if (!existsSync(p))
|
|
50
|
+
findings.push(f('warn', 'local-config', 'reviewerExtensions', 'extension-path-missing', p));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// init's gitignore write is scaffold-once, so pre-0.5.0 repos never receive the new
|
|
54
|
+
// ignore line — this finding is their honest path to it.
|
|
55
|
+
if (existsSync(localConfigPath(root)) && !tryGit(root, 'check-ignore', '-q', '--', '.witness/config.local.yaml').ok) {
|
|
56
|
+
findings.push(f('warn', 'local-config', '.witness/config.local.yaml', 'local-config-unignored', 'machine-local file — add it to .gitignore'));
|
|
57
|
+
}
|
|
40
58
|
const canon0 = loadCanon(root);
|
|
41
59
|
const lazy = lazyStamp(root, ctx, canon0);
|
|
42
60
|
const canon = lazy.stamped.length > 0 ? loadCanon(root) : canon0;
|
package/dist/verbs/design.js
CHANGED
|
@@ -3,7 +3,7 @@ import { hostname, userInfo } from 'node:os';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { EXIT } from '../cli.js';
|
|
6
|
-
import { loadConfig } from '../config.js';
|
|
6
|
+
import { loadConfig, loadLocalConfig } from '../config.js';
|
|
7
7
|
import { designPending, designRel, designStamp, htmlSha, validateDesignArtifact } from '../design.js';
|
|
8
8
|
import { writeDoc } from '../fm.js';
|
|
9
9
|
import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
|
|
@@ -85,9 +85,14 @@ function openOnly(ctx, root, rel, id) {
|
|
|
85
85
|
return EXIT.REFUSED;
|
|
86
86
|
}
|
|
87
87
|
const sha = htmlSha(readFileSync(abs, 'utf8'));
|
|
88
|
-
const
|
|
88
|
+
const localR = loadLocalConfig(root);
|
|
89
|
+
if (!localR.ok) {
|
|
90
|
+
renderRefusal(localR.violations).forEach(ctx.err);
|
|
91
|
+
return EXIT.REFUSED;
|
|
92
|
+
}
|
|
93
|
+
const { outcome, command } = openArtifact(localR.value.opener, abs);
|
|
89
94
|
if (outcome === 'failed') {
|
|
90
|
-
renderRefusal([v('design', 'opener-failed', `${command} did not resolve`, `a working platform opener, or
|
|
95
|
+
renderRefusal([v('design', 'opener-failed', `${command} did not resolve`, `a working platform opener, or opener: in .witness/config.local.yaml — meanwhile open it yourself: file://${abs}`)]).forEach(ctx.err);
|
|
91
96
|
return EXIT.REFUSED;
|
|
92
97
|
}
|
|
93
98
|
// `by` is the account and machine the spawn happened on — not a claim about who looked.
|
package/dist/verbs/init.js
CHANGED
|
@@ -5,7 +5,7 @@ import { EXIT } from '../cli.js';
|
|
|
5
5
|
import { configPath } from '../config.js';
|
|
6
6
|
import { writeDoc } from '../fm.js';
|
|
7
7
|
import { commitWithTrailer, primaryRoot, tryGit } from '../gitio.js';
|
|
8
|
-
import { resolveHarness } from '../harness.js';
|
|
8
|
+
import { loadHarness, resolveHarness } from '../harness.js';
|
|
9
9
|
import { installPayload, mergeSettings, preflightPayload, readIfExists, recordHarness, writeSettings, } from '../install.js';
|
|
10
10
|
import { probe } from '../probe.js';
|
|
11
11
|
import { acquireLock } from '../lock.js';
|
|
@@ -18,6 +18,7 @@ schema: 1
|
|
|
18
18
|
# conventions: [docs/code/architecture.md] # → injected into code-reviewer at implement + ship (cache-keyed)
|
|
19
19
|
gates:
|
|
20
20
|
model: claude-fable-5 # reviewer model pin — exact id, aliases refused
|
|
21
|
+
# reviewerTimeoutMs: 600000 # ms per reviewer invocation (machine auth/extension knobs live in .witness/config.local.yaml)
|
|
21
22
|
# any gate block may pin its own model (wins over the global pin), e.g.
|
|
22
23
|
# decompose: { reviewers: [slicing-critic], model: claude-opus-4-8 }
|
|
23
24
|
decompose: { reviewers: [slicing-critic] }
|
|
@@ -37,6 +38,7 @@ const GITIGNORE_BLOCK = `# witness local (never committed)
|
|
|
37
38
|
.witness/txn.json
|
|
38
39
|
.witness/allow.json
|
|
39
40
|
.witness/calibration.local.yaml
|
|
41
|
+
.witness/config.local.yaml
|
|
40
42
|
.witness/screens/
|
|
41
43
|
`;
|
|
42
44
|
const PRINCIPLES_BODY = `# Principles
|
|
@@ -73,15 +75,14 @@ export async function run(ctx, argv = []) {
|
|
|
73
75
|
let harness;
|
|
74
76
|
if (agent !== undefined) {
|
|
75
77
|
// `auto` is the one value that consults the detection rungs; a named agent is a
|
|
76
|
-
// claim and refuses when false, listing the harnesses that exist
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
: resolveHarness({ WITNESS_HARNESS: agent }, {});
|
|
78
|
+
// claim and refuses when false, listing the harnesses that exist (row 90: no env
|
|
79
|
+
// impersonation — a name resolves through the registry directly).
|
|
80
|
+
const hxR = agent === 'auto' ? resolveHarness(ctx.env, {}) : loadHarness(agent);
|
|
80
81
|
if (!hxR.ok) {
|
|
81
82
|
renderRefusal(hxR.violations.map((x) => ({ ...x, field: '--agent' }))).forEach(ctx.err);
|
|
82
83
|
return EXIT.REFUSED;
|
|
83
84
|
}
|
|
84
|
-
harness = hxR.value.harness;
|
|
85
|
+
harness = 'harness' in hxR.value ? hxR.value.harness : hxR.value;
|
|
85
86
|
// Revision 6: pre-flight before the lock and before any scaffold write, so a
|
|
86
87
|
// refusal here leaves the repo exactly as it was.
|
|
87
88
|
const pre = preflightPayload(root, harness);
|
package/dist/verbs/next.js
CHANGED
|
@@ -14,7 +14,7 @@ import { handoffLine, relayLine, resolveHarness } from '../harness.js';
|
|
|
14
14
|
import { worktreeFlow, worktreePath } from '../worktree.js';
|
|
15
15
|
import { lazyStamp } from '../stamp.js';
|
|
16
16
|
import { ok, refuse, renderRefusal, v } from '../refusal.js';
|
|
17
|
-
import { kv } from '../toon.js';
|
|
17
|
+
import { kv, rows } from '../toon.js';
|
|
18
18
|
import { boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
|
|
19
19
|
// A gate is settled only if the verdict that settled it still describes the CURRENT
|
|
20
20
|
// content. `reviewed_sha` is the tree we actually judged; when the caller can compute
|
|
@@ -60,6 +60,21 @@ function authoringOwed(entries, gate, currentSha) {
|
|
|
60
60
|
e.gate === gate &&
|
|
61
61
|
['revise', 'revise-upstream'].includes(e.decision));
|
|
62
62
|
}
|
|
63
|
+
// D75 re-arms a gate whose reviewed sha has moved, and `worktreeTreeSha` covers the
|
|
64
|
+
// WHOLE worktree — so any state commit reaching it lapses the gate. That is correct, but
|
|
65
|
+
// the row it produces is a bare `gate implement`, indistinguishable from a CLI stuck on
|
|
66
|
+
// a stale answer. A human who just watched that gate pass reads it as the latter, and
|
|
67
|
+
// with a second session in the worktree answering `ship` the pair looks like a deadlock
|
|
68
|
+
// with no error anywhere. The lapse is a fact the CLI already knows; say it.
|
|
69
|
+
function lapseNote(entries, gate, currentSha) {
|
|
70
|
+
const last = lastGateRun(entries, gate);
|
|
71
|
+
if (!last || currentSha === undefined || last.reviewed_sha === currentSha)
|
|
72
|
+
return undefined;
|
|
73
|
+
// sha-free: asks "was it ever settled", which is the only thing that can lapse
|
|
74
|
+
if (!gateSettled(entries, gate))
|
|
75
|
+
return undefined;
|
|
76
|
+
return `${gate} approval lapsed — judged @${last.reviewed_sha.slice(0, 7)}, worktree now @${currentSha.slice(0, 7)} — re-gate to judge the current tree`;
|
|
77
|
+
}
|
|
63
78
|
// The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
|
|
64
79
|
// `witness start` and ends at the merge stamp. Anything else returns undefined and is
|
|
65
80
|
// not a flow. Exported because `--flow` and the dashboard must answer with the SAME
|
|
@@ -87,8 +102,13 @@ export function flowAction(root, cfg, plan) {
|
|
|
87
102
|
const satisfied = files.length > 0 && baseR.ok && evidenceForDiff(wt, root, plan, baseR.value).satisfied;
|
|
88
103
|
if (!satisfied)
|
|
89
104
|
return { line: `witness test-evidence ${id} --phase red|green`, stage: 'implement', target: id, ...inWorktree };
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
const treeSha = worktreeTreeSha(wt);
|
|
106
|
+
if (!gateSettled(entries, 'implement', treeSha)) {
|
|
107
|
+
return {
|
|
108
|
+
line: `witness gate implement ${id}`, target: id, ...inWorktree,
|
|
109
|
+
...noteOf(lapseNote(entries, 'implement', treeSha)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
92
112
|
return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
|
|
93
113
|
}
|
|
94
114
|
// How far along a flow is — the drain order when several are actionable. Most-advanced
|
|
@@ -198,8 +218,13 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
198
218
|
.sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
|
|
199
219
|
if (flows.length > 0) {
|
|
200
220
|
const waiting = pendingDecisionsAll(root, efforts, specs, plans);
|
|
221
|
+
// noteOf, not an overwrite: a lapse note explains WHY this row is the gate rather
|
|
222
|
+
// than the ship it was a moment ago, and must not be evicted by the waiting list.
|
|
201
223
|
return waiting.length > 0
|
|
202
|
-
? {
|
|
224
|
+
? {
|
|
225
|
+
...flows[0],
|
|
226
|
+
...noteOf(flows[0].note, `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}`),
|
|
227
|
+
}
|
|
203
228
|
: flows[0];
|
|
204
229
|
}
|
|
205
230
|
// TIER 2 — human-owed decisions and bound endgames. Unchanged from today's rungs; they
|
|
@@ -398,6 +423,14 @@ export async function run(ctx, argv) {
|
|
|
398
423
|
const lazy = lazyStamp(root, ctx, canon);
|
|
399
424
|
if (lazy.stamped.length > 0)
|
|
400
425
|
canon = loadCanon(root);
|
|
426
|
+
// `dashboard` renders these; `next` computed them and dropped them. A merge stamp that
|
|
427
|
+
// cannot proceed (commit refused, PR closed unmerged, lock held) leaves the flow parked
|
|
428
|
+
// at ship forever, and `next` — the one verb the driving loop calls every turn — said
|
|
429
|
+
// nothing at all. Printed BEFORE the routing block so it cannot split the contiguous
|
|
430
|
+
// next:/stage:/target:/note:/home:/run:/relay: unit the stage skills read verbatim.
|
|
431
|
+
if (lazy.stale.length > 0) {
|
|
432
|
+
rows('stale', ['plan', 'why'], lazy.stale).forEach(ctx.out);
|
|
433
|
+
}
|
|
401
434
|
const { values } = parseArgs({ args: argv, options: { flow: { type: 'string' } }, allowPositionals: true });
|
|
402
435
|
// Precedence: an explicit --flow is a CLAIM about a flow and refuses when false.
|
|
403
436
|
// A worktree cwd is AMBIENT context — it may scope a read-only question, never select
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popovych.co/witness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "witness",
|
|
3
3
|
"description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kostiantyn Popovych"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
|
|
|
10
10
|
Resolve the CLI once:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
# context). Silent everywhere that isn't a witness repo; failures swallowed:
|
|
4
4
|
# session start must never hang or die on this.
|
|
5
5
|
[ -f witness.config.yaml ] || exit 0
|
|
6
|
-
${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
6
|
+
${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1} 2>/dev/null || true
|
|
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
|
|
|
10
10
|
Resolve the CLI once per session:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
|
|
|
16
16
|
Resolve the CLI once per session:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
19
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
|
|
@@ -69,7 +69,7 @@ $WITNESS design <spec-id> --open # opens the artifact for the human — requir
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
- **Register, then show.** `witness gate design` refuses `design-unseen` until the human has been shown the artifact's current bytes, so `--open` is part of the flow, not a nicety. Re-authoring re-arms it: new bytes, new showing. `witness next` names this step by itself — follow it.
|
|
72
|
-
- The opener failing (`opener-failed`) prints a `file://` path. Give the human that path verbatim and stop; do not work around it by pointing `
|
|
72
|
+
- The opener failing (`opener-failed`) prints a `file://` path. Give the human that path verbatim and stop; do not work around it by pointing `opener:` in `.witness/config.local.yaml` at something that does not show anything.
|
|
73
73
|
|
|
74
74
|
## Gate
|
|
75
75
|
|
|
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|