@popovych.co/witness 0.16.0 → 0.17.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 +1 -1
- package/dist/allowlist.js +6 -1
- package/dist/arbiter.js +8 -2
- package/dist/ask.js +182 -0
- package/dist/cli.js +5 -2
- package/dist/criteria.js +25 -2
- package/dist/departure.js +5 -4
- package/dist/drift.js +14 -6
- package/dist/dsl.js +67 -0
- package/dist/gate.js +7 -2
- package/dist/gates/design.js +7 -2
- package/dist/needs.js +67 -2
- package/dist/recap-check.js +66 -0
- package/dist/scan.js +23 -1
- package/dist/schemas.js +2 -1
- package/dist/settle.js +9 -2
- package/dist/ship.js +28 -9
- package/dist/verbs/adopt.js +2 -1
- package/dist/verbs/ask.js +56 -0
- package/dist/verbs/check.js +7 -3
- package/dist/verbs/dashboard.js +31 -3
- package/dist/verbs/decide.js +12 -2
- package/dist/verbs/log.js +3 -0
- package/dist/verbs/next.js +51 -5
- package/dist/verbs/recap.js +151 -14
- package/dist/verbs/rename.js +13 -0
- package/dist/verbs/satisfy.js +21 -12
- package/dist/verbs/start.js +12 -9
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +5 -3
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +13 -6
- package/plugin/skills/witness-decompose/SKILL.md +2 -2
- package/plugin/skills/witness-design/SKILL.md +10 -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/prompts/arbiter-recap.md +23 -0
- package/prompts/arbiter.md +1 -1
package/README.md
CHANGED
|
@@ -182,7 +182,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
182
182
|
| `read <id> [--design] [--outline] [--lines <a>-<b>]` | print a spec/plan/design from the primary root — the read route, since a worktree carries no canon copy; `--outline` names each anchor's line range for fat artifacts |
|
|
183
183
|
| `check` | schema, graph, invariants, needs, trailer audit, probes |
|
|
184
184
|
| `index` | id · summary · status · depends across the canon, then each spec's plans |
|
|
185
|
-
| `satisfy <id> --need <
|
|
185
|
+
| `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
|
|
186
186
|
| `log <id>` | render a journal stream |
|
|
187
187
|
| `gate <decompose\|plan\|implement\|ship\|design> <id>` | run the reviewer gate; journals the round, stamps on pass |
|
|
188
188
|
| `decide <gate> <id> --approve\|--revise [--pin <policy>]` | record the human decision on a stopped gate; `--pin` adds a standing content policy |
|
package/dist/allowlist.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { heldCriteria } from './needs.js';
|
|
3
4
|
const allowPath = (root) => join(root, '.witness', 'allow.json');
|
|
4
5
|
function load(root) {
|
|
5
6
|
if (!existsSync(allowPath(root)))
|
|
@@ -35,11 +36,15 @@ export function grantCommands(root, cmds) {
|
|
|
35
36
|
// artifact is the SPEC that owns the criteria: a plan gate judges a plan, but criteria live
|
|
36
37
|
// on the spec it derives from, and a block that listed nothing there would hide the very
|
|
37
38
|
// commands its approve is about to leave blocked.
|
|
39
|
+
//
|
|
40
|
+
// Row 179: a criterion an unmet need holds is not run, so it needs no trust until the need is
|
|
41
|
+
// met. `witness trust <id>` still lists every command, so a human may trust one ahead of time.
|
|
38
42
|
export function untrustedCmdsFor(root, doc) {
|
|
39
43
|
if (!doc)
|
|
40
44
|
return [];
|
|
45
|
+
const held = heldCriteria(doc);
|
|
41
46
|
const list = Array.isArray(doc.meta.criteria) ? doc.meta.criteria : [];
|
|
42
|
-
const cmds = list.map((c) => c.cmd).filter((c) => typeof c === 'string');
|
|
47
|
+
const cmds = list.filter((c) => !held.has(String(c.id))).map((c) => c.cmd).filter((c) => typeof c === 'string');
|
|
43
48
|
const trusted = new Set(load(root));
|
|
44
49
|
return [...new Set(cmds.filter((c) => !trusted.has(c)))];
|
|
45
50
|
}
|
package/dist/arbiter.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { askOf, askText } from './ask.js';
|
|
1
2
|
import { decidedBy } from './autonomy.js';
|
|
2
3
|
import { loadLocalConfig } from './config.js';
|
|
3
4
|
import { openDeferrals } from './deferral.js';
|
|
@@ -13,7 +14,7 @@ import { anchorRecurrence, gateRuns, roundBudget } from './rounds.js';
|
|
|
13
14
|
import { findById } from './scan.js';
|
|
14
15
|
import { canonicalJson } from './sha.js';
|
|
15
16
|
// How a later mode (plan A2's recap check) adds its own prompt without a second invoker.
|
|
16
|
-
export const PROMPT_OF = { decision: 'arbiter' };
|
|
17
|
+
export const PROMPT_OF = { decision: 'arbiter', recap: 'arbiter-recap' };
|
|
17
18
|
const TEXT_MAX = 600;
|
|
18
19
|
function renderOptions(menu) {
|
|
19
20
|
return menu.flatMap((o, i) => [
|
|
@@ -87,7 +88,7 @@ export async function arbitrate(ctx, root, cfg, req) {
|
|
|
87
88
|
}
|
|
88
89
|
const ruling = readRuling(text, req.menu.length);
|
|
89
90
|
if (ruling.ok) {
|
|
90
|
-
return ok({ ...ruling.value, model: chain[rung], prompts_sha: promptsSha([{ name:
|
|
91
|
+
return ok({ ...ruling.value, model: chain[rung], prompts_sha: promptsSha([{ name: PROMPT_OF[req.mode], contents: lens.contents }]) });
|
|
91
92
|
}
|
|
92
93
|
if (attempt === 0) {
|
|
93
94
|
// One self-repair reroll on the same rung, in the shape runBattery gives a lens.
|
|
@@ -184,6 +185,11 @@ export function decisionEvidence(root, canon, stop) {
|
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
sections.push({ title: 'Scope', body: scope.join('\n\n') });
|
|
188
|
+
// Row 177. A decompose stop is judged against the ask as well as the recap: both of its
|
|
189
|
+
// arbiter routes (the feature-scope stop and amendment-ack) re-scope what the human asked.
|
|
190
|
+
const ask = gate === 'decompose' ? askOf(root, target) : undefined;
|
|
191
|
+
if (ask !== undefined)
|
|
192
|
+
sections.push({ title: 'The ask', body: `${ask.id} — ${askText(ask.text)}` });
|
|
187
193
|
}
|
|
188
194
|
return sections;
|
|
189
195
|
}
|
package/dist/ask.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { FLOOR_STREAM } from './floor.js';
|
|
2
|
+
import { stateCommit } from './gitio.js';
|
|
3
|
+
import { appendEntry, effortStreams, entryLine, journalRel, readStream } from './journal.js';
|
|
4
|
+
import { acquireLock } from './lock.js';
|
|
5
|
+
import { ok, refuse, v } from './refusal.js';
|
|
6
|
+
import { ROUND_BOUND } from './rounds.js';
|
|
7
|
+
import { crashPoint, pendingTxn, withTxn } from './txn.js';
|
|
8
|
+
// Row 176. The human's ask is journaled state, on a stream witness owns the way it owns
|
|
9
|
+
// `floor`. This module is its one home: the entries, the queue derived from them, and the
|
|
10
|
+
// one write path the verb, `/witness <text>` and `drive --ask` share — so a crashed session
|
|
11
|
+
// loses nothing, because the brainstorm that answers an ask reads it from here, never from
|
|
12
|
+
// its prompt.
|
|
13
|
+
export const ASK_STREAM = 'asks';
|
|
14
|
+
// The house bound on review rounds: a recap sent back three times waits for a human.
|
|
15
|
+
export const RECAP_CHECK_BOUND = ROUND_BOUND;
|
|
16
|
+
export function recapChecks(root, id) {
|
|
17
|
+
return readStream(root, ASK_STREAM)
|
|
18
|
+
.filter((e) => e.t === 'human-decision' && e.gate === 'recap' && e.artifact === id);
|
|
19
|
+
}
|
|
20
|
+
// The one rendering of why a check sent a recap back, shared by `recap`'s output, `next`'s
|
|
21
|
+
// rung and the arbiter's own prior-checks section — so it lives here, and this module never
|
|
22
|
+
// imports recap-check.ts.
|
|
23
|
+
export function checkReason(e) {
|
|
24
|
+
return e.rule === 'recap-class'
|
|
25
|
+
? `class — the recap says ${e.class} and the check decided ${e.decided ?? '?'} · ${e.reason}`
|
|
26
|
+
: e.reason;
|
|
27
|
+
}
|
|
28
|
+
// Display only: the stored text is the human's, trimmed and otherwise as given.
|
|
29
|
+
export function askText(text) {
|
|
30
|
+
return oneLine(text);
|
|
31
|
+
}
|
|
32
|
+
// Text a human or a model wrote, made safe to print where a reader parses lines: the
|
|
33
|
+
// skills read `next`'s unit line by line, and a prompt section must never open a heading of
|
|
34
|
+
// its own. Whitespace runs collapse to one space; the words stay as written.
|
|
35
|
+
export function oneLine(text) {
|
|
36
|
+
return text.trim().replace(/\s+/g, ' ');
|
|
37
|
+
}
|
|
38
|
+
export function askQueue(root) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const e of readStream(root, ASK_STREAM)) {
|
|
41
|
+
if (e.t === 'ask')
|
|
42
|
+
out.push({ id: String(e.id), text: String(e.text), state: 'unanswered', checks: 0 });
|
|
43
|
+
else if (e.t === 'ask-withdrawn') {
|
|
44
|
+
const a = out.find((x) => x.id === e.ask);
|
|
45
|
+
if (a)
|
|
46
|
+
a.state = 'withdrawn';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const a of out) {
|
|
50
|
+
const checks = recapChecks(root, a.id);
|
|
51
|
+
const last = checks.at(-1);
|
|
52
|
+
a.checks = checks.length;
|
|
53
|
+
if (last?.decision === 'revise') {
|
|
54
|
+
a.reason = oneLine(checkReason({ rule: String(last.rule), class: last.class, decided: last.decided, reason: String(last.reason ?? '') }));
|
|
55
|
+
if (last.note !== undefined)
|
|
56
|
+
a.note = oneLine(last.note);
|
|
57
|
+
}
|
|
58
|
+
if (a.state !== 'unanswered' || last === undefined)
|
|
59
|
+
continue;
|
|
60
|
+
if (last.decision === 'stop')
|
|
61
|
+
a.parked = 'malformed';
|
|
62
|
+
else if (last.decision === 'revise' && checks.length >= RECAP_CHECK_BOUND)
|
|
63
|
+
a.parked = 'bound';
|
|
64
|
+
if (a.parked !== undefined)
|
|
65
|
+
a.state = 'parked';
|
|
66
|
+
}
|
|
67
|
+
// An ask is answered once any recap names it, even one an abandon later ended: re-asking is
|
|
68
|
+
// the human's act, so an abandoned effort never puts its ask back in the queue.
|
|
69
|
+
const answeredBy = new Map();
|
|
70
|
+
for (const effort of effortStreams(root)) {
|
|
71
|
+
for (const e of readStream(root, effort)) {
|
|
72
|
+
if (e.t === 'recap' && typeof e.ask === 'string' && !answeredBy.has(e.ask))
|
|
73
|
+
answeredBy.set(e.ask, effort);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const a of out) {
|
|
77
|
+
const effort = answeredBy.get(a.id);
|
|
78
|
+
if (effort !== undefined) {
|
|
79
|
+
a.state = 'answered';
|
|
80
|
+
a.effort = effort;
|
|
81
|
+
delete a.parked;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
// The one list of stream ids witness owns. A stream id is an artifact's whole history, so an
|
|
87
|
+
// effort, spec, plan or rename that took one of these would merge two histories into one.
|
|
88
|
+
export function reservedId(id) {
|
|
89
|
+
if (id === ASK_STREAM)
|
|
90
|
+
return "witness's own ask queue";
|
|
91
|
+
if (id === FLOOR_STREAM)
|
|
92
|
+
return "witness's own floor stream";
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const TEXT_REQUIRED = v('text', 'required', 'absent', 'what to build in words — witness ask <text>');
|
|
96
|
+
export function queueAsk(ctx, root, text) {
|
|
97
|
+
if (askText(text) === '')
|
|
98
|
+
return refuse([TEXT_REQUIRED]);
|
|
99
|
+
const lock = acquireLock(root);
|
|
100
|
+
if (!lock.ok)
|
|
101
|
+
return lock;
|
|
102
|
+
try {
|
|
103
|
+
// Under the lock: a writer that crashed before this one took it left its marker here.
|
|
104
|
+
const pending = pendingTxn(root);
|
|
105
|
+
if (pending)
|
|
106
|
+
return refuse([v('.witness/txn.json', 'pending-txn', pending.op, 'witness recover --complete | --rollback')]);
|
|
107
|
+
// Final review F7: a stream an older witness wrote under this name is another artifact's
|
|
108
|
+
// history, and asks appended to it would be read as part of that artifact.
|
|
109
|
+
const first = readStream(root, ASK_STREAM)[0];
|
|
110
|
+
if (first !== undefined && first.t !== 'ask') {
|
|
111
|
+
return refuse([v(ASK_STREAM, 'stream-taken', `the stream starts with ${first.t}`, 'a stream for asks alone — witness check names the artifact that holds it')]);
|
|
112
|
+
}
|
|
113
|
+
// Final review F10: the same words while that ask is still open are the same ask — a
|
|
114
|
+
// crashed `/witness <words>` run again must not queue, and build, the work twice.
|
|
115
|
+
const queue = askQueue(root);
|
|
116
|
+
const unanswered = () => askQueue(root).filter((a) => a.state === 'unanswered').length;
|
|
117
|
+
const open = queue.find((a) => (a.state === 'unanswered' || a.state === 'parked') && askText(a.text) === askText(text));
|
|
118
|
+
if (open !== undefined)
|
|
119
|
+
return ok({ id: open.id, unanswered: unanswered(), already: true });
|
|
120
|
+
// numbered under the lock: two queuers racing must not both take the next id
|
|
121
|
+
const id = `a${queue.length + 1}`;
|
|
122
|
+
const entry = { t: 'ask', id, text: text.trim() };
|
|
123
|
+
const stream = journalRel(ASK_STREAM);
|
|
124
|
+
const res = withTxn(root, { op: `ask(${id})`, files: [stream], journal: { stream: ASK_STREAM, line: entryLine(entry) } }, () => {
|
|
125
|
+
appendEntry(root, ASK_STREAM, entry);
|
|
126
|
+
crashPoint(ctx.env, 'journal-append');
|
|
127
|
+
return stateCommit(root, [stream], `ask(${id}): ${askText(text).slice(0, 60)}`);
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
return res;
|
|
131
|
+
return ok({ id, unanswered: unanswered(), already: false });
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
lock.value();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function withdrawAsk(ctx, root, id, note) {
|
|
138
|
+
const lock = acquireLock(root);
|
|
139
|
+
if (!lock.ok)
|
|
140
|
+
return lock;
|
|
141
|
+
try {
|
|
142
|
+
const pending = pendingTxn(root);
|
|
143
|
+
if (pending)
|
|
144
|
+
return refuse([v('.witness/txn.json', 'pending-txn', pending.op, 'witness recover --complete | --rollback')]);
|
|
145
|
+
const ask = askQueue(root).find((a) => a.id === id);
|
|
146
|
+
if (!ask)
|
|
147
|
+
return refuse([v('--withdraw', 'unknown-ask', id, 'an ask id — witness status lists the queue')]);
|
|
148
|
+
if (ask.state === 'withdrawn')
|
|
149
|
+
return ok({ already: true });
|
|
150
|
+
if (ask.state === 'answered') {
|
|
151
|
+
return refuse([v('--withdraw', 'ask-answered', `${id} is answered by ${ask.effort}`, `an unanswered ask — ${ask.effort} answers it now · witness abandon ${ask.effort} ends that effort`)]);
|
|
152
|
+
}
|
|
153
|
+
const entry = { t: 'ask-withdrawn', ask: id, ...(note !== undefined ? { note } : {}) };
|
|
154
|
+
const stream = journalRel(ASK_STREAM);
|
|
155
|
+
const res = withTxn(root, { op: `ask(${id})`, files: [stream], journal: { stream: ASK_STREAM, line: entryLine(entry) } }, () => {
|
|
156
|
+
appendEntry(root, ASK_STREAM, entry);
|
|
157
|
+
crashPoint(ctx.env, 'journal-append');
|
|
158
|
+
return stateCommit(root, [stream], `ask(${id}): withdrawn`);
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
return res;
|
|
162
|
+
return ok({ already: false });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
lock.value();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The one derivation of which ask is routed next: the oldest still open. A parked ask waits
|
|
169
|
+
// for a human, so it is routable whenever a human interviews and skipped under autonomy,
|
|
170
|
+
// where re-offering it would repeat a command `recap` refuses.
|
|
171
|
+
export function nextAsk(root, autonomous) {
|
|
172
|
+
return askQueue(root).find((a) => a.state === 'unanswered' || (a.state === 'parked' && !autonomous));
|
|
173
|
+
}
|
|
174
|
+
// The ask an effort answers: what the decompose arbiter judges a slicing against beside the
|
|
175
|
+
// recap itself (row 177). Named by the recap that birthed the effort — an amend carries no
|
|
176
|
+
// ask, and it is exactly a re-scoped slicing that must still be judged against the ask.
|
|
177
|
+
export function askOf(root, effort) {
|
|
178
|
+
const id = readStream(root, effort).find((e) => e.t === 'recap' && typeof e.ask === 'string')?.ask;
|
|
179
|
+
if (id === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
return readStream(root, ASK_STREAM).find((e) => e.t === 'ask' && e.id === id);
|
|
182
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ const VERBS = {
|
|
|
9
9
|
recover: () => import('./verbs/recover.js'),
|
|
10
10
|
init: () => import('./verbs/init.js'),
|
|
11
11
|
recap: () => import('./verbs/recap.js'),
|
|
12
|
+
// Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
|
|
13
|
+
ask: () => import('./verbs/ask.js'),
|
|
12
14
|
write: () => import('./verbs/write.js'),
|
|
13
15
|
// Row 132's read route. Canon lives at the primary root and nowhere else, so reading it
|
|
14
16
|
// is a verb rather than a path — which is also what lets a worktree carry no copy.
|
|
@@ -51,6 +53,7 @@ const VERBS = {
|
|
|
51
53
|
const VERB_USAGE = {
|
|
52
54
|
abandon: 'witness abandon <plan-id | effort-slug>',
|
|
53
55
|
adopt: 'witness adopt <specs/... | plans/...>.md',
|
|
56
|
+
ask: 'witness ask <text> | --withdraw <id> [--note <why>]',
|
|
54
57
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
55
58
|
check: 'witness check',
|
|
56
59
|
clean: 'witness clean',
|
|
@@ -66,10 +69,10 @@ const VERB_USAGE = {
|
|
|
66
69
|
log: 'witness log <stream> [--all] [--lineage]',
|
|
67
70
|
next: 'witness next',
|
|
68
71
|
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
|
|
69
|
-
recap: 'witness recap --file <recap.json> [--amend]',
|
|
72
|
+
recap: 'witness recap --file <recap.json> [--ask <id>] [--amend] [--manual]',
|
|
70
73
|
recover: 'witness recover [--complete | --rollback]',
|
|
71
74
|
rename: 'witness rename <old-id> <new-id>',
|
|
72
|
-
satisfy: 'witness satisfy <doc-id> --need <
|
|
75
|
+
satisfy: 'witness satisfy <doc-id> --need <id | index | text>',
|
|
73
76
|
dismiss: 'witness dismiss <artifact> --deferral <id|index> --cause <superseded|lens-retired|judged-wrong> --note "<why>"',
|
|
74
77
|
ship: 'witness ship <plan-id> [--manual]',
|
|
75
78
|
start: 'witness start <plan-id>',
|
package/dist/criteria.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { ensureTrusted } from './allowlist.js';
|
|
2
2
|
import { loadConfig } from './config.js';
|
|
3
3
|
import { matchesTag, sourceTags } from './matcher.js';
|
|
4
|
+
import { heldCriteria, satisfyCommand } from './needs.js';
|
|
4
5
|
import { ok } from './refusal.js';
|
|
5
6
|
import { criteriaExcludes, execCommand, runFiltered, runFullSuite, runnerConfig, untrustedOf } from './runner.js';
|
|
6
7
|
import { canonicalSha } from './sha.js';
|
|
8
|
+
export function criterionMark(c) {
|
|
9
|
+
if (c.held !== undefined)
|
|
10
|
+
return `held by ${c.held.join(' · ')}`;
|
|
11
|
+
return c.ok ? 'ok' : 'fail';
|
|
12
|
+
}
|
|
7
13
|
export async function runCriteria(runRoot, ctx, doc, opts = {}) {
|
|
8
14
|
const trustRoot = opts.trustRoot ?? runRoot;
|
|
9
15
|
// D154 root unification. The trust list and the runner config are two halves of one
|
|
@@ -34,9 +40,18 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
|
|
|
34
40
|
const src = rc.mode === 'filtered'
|
|
35
41
|
? sourceTags(runRoot, criteriaExcludes(cfg.value))
|
|
36
42
|
: undefined;
|
|
43
|
+
const held = heldCriteria(doc);
|
|
37
44
|
const criteria = [];
|
|
38
45
|
for (const c of list) {
|
|
39
46
|
const cid = String(c.id);
|
|
47
|
+
// A hold names a `cmd:` record, never just an id: a hand-edited doc can give a test
|
|
48
|
+
// criterion the same id, and holding it would skip the spec's tests.
|
|
49
|
+
const holders = typeof c.cmd === 'string' ? held.get(cid) : undefined;
|
|
50
|
+
if (holders !== undefined) {
|
|
51
|
+
criteria.push({ id: cid, kind: 'cmd', ok: false, held: holders,
|
|
52
|
+
detail: `held by ${holders.join(' · ')} — ${satisfyCommand(id, holders[0])}` });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
40
55
|
if (typeof c.test === 'string') {
|
|
41
56
|
if (rc.mode === 'full-suite') {
|
|
42
57
|
if (!suite) {
|
|
@@ -94,7 +109,9 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
|
|
|
94
109
|
return ok({
|
|
95
110
|
spec: id,
|
|
96
111
|
sha: canonicalSha(doc.meta, doc.body),
|
|
97
|
-
|
|
112
|
+
// A lane passes only when something actually passed and nothing failed: a held criterion
|
|
113
|
+
// is not a failure, and it is never a pass either.
|
|
114
|
+
ok: criteria.some((c) => c.ok) && criteria.every((c) => c.ok || c.held !== undefined),
|
|
98
115
|
mode: rc.mode,
|
|
99
116
|
tagCount,
|
|
100
117
|
criteria,
|
|
@@ -107,12 +124,18 @@ export function laneCheck(lane) {
|
|
|
107
124
|
const untrusted = lane.ok
|
|
108
125
|
? [...new Set(lane.value.criteria.flatMap((c) => (c.untrusted !== undefined ? [c.untrusted] : [])))]
|
|
109
126
|
: [];
|
|
127
|
+
// Row 179: a lane that passed with held criteria says so in its own record, so no reader
|
|
128
|
+
// of the gate-run can take the green for a verification of what was not run.
|
|
129
|
+
const held = lane.ok
|
|
130
|
+
? lane.value.criteria.flatMap((c) => (c.held ?? []).map((need) => ({ criterion: c.id, need, satisfy: satisfyCommand(lane.value.spec, need) })))
|
|
131
|
+
: [];
|
|
110
132
|
return {
|
|
111
133
|
name: 'drift-lane',
|
|
112
134
|
ok: lane.ok && lane.value.ok,
|
|
113
135
|
detail: lane.ok
|
|
114
|
-
? lane.value.criteria.map((c) => `${c.id}:${c
|
|
136
|
+
? lane.value.criteria.map((c) => `${c.id}:${criterionMark(c)}`).join(' · ')
|
|
115
137
|
: lane.violations.map((x) => `${x.field}:${x.rule}`).join(' · '),
|
|
116
138
|
...(untrusted.length > 0 ? { untrusted } : {}),
|
|
139
|
+
...(held.length > 0 ? { held } : {}),
|
|
117
140
|
};
|
|
118
141
|
}
|
package/dist/departure.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'n
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { deferralMoves } from './deferral.js';
|
|
4
4
|
import { designRel } from './design.js';
|
|
5
|
-
import {
|
|
5
|
+
import { reservedId } from './ask.js';
|
|
6
6
|
import { splitDoc } from './fm.js';
|
|
7
7
|
import { adoptedCommits, lastWitnessed } from './adopt.js';
|
|
8
8
|
import { gitBlob, tryGit } from './gitio.js';
|
|
@@ -130,13 +130,14 @@ function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
|
|
|
130
130
|
// Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
|
|
131
131
|
// because its stream IS that artifact's history. Three streams already belong to something
|
|
132
132
|
// else: a superseded spec's (a successor owns its content, and its finished plans resolve their
|
|
133
|
-
// parent there), an effort's, and witness's own floor pins. The one re-use is the same artifact
|
|
133
|
+
// parent there), an effort's, and witness's own (`reservedId`: the floor pins and the ask queue). The one re-use is the same artifact
|
|
134
134
|
// again: a spec its own abandon unwound may be written again as a spec. A stream that records
|
|
135
135
|
// none of these — a stray, or a doc canon cannot parse — does not hold its id; the findings
|
|
136
136
|
// that already name it (`orphan-journal`, the parse error) are its remedy.
|
|
137
137
|
export function idRetired(root, id, type) {
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
const reserved = reservedId(id);
|
|
139
|
+
if (reserved !== undefined)
|
|
140
|
+
return v('id', 'id-taken', id, `a new id — ${id} is ${reserved}`);
|
|
140
141
|
const entries = readStream(root, id);
|
|
141
142
|
if (entries[0]?.t === 'recap')
|
|
142
143
|
return v('id', 'id-taken', id, `a new id — ${id} is an effort's slug`);
|
package/dist/drift.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { EXIT } from './cli.js';
|
|
4
4
|
import { loadConfig, loadLocalConfig } from './config.js';
|
|
5
|
-
import { runCriteria } from './criteria.js';
|
|
5
|
+
import { criterionMark, runCriteria } from './criteria.js';
|
|
6
6
|
import { serializeDoc, writeDoc } from './fm.js';
|
|
7
7
|
import { acquireLock } from './lock.js';
|
|
8
8
|
import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
|
|
@@ -25,9 +25,17 @@ export function driftEntry(run, runId) {
|
|
|
25
25
|
artifact_sha: run.sha,
|
|
26
26
|
ok: run.ok,
|
|
27
27
|
tags: run.tagCount,
|
|
28
|
-
criteria: run.criteria.map(
|
|
28
|
+
criteria: run.criteria.map(criterionRecord),
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
// Row 179: the journaled record of one criterion carries its hold, so a reader of the drift
|
|
32
|
+
// history sees what was not run rather than an unexplained `ok: false` on a passing spec.
|
|
33
|
+
const criterionRecord = (c) => ({ id: c.id, kind: c.kind, ok: c.ok, detail: c.detail, ...(c.held !== undefined ? { held: c.held } : {}) });
|
|
34
|
+
// ` · <n> held` for a summary line, or nothing when the sweep held nothing.
|
|
35
|
+
const heldSuffix = (runs) => {
|
|
36
|
+
const n = runs.reduce((k, r) => k + r.criteria.filter((c) => c.held !== undefined).length, 0);
|
|
37
|
+
return n > 0 ? ` · ${n} held` : '';
|
|
38
|
+
};
|
|
31
39
|
export function trailingFails(entries) {
|
|
32
40
|
const checks = entries.filter((e) => e.t === 'drift-check');
|
|
33
41
|
let n = 0;
|
|
@@ -133,7 +141,7 @@ export async function deepDrift(root, ctx, canon, specId) {
|
|
|
133
141
|
renderRefusal(localR.violations).forEach(ctx.err);
|
|
134
142
|
return EXIT.REFUSED;
|
|
135
143
|
}
|
|
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
|
|
144
|
+
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}:${criterionMark(c)}`).join(' · ') : 'unrunnable'})\n`;
|
|
137
145
|
const invoked = invokeReviewer(ctx, hxR.value.harness, {
|
|
138
146
|
cwd: root, prompt,
|
|
139
147
|
timeoutMs: cfgR.ok ? cfgR.value.gates.reviewerTimeoutMs : undefined,
|
|
@@ -152,7 +160,7 @@ export async function deepDrift(root, ctx, canon, specId) {
|
|
|
152
160
|
v: 1, t: 'drift-check', run_id: newRunId(), artifact: specId,
|
|
153
161
|
artifact_sha: lane.ok ? lane.value.sha : canonicalSha(doc.meta, doc.body),
|
|
154
162
|
ok: lane.ok && lane.value.ok, tags: lane.ok ? lane.value.tagCount : 0,
|
|
155
|
-
criteria: lane.ok ? lane.value.criteria.map(
|
|
163
|
+
criteria: lane.ok ? lane.value.criteria.map(criterionRecord) : [],
|
|
156
164
|
deep: true,
|
|
157
165
|
...(violations.length === 0 && parsed.ok ? { verdict: parsed.value } : { malformed: violations }),
|
|
158
166
|
};
|
|
@@ -233,7 +241,7 @@ export async function runDrift(ctx, argv) {
|
|
|
233
241
|
})).forEach(ctx.out);
|
|
234
242
|
}
|
|
235
243
|
if (readonly) {
|
|
236
|
-
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing · mode read-only`));
|
|
244
|
+
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing${heldSuffix(runs)} · mode read-only`));
|
|
237
245
|
ctx.out('help: read-only run — the next local check --drift journals and stamps');
|
|
238
246
|
return failing.length ? EXIT.FINDINGS : EXIT.OK;
|
|
239
247
|
}
|
|
@@ -282,7 +290,7 @@ export async function runDrift(ctx, argv) {
|
|
|
282
290
|
const stamped = actions.filter((a) => a.stamp).length;
|
|
283
291
|
const cleared = actions.filter((a) => a.clear).length;
|
|
284
292
|
const suffix = `${stamped ? ` · stamped ${stamped}` : ''}${cleared ? ` · cleared ${cleared}` : ''}`;
|
|
285
|
-
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing · mode local${suffix}`));
|
|
293
|
+
ctx.out(kv('drift-summary', `${failing.length}/${runs.length} failing${heldSuffix(runs)} · mode local${suffix}`));
|
|
286
294
|
}
|
|
287
295
|
finally {
|
|
288
296
|
lock.ok && lock.value();
|
package/dist/dsl.js
CHANGED
|
@@ -44,6 +44,7 @@ export function validateNeeds(raw) {
|
|
|
44
44
|
return [v('needs', 'shape', typeof raw, 'list of {env} | {cmd} | {manual, satisfied}')];
|
|
45
45
|
}
|
|
46
46
|
const out = [];
|
|
47
|
+
const ids = new Set();
|
|
47
48
|
raw.forEach((n, i) => {
|
|
48
49
|
const at = `needs[${i}]`;
|
|
49
50
|
if (typeof n !== 'object' || n === null) {
|
|
@@ -63,6 +64,72 @@ export function validateNeeds(raw) {
|
|
|
63
64
|
if (kind === 'manual' && typeof e.satisfied !== 'boolean') {
|
|
64
65
|
out.push(v(`${at}.satisfied`, 'required', String(e.satisfied ?? 'absent'), 'boolean'));
|
|
65
66
|
}
|
|
67
|
+
if (e.id !== undefined) {
|
|
68
|
+
if (typeof e.id !== 'string' || !ID_RE.test(e.id))
|
|
69
|
+
out.push(v(`${at}.id`, 'id-charset', String(e.id), '[a-z0-9-]+'));
|
|
70
|
+
else if (ids.has(e.id))
|
|
71
|
+
out.push(v(`${at}.id`, 'id-unique', e.id, 'unique per doc'));
|
|
72
|
+
else
|
|
73
|
+
ids.add(e.id);
|
|
74
|
+
}
|
|
75
|
+
if (e.holds === undefined)
|
|
76
|
+
return;
|
|
77
|
+
if (kind !== 'manual') {
|
|
78
|
+
out.push(v(`${at}.holds`, 'forbidden', JSON.stringify(e.holds), 'holds rides a manual need'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const holds = e.holds;
|
|
82
|
+
if (!Array.isArray(holds) || holds.length === 0 || new Set(holds).size !== holds.length ||
|
|
83
|
+
holds.some((h) => typeof h !== 'string' || !ID_RE.test(h))) {
|
|
84
|
+
out.push(v(`${at}.holds`, 'shape', JSON.stringify(holds), 'a non-empty list of criterion ids'));
|
|
85
|
+
}
|
|
86
|
+
if (e.id === undefined)
|
|
87
|
+
out.push(v(`${at}.id`, 'required', 'absent', 'an id — a held criterion reports held by <id>'));
|
|
66
88
|
});
|
|
67
89
|
return out;
|
|
68
90
|
}
|
|
91
|
+
// Row 179. What a need's `holds` may name, judged against the doc it sits in: a spec's own
|
|
92
|
+
// `cmd:` criteria only — test criteria share one @spec run, so holding one holds none — and
|
|
93
|
+
// never all of them, so the lane always runs something while every need is unmet. The
|
|
94
|
+
// shapes themselves are validateNeeds'; a list it refused is not judged twice here.
|
|
95
|
+
export function validateHolds(meta) {
|
|
96
|
+
const needs = Array.isArray(meta.needs) ? meta.needs : [];
|
|
97
|
+
const criteria = meta.type === 'spec' && Array.isArray(meta.criteria)
|
|
98
|
+
? meta.criteria.filter((c) => typeof c === 'object' && c !== null)
|
|
99
|
+
: [];
|
|
100
|
+
const ids = (pick) => criteria.filter((c) => typeof c.id === 'string' && pick(c)).map((c) => c.id);
|
|
101
|
+
const cmdIds = ids((c) => typeof c.cmd === 'string');
|
|
102
|
+
const testIds = ids((c) => typeof c.test === 'string');
|
|
103
|
+
const out = [];
|
|
104
|
+
const held = new Set();
|
|
105
|
+
needs.forEach((n, i) => {
|
|
106
|
+
if (typeof n !== 'object' || n === null)
|
|
107
|
+
return;
|
|
108
|
+
const e = n;
|
|
109
|
+
if (typeof e.manual !== 'string' || !Array.isArray(e.holds))
|
|
110
|
+
return;
|
|
111
|
+
if (meta.type !== 'spec') {
|
|
112
|
+
out.push(v(`needs[${i}].holds`, 'forbidden', JSON.stringify(e.holds), 'holds is spec-only — a plan need blocks its start instead'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
e.holds.forEach((h, j) => {
|
|
116
|
+
if (typeof h !== 'string')
|
|
117
|
+
return;
|
|
118
|
+
const at = `needs[${i}].holds[${j}]`;
|
|
119
|
+
if (testIds.includes(h)) {
|
|
120
|
+
out.push(v(at, 'held-test-criterion', h, 'a cmd criterion — test criteria share one @spec run so holding one holds none'));
|
|
121
|
+
}
|
|
122
|
+
else if (!cmdIds.includes(h)) {
|
|
123
|
+
out.push(v(at, 'unknown-criterion', h, `a cmd criterion id of this spec: ${cmdIds.join(' · ') || '(none)'}`));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
held.add(h);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
const all = ids(() => true);
|
|
131
|
+
if (held.size > 0 && all.every((id) => held.has(id))) {
|
|
132
|
+
out.push(v('needs', 'holds-every-criterion', [...held].join(' · '), 'at least one criterion the lane runs while every need is unmet'));
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/dist/gate.js
CHANGED
|
@@ -65,6 +65,9 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
|
|
|
65
65
|
if (entry.rerolled?.length)
|
|
66
66
|
ctx.out(kv('rerolled', entry.rerolled.join(' · ')));
|
|
67
67
|
ctx.out(rows('checks', ['name', 'ok', 'detail'], entry.checks.map((c) => ({ name: c.name, ok: String(c.ok), detail: c.detail ?? '' }))).join('\n'));
|
|
68
|
+
// Row 179: what the lane did not run, beside the row that passed without it.
|
|
69
|
+
for (const h of entry.checks.flatMap((c) => c.held ?? []))
|
|
70
|
+
ctx.out(`held: ${h.criterion} — ${h.need} unmet · ${h.satisfy}`);
|
|
68
71
|
const findings = (entry.verdicts ?? []).flatMap((rv) => rv.findings.map((f) => ({
|
|
69
72
|
reviewer: rv.reviewer, blocking: String(f.blocking),
|
|
70
73
|
pin: f.contradicts_pin !== undefined ? `#${f.contradicts_pin}` : '',
|
|
@@ -188,7 +191,7 @@ async function judge(ctx, gateName, target, flags) {
|
|
|
188
191
|
return owed !== undefined && !owed.manual;
|
|
189
192
|
};
|
|
190
193
|
const canon = loadCanon(root);
|
|
191
|
-
const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target);
|
|
194
|
+
const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target, { autonomous });
|
|
192
195
|
if (!inputR.ok) {
|
|
193
196
|
renderRefusal(inputR.violations).forEach((l) => ctx.err(l));
|
|
194
197
|
return EXIT.REFUSED;
|
|
@@ -300,7 +303,9 @@ async function judge(ctx, gateName, target, flags) {
|
|
|
300
303
|
// A check trust kept from running and one that ran and failed are both `ok: false`, but they
|
|
301
304
|
// are different judgments: granting the command flips the first into the second, and without
|
|
302
305
|
// this term the gate resumed the blocked round forever while the policy re-ran it (F3).
|
|
303
|
-
|
|
306
|
+
// A hold that lifts or lands is a new judgment too (row 179): the lane may stay green while
|
|
307
|
+
// the set of criteria it did not run changes, and a resumed round would re-render a stale `held:`.
|
|
308
|
+
const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}${(c.untrusted?.length ?? 0) > 0 ? ':blocked' : ''}:${(c.held ?? []).map((h) => h.criterion).sort().join('+')}`).sort().join('|');
|
|
304
309
|
if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
|
|
305
310
|
checkOk(kind.entry.checks) !== checkOk(input.checks) &&
|
|
306
311
|
kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
|
package/dist/gates/design.js
CHANGED
|
@@ -15,7 +15,7 @@ const reviewedShaOf = (html, spec) => html !== undefined ? designPairSha(html, s
|
|
|
15
15
|
registerGate({
|
|
16
16
|
gate: 'design',
|
|
17
17
|
targetKind: 'spec',
|
|
18
|
-
async resolve(root, _ctx, canon, cfg, specId) {
|
|
18
|
+
async resolve(root, _ctx, canon, cfg, specId, run) {
|
|
19
19
|
const spec = findById(canon, specId);
|
|
20
20
|
if (!spec || spec.meta.type !== 'spec') {
|
|
21
21
|
return refuse([v('spec', 'unknown-spec', specId, 'a specs/ doc id')]);
|
|
@@ -31,7 +31,12 @@ registerGate({
|
|
|
31
31
|
// changed-nothing, bound, malformed-streak), producing a window the journal never
|
|
32
32
|
// learns about. An agent that drops the show step now hits a loud refusal its repair
|
|
33
33
|
// loop already handles, instead of silently gating something nobody was shown.
|
|
34
|
-
|
|
34
|
+
//
|
|
35
|
+
// Row 178 moves sight from the gate to the approval for an autonomous run. Gating unseen
|
|
36
|
+
// mattered because a human decided each round from findings; under autonomy nobody does
|
|
37
|
+
// until the kept stop, and `decide design --approve` still refuses a look nobody opened,
|
|
38
|
+
// so the human's stop is where it is opened and nothing stamps a look unseen.
|
|
39
|
+
const unseen = run?.autonomous ? undefined : designUnseen(root, cfg.paths, specId);
|
|
35
40
|
if (unseen !== undefined) {
|
|
36
41
|
return refuse([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${specId} --open`)]);
|
|
37
42
|
}
|