@popovych.co/witness 0.9.0 → 0.11.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 +15 -0
- package/dist/cli.js +45 -6
- package/dist/deferral.js +43 -0
- package/dist/floor.js +74 -0
- package/dist/gate.js +50 -11
- package/dist/gates/decompose.js +5 -0
- package/dist/gates/design.js +6 -0
- package/dist/gates/implement.js +6 -0
- package/dist/gates/plan.js +4 -0
- package/dist/gates/ship.js +5 -0
- package/dist/install.js +40 -1
- package/dist/journal.js +8 -1
- package/dist/recommend.js +243 -0
- package/dist/rounds.js +92 -9
- package/dist/ship.js +16 -5
- package/dist/stamp.js +22 -3
- package/dist/toon.js +8 -0
- package/dist/verbs/check.js +16 -6
- package/dist/verbs/dashboard.js +77 -1
- package/dist/verbs/decide.js +76 -12
- package/dist/verbs/dismiss.js +94 -0
- package/dist/verbs/floor.js +49 -0
- package/dist/verbs/init.js +20 -3
- package/dist/verbs/next.js +180 -26
- package/dist/verbs/recap.js +4 -2
- package/dist/verbs/recover.js +35 -2
- package/dist/verbs/write.js +7 -2
- package/dist/version.js +12 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +4 -3
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +4 -2
- package/plugin/skills/witness-decompose/SKILL.md +4 -2
- package/plugin/skills/witness-design/SKILL.md +6 -4
- package/plugin/skills/witness-implement/SKILL.md +4 -2
- package/plugin/skills/witness-plan/SKILL.md +4 -2
- package/plugin/skills/witness-ship/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -117,6 +117,21 @@ witness commits the payload. It refuses the whole run if a payload path carries
|
|
|
117
117
|
uncommitted change (`payload-dirty`) or if the CLI you are running is older than the
|
|
118
118
|
payload already installed (`cli-behind-payload`).
|
|
119
119
|
|
|
120
|
+
From **0.10.0** onward `init` installs into **every home of the repository** — the primary
|
|
121
|
+
root and each live `.witness/worktrees/<plan-id>` — because a worktree is a branch
|
|
122
|
+
checkout, so its payload is a different file on a different timeline. A worktree cut
|
|
123
|
+
before an upgrade used to keep the old pin forever, and the old pin is what selects the
|
|
124
|
+
CLI that runs there. Every home is preflighted before any is written, so a dirty payload
|
|
125
|
+
in one refuses the whole run rather than leaving the set half-upgraded, and `witness check`
|
|
126
|
+
now reports `payload-stale` per home and names the stale one.
|
|
127
|
+
|
|
128
|
+
The same release makes the bound structural: every journal entry records the CLI that
|
|
129
|
+
wrote it, and a CLI older than anything the repository's state has seen refuses every verb
|
|
130
|
+
with `cli-behind-state` rather than answering from rules the repository has moved past.
|
|
131
|
+
`witness floor --show` reports the bound. If you are deliberately rolling back — a bad
|
|
132
|
+
release, say — lower it first with `witness floor --set <version> --note <why>`; the
|
|
133
|
+
decision is journaled, and `floor` is the one verb that still runs under a bound it fails.
|
|
134
|
+
|
|
120
135
|
### Support tiers
|
|
121
136
|
|
|
122
137
|
| Tier | Agents | What they get |
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
3
2
|
import { configPath } from './config.js';
|
|
3
|
+
import { stateFloor } from './floor.js';
|
|
4
4
|
import { primaryRoot } from './gitio.js';
|
|
5
5
|
import { renderRefusal, v } from './refusal.js';
|
|
6
|
+
import { NPX_LATEST, compareTriple, version } from './version.js';
|
|
6
7
|
export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
7
8
|
const VERBS = {
|
|
8
9
|
recover: () => import('./verbs/recover.js'),
|
|
@@ -13,6 +14,7 @@ const VERBS = {
|
|
|
13
14
|
check: () => import('./verbs/check.js'),
|
|
14
15
|
index: () => import('./verbs/index-verb.js'),
|
|
15
16
|
satisfy: () => import('./verbs/satisfy.js'),
|
|
17
|
+
dismiss: () => import('./verbs/dismiss.js'),
|
|
16
18
|
log: () => import('./verbs/log.js'),
|
|
17
19
|
'test-evidence': () => import('./verbs/evidence.js'),
|
|
18
20
|
'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
|
|
@@ -33,6 +35,9 @@ const VERBS = {
|
|
|
33
35
|
rename: () => import('./verbs/rename.js'),
|
|
34
36
|
sync: () => import('./verbs/sync.js'),
|
|
35
37
|
calibrate: () => import('./verbs/calibrate.js'),
|
|
38
|
+
// Row 116's valve. Exempt from the floor gate in `main`, because a repository that
|
|
39
|
+
// refuses every verb must still be able to run the one that unrefuses it.
|
|
40
|
+
floor: () => import('./verbs/floor.js'),
|
|
36
41
|
};
|
|
37
42
|
// answered centrally so no verb can crash on --help (several parse argv
|
|
38
43
|
// strictly); each line mirrors the verb's own usage refusal where one exists
|
|
@@ -43,6 +48,7 @@ const VERB_USAGE = {
|
|
|
43
48
|
check: 'witness check',
|
|
44
49
|
clean: 'witness clean',
|
|
45
50
|
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
|
|
51
|
+
floor: 'witness floor --show | --set <triple> --note <why>',
|
|
46
52
|
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
47
53
|
diff: 'witness diff <spec-id>',
|
|
48
54
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
|
@@ -55,6 +61,7 @@ const VERB_USAGE = {
|
|
|
55
61
|
recover: 'witness recover [--complete | --rollback]',
|
|
56
62
|
rename: 'witness rename <old-id> <new-id>',
|
|
57
63
|
satisfy: 'witness satisfy <doc-id> --need <text | index>',
|
|
64
|
+
dismiss: 'witness dismiss <artifact> --deferral <id|index> --cause <superseded|lens-retired|judged-wrong> --note "<why>"',
|
|
58
65
|
ship: 'witness ship <plan-id>',
|
|
59
66
|
start: 'witness start <plan-id>',
|
|
60
67
|
status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
|
|
@@ -62,10 +69,10 @@ const VERB_USAGE = {
|
|
|
62
69
|
'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
|
|
63
70
|
'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
|
|
64
71
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
72
|
+
// Row 116 moved the body to version.ts, which journal.ts can import without closing a
|
|
73
|
+
// cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
|
|
74
|
+
// the question they are asking has not changed.
|
|
75
|
+
export { version };
|
|
69
76
|
export function usage() {
|
|
70
77
|
return [
|
|
71
78
|
'witness — spec-led pipeline: specs are state, plans are motion',
|
|
@@ -80,6 +87,38 @@ export async function main(ctx, argv) {
|
|
|
80
87
|
ctx.out(version());
|
|
81
88
|
return EXIT.OK;
|
|
82
89
|
}
|
|
90
|
+
// Row 116. Above the verb table on purpose — including above the bare-`witness`
|
|
91
|
+
// dashboard route, which reads the same state every verb does. The invariant is "one
|
|
92
|
+
// repository, one witness version", and a per-verb opt-in is a list some future verb
|
|
93
|
+
// forgets to join.
|
|
94
|
+
//
|
|
95
|
+
// This is the guard row 102's `cli-behind-payload` structurally could not be. That one
|
|
96
|
+
// fires inside the home it protects, so a home frozen at an old pin runs an old CLI that
|
|
97
|
+
// never learned to check — which is how two sessions a lifecycle apart each computed a
|
|
98
|
+
// different next stage, each redirected to the other's home, and each honestly ended its
|
|
99
|
+
// turn. The state is the one thing every home shares, so the state is where the bound
|
|
100
|
+
// belongs.
|
|
101
|
+
//
|
|
102
|
+
// Forward-only, and the text must never imply otherwise: a CLI published before this row
|
|
103
|
+
// cannot run this code. Homes already frozen are found by `check` and repaired by `init`.
|
|
104
|
+
//
|
|
105
|
+
// Two exemptions, both load-bearing. `floor` is how a bad publish is recovered from — a
|
|
106
|
+
// repository that refuses every verb must still be able to run the one that unrefuses it.
|
|
107
|
+
// An explicit help request is information, and refusing to print a usage line teaches
|
|
108
|
+
// the human nothing they can act on.
|
|
109
|
+
const helpRequested = verb === 'help' || verb === '--help'
|
|
110
|
+
|| rest.includes('--help') || rest.includes('-h');
|
|
111
|
+
if (!helpRequested && verb !== 'floor') {
|
|
112
|
+
const gateRoot = primaryRoot(ctx.cwd);
|
|
113
|
+
if (gateRoot.ok) {
|
|
114
|
+
const floor = stateFloor(gateRoot.value);
|
|
115
|
+
if (floor !== undefined && (compareTriple(version(), floor.pin) ?? 0) < 0) {
|
|
116
|
+
renderRefusal([v('witness', 'cli-behind-state', `this CLI is ${version()}, this repository's state was written by ${floor.pin}`, `a CLI at or ahead of ${floor.pin} — run ${NPX_LATEST} init --agent <name>; if you are `
|
|
117
|
+
+ `deliberately rolling back, lower the bound first with witness floor --set ${version()} --note <why>`)]).forEach((l) => ctx.err(l));
|
|
118
|
+
return EXIT.REFUSED;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
83
122
|
if (!verb || verb === 'help' || verb === '--help') {
|
|
84
123
|
if (!verb) {
|
|
85
124
|
const rootRes = primaryRoot(ctx.cwd);
|
package/dist/deferral.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
export const DISMISS_CAUSES = ['superseded', 'lens-retired', 'judged-wrong'];
|
|
3
|
+
// D122. A minted id, never a derived ordinal: the debt is re-booked onto the parent spec
|
|
4
|
+
// when its flow completes, and a per-stream ordinal renumbers when it changes homes — so
|
|
5
|
+
// "how long has this been open" becomes unanswerable across the move, and age is the only
|
|
6
|
+
// thing separating a fresh deferral from a chronic one. Same shape as `newRunId`.
|
|
7
|
+
export const newDeferralId = () => `d-${randomBytes(4).toString('hex')}`;
|
|
8
|
+
// State is an append-only FOLD, never a mutation: `deferral` opens, `deferral-moved`
|
|
9
|
+
// closes it on THIS stream (it continues on another), `deferral-retyped` changes only its
|
|
10
|
+
// kind, and discharge/dismiss close it for good.
|
|
11
|
+
export function openDeferrals(entries) {
|
|
12
|
+
const open = new Map();
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
const id = typeof e.id === 'string' ? e.id : undefined;
|
|
15
|
+
if (id === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
if (e.t === 'deferral')
|
|
18
|
+
open.set(id, e);
|
|
19
|
+
else if (e.t === 'deferral-moved' || e.t === 'deferral-discharged' || e.t === 'deferral-dismissed')
|
|
20
|
+
open.delete(id);
|
|
21
|
+
else if (e.t === 'deferral-retyped') {
|
|
22
|
+
const cur = open.get(id);
|
|
23
|
+
if (cur)
|
|
24
|
+
open.set(id, { ...cur, kind: e.kind });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...open.values()];
|
|
28
|
+
}
|
|
29
|
+
// Injected into every lens, exactly as `pinsBlock` is, and joined to `prompts_sha` so a new
|
|
30
|
+
// obligation invalidates the verdict cache and the next round cannot judge without it.
|
|
31
|
+
// Deliberately the INVERSE of a pin: a pin tells reviewers not to re-litigate, this tells
|
|
32
|
+
// them to report the thing if it is still there. Pins suppress findings; obligations
|
|
33
|
+
// solicit them — which is what makes the discharge automatic and evidence-shaped.
|
|
34
|
+
export function deferralsBlock(open) {
|
|
35
|
+
if (open.length === 0)
|
|
36
|
+
return '';
|
|
37
|
+
return '## Open deferrals (human overrides — report these if they still hold)\n\n' +
|
|
38
|
+
'A human approved this artifact over the findings below rather than fixing them. They are ' +
|
|
39
|
+
'NOT settled policy and they are NOT pins. If the defect is still present, report it as a ' +
|
|
40
|
+
'finding and anchor it exactly as listed. If it is gone, say so in your coverage. Silence ' +
|
|
41
|
+
'is read as "still present".\n\n' +
|
|
42
|
+
open.map((d, i) => `${i + 1}. ${d.anchor} — ${d.gate} round ${d.round} (${d.id})`).join('\n') + '\n\n';
|
|
43
|
+
}
|
package/dist/floor.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { journalRel } from './journal.js';
|
|
4
|
+
import { compareTriple } from './version.js';
|
|
5
|
+
// Row 116. The state's own answer to "what is the oldest CLI allowed to touch this
|
|
6
|
+
// repository", derived from the `w` stamps entryLine writes and stored nowhere. Derived
|
|
7
|
+
// because a stored floor is a second source of truth that drifts from the entries it
|
|
8
|
+
// summarises — the shape rows 93, 95 and 96 all name — and because a derived one cannot
|
|
9
|
+
// be lowered by editing a file, only by a journaled decision (verbs/floor.ts).
|
|
10
|
+
//
|
|
11
|
+
// MAXIMUM, not most-recent: a downgraded CLI writing a lower stamp after a higher one is
|
|
12
|
+
// exactly the event this guards against, and a floor that fell to the last writer would
|
|
13
|
+
// ratify the regression it exists to refuse.
|
|
14
|
+
//
|
|
15
|
+
// `undefined` means the state carries no readable stamp at all — every repository written
|
|
16
|
+
// before this row shipped. It is silence, not zero: a floor of 0.0.0 would be a claim the
|
|
17
|
+
// state never made, and it would refuse nothing anyway.
|
|
18
|
+
export function stateFloor(root) {
|
|
19
|
+
const dir = join(root, '.witness', 'journal');
|
|
20
|
+
if (!existsSync(dir))
|
|
21
|
+
return undefined;
|
|
22
|
+
// An explicit decision outranks the derived maximum — including downward, which is the
|
|
23
|
+
// only reason the verb exists. Latest wins, so a second rollback supersedes the first:
|
|
24
|
+
// the last decision is the state, the same doctrine D94 applies to gate decisions.
|
|
25
|
+
const pinned = entries(root, FLOOR_STREAM)
|
|
26
|
+
.filter((e) => e.t === 'policy-pin' && e.key === FLOOR_KEY && typeof e.pin === 'string')
|
|
27
|
+
.at(-1);
|
|
28
|
+
if (pinned !== undefined)
|
|
29
|
+
return { pin: pinned.pin, stream: 'a human decision' };
|
|
30
|
+
let best;
|
|
31
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort()) {
|
|
32
|
+
const stream = file.slice(0, -'.jsonl'.length);
|
|
33
|
+
for (const e of entries(root, stream)) {
|
|
34
|
+
if (typeof e.w !== 'string')
|
|
35
|
+
continue;
|
|
36
|
+
const pin = e.w;
|
|
37
|
+
// `compareTriple(pin, pin)` is the parse check: undefined means CANNOT COMPARE, the
|
|
38
|
+
// same contract install.ts's two guards read it under. A stamp we cannot read must
|
|
39
|
+
// never raise a bound — that would refuse a legal CLI over a typo.
|
|
40
|
+
if (compareTriple(pin, pin) === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
if (best === undefined || (compareTriple(pin, best.pin) ?? 0) > 0)
|
|
43
|
+
best = { pin, stream };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best;
|
|
47
|
+
}
|
|
48
|
+
// The journal stream a floor decision lives in. Its first entry is a policy-pin, never a
|
|
49
|
+
// recap, so `effortStreams` does not mistake it for an effort.
|
|
50
|
+
export const FLOOR_STREAM = 'floor';
|
|
51
|
+
export const FLOOR_KEY = 'floor';
|
|
52
|
+
// Deliberately NOT journal.ts's readStream, whose bare JSON.parse throws. This runs on
|
|
53
|
+
// EVERY verb now, so one unreadable line would brick the whole CLI — including `floor`
|
|
54
|
+
// itself, the one verb that could unstick it. readStream's strict parse stays right for
|
|
55
|
+
// callers that need the entry; a bound derived from stamps needs only the lines it can
|
|
56
|
+
// actually read, and skipping the rest is the honest reading of a damaged file.
|
|
57
|
+
function entries(root, stream) {
|
|
58
|
+
const path = join(root, journalRel(stream));
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
return [];
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const raw of readFileSync(path, 'utf8').split('\n')) {
|
|
63
|
+
if (raw === '')
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
out.push(JSON.parse(raw));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// a line we cannot parse carries no decision and no stamp we can trust — the same
|
|
70
|
+
// answer as a line that never had one
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
package/dist/gate.js
CHANGED
|
@@ -7,10 +7,11 @@ import { acquireLock } from './lock.js';
|
|
|
7
7
|
import { appendEntry, entryLine, journalRel, policyPins, readStream } from './journal.js';
|
|
8
8
|
import { primaryRoot, stateCommit } from './gitio.js';
|
|
9
9
|
import { loadCanon, findById } from './scan.js';
|
|
10
|
+
import { deferralsBlock, openDeferrals } from './deferral.js';
|
|
10
11
|
import { newRunId } from './drift.js';
|
|
11
12
|
import { resolveJudge } from './harness.js';
|
|
12
13
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
13
|
-
import { kv, rows } from './toon.js';
|
|
14
|
+
import { cmd, kv, rows } from './toon.js';
|
|
14
15
|
import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
|
|
15
16
|
import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
|
|
16
17
|
import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
|
|
@@ -18,6 +19,7 @@ import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits
|
|
|
18
19
|
export { liveExits } from './rounds.js';
|
|
19
20
|
import { gateSettled } from './verbs/next.js';
|
|
20
21
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
22
|
+
import { recommend, renderDecision } from './recommend.js';
|
|
21
23
|
const GATES = new Map();
|
|
22
24
|
export function registerGate(spec) { GATES.set(spec.gate, spec); }
|
|
23
25
|
export function gateSpec(name) { return GATES.get(name); }
|
|
@@ -87,9 +89,22 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
|
|
|
87
89
|
// Without `entries` this stays a pure formatter over the entry it was handed, and the
|
|
88
90
|
// off-bound triple is the right answer; with them it tells the truth at the bound too.
|
|
89
91
|
if (opts.help !== false)
|
|
90
|
-
ctx
|
|
92
|
+
renderChoices(ctx, entry.gate, entry.artifact, entries, opts.upstream);
|
|
91
93
|
}
|
|
92
94
|
}
|
|
95
|
+
// D121: the exits line stated which commands would not refuse and never which to take, so
|
|
96
|
+
// --approve read as the default at stops with a live blocking finding. The block ranks them.
|
|
97
|
+
// One renderer for all four decision surfaces in this file — a per-site copy is how D119's
|
|
98
|
+
// nine hand-copied exits sets happened, and three of these four sites were among them.
|
|
99
|
+
// `undefined` from `recommend` means no decision exists at this state (stale below the
|
|
100
|
+
// bound, where the only live act is a re-gate); the exits line still answers that.
|
|
101
|
+
function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
|
|
102
|
+
const d = recommend({ gate, target, entries, upstream, stale });
|
|
103
|
+
if (d)
|
|
104
|
+
renderDecision(d).forEach((l) => ctx.out(l));
|
|
105
|
+
else
|
|
106
|
+
ctx.out(cmd('help', liveExits(gate, target, entries, stale, upstream)));
|
|
107
|
+
}
|
|
93
108
|
// Row 79: the human approving a plan sees the run's shape before it starts.
|
|
94
109
|
// Display only — no lens, no threshold; loads config/canon itself so the
|
|
95
110
|
// entry-renderers stay pure formatters.
|
|
@@ -133,6 +148,9 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
133
148
|
return EXIT.REFUSED;
|
|
134
149
|
}
|
|
135
150
|
const input = inputR.value;
|
|
151
|
+
// Resolved once for every render this run performs — the entry renderers stay pure
|
|
152
|
+
// formatters (they take the id, they never look it up).
|
|
153
|
+
const upstreamId = spec.upstreamOf?.(root, canon, target);
|
|
136
154
|
const batteryR = batteryFor(cfgR.value, spec.gate, input.class);
|
|
137
155
|
if (!batteryR.ok) {
|
|
138
156
|
renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
|
|
@@ -144,6 +162,11 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
144
162
|
const entries = readStream(root, target);
|
|
145
163
|
const pins = policyPins(entries);
|
|
146
164
|
const pinsText = pinsBlock(pins);
|
|
165
|
+
// D122. Open obligations join the prompt exactly as pins do, and join `prompts_sha` for
|
|
166
|
+
// the same reason: an obligation the battery did not see is an obligation nothing can
|
|
167
|
+
// discharge. Minting one therefore guarantees the next round re-invokes the battery,
|
|
168
|
+
// which is the intended cost of an override rather than an accident of the cache.
|
|
169
|
+
const deferralsText = deferralsBlock(openDeferrals(entries));
|
|
147
170
|
const lenses = [];
|
|
148
171
|
for (const name of active) {
|
|
149
172
|
const lensR = resolvePrompt(name);
|
|
@@ -201,7 +224,8 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
201
224
|
const pin = chain[0];
|
|
202
225
|
const key = {
|
|
203
226
|
reviewed_sha: input.reviewedSha, gate: spec.gate,
|
|
204
|
-
prompts_sha: promptsSha(lenses, pinsText
|
|
227
|
+
prompts_sha: promptsSha(lenses, [pinsText, deferralsText].filter((t) => t !== '').join('') || undefined),
|
|
228
|
+
pin, witness: version(),
|
|
205
229
|
harness: harness.name,
|
|
206
230
|
};
|
|
207
231
|
// D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
|
|
@@ -241,21 +265,22 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
241
265
|
ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
|
|
242
266
|
}
|
|
243
267
|
if (kind.kind === 'resume') {
|
|
244
|
-
renderGateRun(ctx, kind.entry, 'resume', { entries });
|
|
268
|
+
renderGateRun(ctx, kind.entry, 'resume', { entries, upstream: upstreamId });
|
|
245
269
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
246
270
|
return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
|
|
247
271
|
}
|
|
248
272
|
if (kind.kind === 'changed-nothing') {
|
|
249
273
|
ctx.out(kv('gate', spec.gate));
|
|
250
274
|
ctx.out(kv('outcome', 'revise changed nothing — reviewed content is identical to the last round'));
|
|
251
|
-
|
|
275
|
+
// The owed work is an EDIT, so the exits are the decisions that remain legal without
|
|
276
|
+
// one — the same set every other screen shows, rather than prose naming two of four.
|
|
277
|
+
renderChoices(ctx, spec.gate, target, entries, upstreamId);
|
|
252
278
|
return EXIT.FINDINGS;
|
|
253
279
|
}
|
|
254
280
|
if (boundReached(entries, spec.gate)) {
|
|
255
281
|
ctx.out(kv('gate', spec.gate));
|
|
256
282
|
ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entries, spec.gate)} rounds since last approve)`));
|
|
257
|
-
ctx
|
|
258
|
-
ctx.out(`help: or discard the plan: witness abandon ${target}`);
|
|
283
|
+
renderChoices(ctx, spec.gate, target, entries, upstreamId);
|
|
259
284
|
return EXIT.BLOCKED;
|
|
260
285
|
}
|
|
261
286
|
if (!flags.fresh) {
|
|
@@ -311,7 +336,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
311
336
|
const reviewed = ov?.reviewed ?? input.reviewed;
|
|
312
337
|
const body = ov?.promptBody ?? input.promptBody;
|
|
313
338
|
const menu = anchorMenu(reviewed);
|
|
314
|
-
let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
|
|
339
|
+
let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${deferralsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
|
|
315
340
|
for (let attempt = 0;; attempt++) {
|
|
316
341
|
let answered;
|
|
317
342
|
for (;;) {
|
|
@@ -389,8 +414,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
389
414
|
if (boundReached(entriesNow, spec.gate)) {
|
|
390
415
|
ctx.out(kv('gate', spec.gate));
|
|
391
416
|
ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entriesNow, spec.gate)} rounds since last approve)`));
|
|
392
|
-
ctx
|
|
393
|
-
ctx.out(`help: or discard the plan: witness abandon ${target}`);
|
|
417
|
+
renderChoices(ctx, spec.gate, target, entriesNow, upstreamId);
|
|
394
418
|
return EXIT.BLOCKED;
|
|
395
419
|
}
|
|
396
420
|
const entry = {
|
|
@@ -418,6 +442,16 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
418
442
|
? [prepareStamp(doc, s.to, 'gate-approve', { run_id: entry.run_id })] : [];
|
|
419
443
|
})
|
|
420
444
|
: [];
|
|
445
|
+
// D122. The honest closure: a battery LOOKED and no longer found it. Only a run that
|
|
446
|
+
// actually judged this anchor's artifact can discharge — a malformed round judged nothing
|
|
447
|
+
// and must never close a debt, which is the same reason it does not spend the budget
|
|
448
|
+
// (row 67). Silence in a real verdict is the evidence; that is exactly what the injected
|
|
449
|
+
// block tells the reviewer, so the reviewer knows silence will be read that way.
|
|
450
|
+
const reported = new Set((entry.verdicts ?? []).flatMap((rv) => rv.findings
|
|
451
|
+
.map((f) => (typeof f.anchor === 'string' ? f.anchor : `omission:${f.anchor.scope}`))));
|
|
452
|
+
const discharged = entry.outcome === 'malformed' ? []
|
|
453
|
+
: openDeferrals(entriesNow).filter((d) => !reported.has(d.anchor))
|
|
454
|
+
.map((d) => ({ v: 1, t: 'deferral-discharged', id: d.id, artifact: target, by_run: entry.run_id }));
|
|
421
455
|
const files = [
|
|
422
456
|
journalRel(target), ...stamps.flatMap((s) => [s.rel, journalRel(s.stream)]),
|
|
423
457
|
...(input.repin ? [input.repin.rel] : []),
|
|
@@ -428,10 +462,13 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
428
462
|
journalMulti: [
|
|
429
463
|
{ stream: target, line: entryLine(entry) },
|
|
430
464
|
...stamps.map((s) => ({ stream: s.stream, line: s.line })),
|
|
465
|
+
...discharged.map((d) => ({ stream: target, line: entryLine(d) })),
|
|
431
466
|
],
|
|
432
467
|
};
|
|
433
468
|
const txn = withTxn(root, marker, () => {
|
|
434
469
|
appendEntry(root, target, entry);
|
|
470
|
+
for (const d of discharged)
|
|
471
|
+
appendEntry(root, target, d);
|
|
435
472
|
for (const s of stamps)
|
|
436
473
|
writeStamp(root, s);
|
|
437
474
|
if (input.repin) {
|
|
@@ -444,8 +481,10 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
444
481
|
renderRefusal(txn.violations).forEach((l) => ctx.err(l));
|
|
445
482
|
return EXIT.REFUSED;
|
|
446
483
|
}
|
|
447
|
-
renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
|
|
484
|
+
renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry], upstream: upstreamId });
|
|
448
485
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
486
|
+
for (const d of discharged)
|
|
487
|
+
ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
|
|
449
488
|
if (input.repin)
|
|
450
489
|
ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
|
|
451
490
|
return outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
|
package/dist/gates/decompose.js
CHANGED
|
@@ -84,6 +84,11 @@ registerGate({
|
|
|
84
84
|
currentSha(root, canon, _cfg, effort) {
|
|
85
85
|
return effortReviewedSha(root, canon, effort).sha;
|
|
86
86
|
},
|
|
87
|
+
// Upstream from decompose is the scope itself — `decide` routes it to `witness recap
|
|
88
|
+
// --amend` rather than a second stream, so the id it names is the effort.
|
|
89
|
+
upstreamOf(root, _canon, effort) {
|
|
90
|
+
return streamExists(root, effort) ? effort : undefined;
|
|
91
|
+
},
|
|
87
92
|
approveStamps(root, canon, effort) {
|
|
88
93
|
return stampsFor(effortSpecs(root, canon, effort));
|
|
89
94
|
},
|
package/dist/gates/design.js
CHANGED
|
@@ -67,6 +67,12 @@ registerGate({
|
|
|
67
67
|
const abs = join(root, designRel(cfg.paths, specId));
|
|
68
68
|
return reviewedShaOf(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
|
|
69
69
|
},
|
|
70
|
+
// Upstream from the design gate is the spec's SLICING, which reopens the owning
|
|
71
|
+
// effort's decompose (Decision 52) — so the id is the effort, not the spec.
|
|
72
|
+
upstreamOf(root, canon, specId) {
|
|
73
|
+
const spec = findById(canon, specId);
|
|
74
|
+
return spec && spec.meta.type === 'spec' ? effortOf(root, specId) : undefined;
|
|
75
|
+
},
|
|
70
76
|
approveMeta(root, canon, cfg, specId) {
|
|
71
77
|
const spec = findById(canon, specId);
|
|
72
78
|
if (!spec || spec.meta.type !== 'spec')
|
package/dist/gates/implement.js
CHANGED
|
@@ -165,4 +165,10 @@ registerGate({
|
|
|
165
165
|
// un-settle a gate or invert `decide --show`'s staleness line.
|
|
166
166
|
return baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
|
|
167
167
|
},
|
|
168
|
+
// The implement gate judges a plan's CODE, so upstream is the plan itself — reopening
|
|
169
|
+
// the plan stage, which is where an unimplementable step is answered.
|
|
170
|
+
upstreamOf(_root, canon, planId) {
|
|
171
|
+
const plan = findById(canon, planId);
|
|
172
|
+
return plan && plan.meta.type === 'plan' ? planId : undefined;
|
|
173
|
+
},
|
|
168
174
|
});
|
package/dist/gates/plan.js
CHANGED
|
@@ -79,6 +79,10 @@ registerGate({
|
|
|
79
79
|
const parent = plan ? findById(canon, String(plan.meta.parent)) : undefined;
|
|
80
80
|
return plan && parent ? planPairSha(plan, parent) : undefined;
|
|
81
81
|
},
|
|
82
|
+
upstreamOf(_root, canon, planId) {
|
|
83
|
+
const plan = findById(canon, planId);
|
|
84
|
+
return plan && plan.meta.type === 'plan' ? String(plan.meta.parent) : undefined;
|
|
85
|
+
},
|
|
82
86
|
approveStamps(_root, canon, planId) {
|
|
83
87
|
const doc = findById(canon, planId);
|
|
84
88
|
return doc && String(doc.meta.status) === 'draft' ? [{ artifact: planId, to: 'approved' }] : [];
|
package/dist/gates/ship.js
CHANGED
|
@@ -105,4 +105,9 @@ registerGate({
|
|
|
105
105
|
// undefined is "cannot compute", never "moved" — same doctrine as the implement gate.
|
|
106
106
|
return baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
|
|
107
107
|
},
|
|
108
|
+
// Same as the implement gate: what ships is a plan's code, so upstream is the plan.
|
|
109
|
+
upstreamOf(_root, canon, planId) {
|
|
110
|
+
const plan = findById(canon, planId);
|
|
111
|
+
return plan && plan.meta.type === 'plan' ? planId : undefined;
|
|
112
|
+
},
|
|
108
113
|
});
|
package/dist/install.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { version } from './cli.js';
|
|
@@ -114,6 +114,45 @@ export function installPayload(root, harness) {
|
|
|
114
114
|
}
|
|
115
115
|
return ok(out);
|
|
116
116
|
}
|
|
117
|
+
// Row 117. "Upgrade the repository" has always meant "upgrade one checkout of it":
|
|
118
|
+
// installPayload had exactly one call site and it targeted the primary root, so nothing
|
|
119
|
+
// ever refreshed a live worktree. A worktree is a branch checkout, so its payload is a
|
|
120
|
+
// different file on a different timeline — the same property row 87 relies on to get the
|
|
121
|
+
// payload to the agent at all, read the other way round.
|
|
122
|
+
//
|
|
123
|
+
// Root first, then worktrees in sorted order: the root is the home the human is standing
|
|
124
|
+
// in, and if a later home fails they need the report to read top-down.
|
|
125
|
+
export function payloadHomes(root) {
|
|
126
|
+
const dir = join(root, '.witness', 'worktrees');
|
|
127
|
+
if (!existsSync(dir))
|
|
128
|
+
return [root];
|
|
129
|
+
return [root, ...readdirSync(dir, { withFileTypes: true })
|
|
130
|
+
.filter((e) => e.isDirectory())
|
|
131
|
+
.map((e) => join(dir, e.name))
|
|
132
|
+
.sort()];
|
|
133
|
+
}
|
|
134
|
+
// Preflight EVERY home before writing ANY. install.ts's standing doctrine is that a
|
|
135
|
+
// refusal leaves nothing half-installed; across homes that matters more, not less — a
|
|
136
|
+
// half-upgraded set of homes IS the skew row 116 refuses and this row prevents.
|
|
137
|
+
export function installAllHomes(root, harness) {
|
|
138
|
+
const homes = payloadHomes(root);
|
|
139
|
+
const failures = homes.flatMap((home) => {
|
|
140
|
+
const pre = preflightPayload(home, harness);
|
|
141
|
+
// Name the home in the field, or a `payload-dirty` row over `.claude/commands/witness.md`
|
|
142
|
+
// is ambiguous across four checkouts of the same repository.
|
|
143
|
+
return pre.ok ? [] : pre.violations.map((x) => ({ ...x, field: home === root ? x.field : `${home}: ${x.field}` }));
|
|
144
|
+
});
|
|
145
|
+
if (failures.length > 0)
|
|
146
|
+
return refuse(failures);
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const home of homes) {
|
|
149
|
+
const synced = installPayload(home, harness);
|
|
150
|
+
if (!synced.ok)
|
|
151
|
+
return refuse(synced.violations);
|
|
152
|
+
out.push({ home, synced: synced.value });
|
|
153
|
+
}
|
|
154
|
+
return ok(out);
|
|
155
|
+
}
|
|
117
156
|
function hasCommand(entries, command) {
|
|
118
157
|
return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
|
|
119
158
|
}
|
package/dist/journal.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { version } from './version.js';
|
|
3
4
|
const dir = (root) => join(root, '.witness', 'journal');
|
|
4
5
|
export const journalRel = (id) => `.witness/journal/${id}.jsonl`;
|
|
5
6
|
export function streamExists(root, id) {
|
|
6
7
|
return existsSync(join(root, journalRel(id)));
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
+
// Row 116. `w` is the CLI that wrote this line. Stamped HERE because entryLine is the
|
|
10
|
+
// single funnel every state write passes through, so one edit covers fourteen entry types
|
|
11
|
+
// and every verb without a widened signature to disagree over. It is what lets the state
|
|
12
|
+
// name its own floor (floor.ts) instead of a stored number that drifts from the entries it
|
|
13
|
+
// summarises — and it is why a repo can refuse a CLI older than its own history without
|
|
14
|
+
// consulting any payload file, which is the guard row 102 structurally could not be.
|
|
15
|
+
export const entryLine = (entry) => JSON.stringify({ v: 1, w: version(), ...entry });
|
|
9
16
|
export function appendEntry(root, id, entry) {
|
|
10
17
|
mkdirSync(dir(root), { recursive: true });
|
|
11
18
|
const line = entryLine(entry);
|