@popovych.co/witness 0.16.0 → 0.18.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 +11 -3
- package/dist/allowlist.js +6 -1
- package/dist/arbiter.js +8 -2
- package/dist/ask.js +182 -0
- package/dist/awake.js +16 -0
- package/dist/caps.js +74 -0
- package/dist/cli.js +6 -3
- package/dist/config.js +86 -10
- package/dist/criteria.js +25 -2
- package/dist/departure.js +5 -4
- package/dist/drift.js +14 -6
- package/dist/drive-presence.js +104 -0
- package/dist/drive-report.js +104 -0
- package/dist/drive.js +510 -113
- package/dist/dsl.js +67 -0
- package/dist/evidence.js +42 -9
- package/dist/gate.js +7 -2
- package/dist/gates/design.js +7 -2
- package/dist/gitio.js +9 -0
- package/dist/harness.js +11 -4
- package/dist/lock.js +167 -16
- package/dist/needs.js +67 -2
- package/dist/notify.js +31 -0
- package/dist/opener.js +1 -1
- package/dist/recap-check.js +66 -0
- package/dist/reviewer.js +53 -4
- package/dist/runner-lock.js +129 -0
- package/dist/runner.js +4 -0
- package/dist/scan.js +23 -1
- package/dist/schemas.js +2 -1
- package/dist/settle.js +18 -5
- package/dist/ship.js +28 -9
- package/dist/txn.js +17 -2
- package/dist/usage.js +145 -0
- 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 +23 -5
- package/dist/verbs/drive.js +84 -14
- package/dist/verbs/evidence.js +29 -47
- package/dist/verbs/init.js +3 -0
- package/dist/verbs/log.js +3 -0
- package/dist/verbs/next.js +119 -13
- 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 +8 -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
|
@@ -149,6 +149,12 @@ either.
|
|
|
149
149
|
| `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
|
|
150
150
|
| `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
|
|
151
151
|
| `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
|
|
152
|
+
| `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
|
|
153
|
+
| `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
|
|
154
|
+
| `drive.caps.rounds` | gate rounds one target may run across budget resets (default 16) — the run parks a target that reaches it |
|
|
155
|
+
| `drive.caps.upstream` | upstream resets one target may take (default 6) — the run parks a target that reaches it |
|
|
156
|
+
| `drive.caps.spawns` | sessions one target may take in one drive run (default 24) — the run parks a target that reaches it |
|
|
157
|
+
| `drive.caps.wallClockMs` | milliseconds of session time one target may take in one drive run (default 57600000 · 16 h) — the run parks a target that reaches it |
|
|
152
158
|
| `autonomy: on \| off` | whether the CLI settles its own stops — through its recommendation or the arbiter it launches — and merges its own PRs once CI is green (default off). A committed repo fact: it counts only once HEAD says it, and the merge also needs the ship branch on origin to say it. `--manual` on a gate run keeps that run's stop for a human, and three stops always reach one: the look of a new screen, an unmet plan need and a trust grant |
|
|
153
159
|
| `ship.merge: merge \| squash \| rebase` | how `witness ship` merges its own PR under `autonomy: on` (default merge) — only once the ship gate is approved and CI is green, and never when the approval lapsed or GitHub refuses |
|
|
154
160
|
| `ship.ciSettleMs` | milliseconds a green check set must hold still before `witness ship` merges under autonomy (default 60000, at most 1800000) — gh's watch ends the moment nothing is pending, so a check that registers late would otherwise be missed. Green means at least one check passed and none failed, was cancelled or is pending |
|
|
@@ -165,6 +171,7 @@ are declared in machine config, not env:
|
|
|
165
171
|
reviewerExtensions:
|
|
166
172
|
- /Users/you/.pi/agent/npm/node_modules/pi-claude-oauth-adapter
|
|
167
173
|
opener: xdg-open # optional; nonstandard desktops only
|
|
174
|
+
notify: /usr/local/bin/ping-me # optional; the run summary is its one argument · default: the platform notifier
|
|
168
175
|
```
|
|
169
176
|
|
|
170
177
|
Repo facts live in `witness.config.yaml` (committed); machine facts live in
|
|
@@ -182,14 +189,14 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
182
189
|
| `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
190
|
| `check` | schema, graph, invariants, needs, trailer audit, probes |
|
|
184
191
|
| `index` | id · summary · status · depends across the canon, then each spec's plans |
|
|
185
|
-
| `satisfy <id> --need <
|
|
192
|
+
| `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
|
|
186
193
|
| `log <id>` | render a journal stream |
|
|
187
194
|
| `gate <decompose\|plan\|implement\|ship\|design> <id>` | run the reviewer gate; journals the round, stamps on pass |
|
|
188
195
|
| `decide <gate> <id> --approve\|--revise [--pin <policy>]` | record the human decision on a stopped gate; `--pin` adds a standing content policy |
|
|
189
196
|
| `design <spec-id> --file <html>\|--open\|--reconfirm` | register/show a `ui` spec's approved look |
|
|
190
197
|
| `start <plan-id>` | create/re-attach the plan's worktree (`.witness/worktrees/<id>`) — code only: the canon dirs are excluded from it, so a plan cannot go stale in there |
|
|
191
198
|
| `next` | the one next action across every effort |
|
|
192
|
-
| `drive [--flow <plan-id>] [--max-spawns <n>]` |
|
|
199
|
+
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"]` | runs every session itself · under autonomy it needs no terminal and reports when nothing else can move. Up to `drive.parallel` targets at once, one session per target; usage limits are waited out; a target past a cap is parked. `--detach` (autonomy only) runs it in the background into `.witness/drive.log`; `--ask` queues an ask first; `--manual` runs serially and asks you at every stop. One drive per repo. A session drive spawned is refused — agents are what drive spawns, never what spawns drive |
|
|
193
200
|
| `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
|
|
194
201
|
| `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
|
|
195
202
|
| `adopt <path>` | absolve a finished hand-edit into the journal |
|
|
@@ -234,7 +241,8 @@ depends on none of them (DESIGN.md row 79):
|
|
|
234
241
|
|
|
235
242
|
- **Keep the host awake.** A suspend longer than an hour mid-run expires the prompt
|
|
236
243
|
cache; on wake the full agent context is re-written at cache-write price. On macOS:
|
|
237
|
-
`caffeinate -
|
|
244
|
+
`witness drive` holds `caffeinate -ims` for its own lifetime (a closed lid on battery
|
|
245
|
+
still sleeps); for a chat session, `caffeinate -dims`, or plug in and disable sleep.
|
|
238
246
|
- **`CLAUDE_CODE_AUTO_COMPACT_WINDOW`** (set at CLI launch) lowers the harness's
|
|
239
247
|
auto-compaction threshold as belt-and-suspenders under the dispatch budget. It is
|
|
240
248
|
documented for main sessions only — whether subagents honor it is unverified.
|
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/awake.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function keepAwake(env, platform, pid) {
|
|
3
|
+
// Test seam, on WITNESS_DRIVE_AGENT_BIN's precedent: never documented.
|
|
4
|
+
const bin = env.WITNESS_KEEP_AWAKE_BIN ?? (platform === 'darwin' ? 'caffeinate' : undefined);
|
|
5
|
+
if (bin === undefined)
|
|
6
|
+
return { stop: () => { } };
|
|
7
|
+
const args = ['-ims', '-w', String(pid)];
|
|
8
|
+
const child = spawn(bin, args, { stdio: 'ignore' });
|
|
9
|
+
child.on('error', () => { }); // a missing binary costs the guard, never the run
|
|
10
|
+
child.unref();
|
|
11
|
+
return {
|
|
12
|
+
command: [bin, ...args].join(' '),
|
|
13
|
+
stop: () => { if (child.exitCode === null && child.signalCode === null)
|
|
14
|
+
child.kill('SIGTERM'); },
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/caps.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { EXIT } from './cli.js';
|
|
2
|
+
import { fellBack, gateRuns, lastGateRun } from './rounds.js';
|
|
3
|
+
import { duration } from './usage.js';
|
|
4
|
+
import { gateSettled } from './verbs/next.js';
|
|
5
|
+
const isCapPark = (e) => {
|
|
6
|
+
if (e.t !== 'human-decision')
|
|
7
|
+
return false;
|
|
8
|
+
const d = e;
|
|
9
|
+
return d.selected === 'policy' && typeof d.rule === 'string' && d.rule.startsWith('cap-');
|
|
10
|
+
};
|
|
11
|
+
// The gates still open on a target — run, and neither settled nor approved — most recent
|
|
12
|
+
// last. A cap parks only on one of these (F7): a flow whose gates converged is moving, and a
|
|
13
|
+
// stop journaled on a settled gate would hide it behind a reopen that refuses.
|
|
14
|
+
function openGates(entries) {
|
|
15
|
+
const order = entries.filter((e) => e.t === 'gate-run').map((e) => String(e.gate));
|
|
16
|
+
return [...new Set(order.reverse())].reverse().filter((g) => !gateSettled(entries, g));
|
|
17
|
+
}
|
|
18
|
+
// First hit wins, in this order: rounds · upstream · spawns · wall clock.
|
|
19
|
+
export function capHit(entries, use, caps) {
|
|
20
|
+
let lastPark = -1;
|
|
21
|
+
entries.forEach((e, i) => { if (isCapPark(e))
|
|
22
|
+
lastPark = i; });
|
|
23
|
+
const since = entries.slice(lastPark + 1);
|
|
24
|
+
// Every budget reset is ignored: this counts the rounds a gate ran, not its window. The
|
|
25
|
+
// exemptions are `roundsSinceApprove`'s — a malformed or fallen-back round spends nothing.
|
|
26
|
+
const gates = [...new Set(since.filter((e) => e.t === 'gate-run').map((e) => String(e.gate)))];
|
|
27
|
+
for (const gate of gates) {
|
|
28
|
+
const n = gateRuns(since, gate).filter((r) => r.outcome !== 'malformed' && !fellBack(r)).length;
|
|
29
|
+
// a gate that converged at the cap is not capped
|
|
30
|
+
if (n >= caps.rounds && !gateSettled(entries, gate)) {
|
|
31
|
+
return { cap: 'rounds', gate, note: `drive cap: ${gate} ran ${n} rounds across budget resets · drive.caps.rounds ${caps.rounds}` };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const upstream = since.filter((e) => e.t === 'human-decision' &&
|
|
35
|
+
e.decision === 'revise-upstream').length;
|
|
36
|
+
if (upstream >= caps.upstream && openGates(entries).length > 0) {
|
|
37
|
+
return { cap: 'upstream', note: `drive cap: ${upstream} upstream resets · drive.caps.upstream ${caps.upstream}` };
|
|
38
|
+
}
|
|
39
|
+
if (use.spawns >= caps.spawns) {
|
|
40
|
+
return { cap: 'spawns', note: `drive cap: ${use.spawns} session(s) this run · drive.caps.spawns ${caps.spawns}` };
|
|
41
|
+
}
|
|
42
|
+
if (use.ms >= caps.wallClockMs) {
|
|
43
|
+
return {
|
|
44
|
+
cap: 'wall-clock',
|
|
45
|
+
note: `drive cap: ${duration(use.ms)} of sessions this run · drive.caps.wallClockMs ${duration(caps.wallClockMs)}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// The park goes through A1's one door with `selected: 'policy'` and the cap as its rule.
|
|
51
|
+
// `park` anchors it on the gate's last run even after the author moved the tree. A target
|
|
52
|
+
// with no open gate has nothing to park on; a refusal from the door is named, not guessed.
|
|
53
|
+
export async function parkAtCap(ctx, root, target, hit) {
|
|
54
|
+
const { readStream } = await import('./journal.js');
|
|
55
|
+
const entries = readStream(root, target);
|
|
56
|
+
if (!entries.some((e) => e.t === 'gate-run'))
|
|
57
|
+
return { parked: false, why: 'no gate run to park it on' };
|
|
58
|
+
const open = openGates(entries);
|
|
59
|
+
const gate = hit.gate !== undefined && open.includes(hit.gate) ? hit.gate : open.at(-1);
|
|
60
|
+
const last = gate !== undefined ? lastGateRun(entries, gate) : undefined;
|
|
61
|
+
if (gate === undefined || last === undefined)
|
|
62
|
+
return { parked: false, why: 'no open gate to park it on' };
|
|
63
|
+
// Loaded on A1's precedent for settle.ts: decide.ts reaches the gate registry.
|
|
64
|
+
const { applyDecision } = await import('./verbs/decide.js');
|
|
65
|
+
const errs = [];
|
|
66
|
+
const code = await applyDecision({ ...ctx, cwd: root, err: (l) => { errs.push(l); ctx.err(l); } }, {
|
|
67
|
+
gate, target, decision: 'stop', note: hit.note, selected: 'policy', rule: `cap-${hit.cap}`, park: true, runId: last.run_id,
|
|
68
|
+
});
|
|
69
|
+
if (code === EXIT.OK)
|
|
70
|
+
return { parked: true };
|
|
71
|
+
// the refusal's rows are indented under its header; the rule is each row's second field
|
|
72
|
+
const rule = errs.map((l) => /^\s+[^,]+,([a-z-]+),/.exec(l)?.[1]).find((r) => r !== undefined);
|
|
73
|
+
return { parked: false, why: `the policy could not park it (${rule ?? `exit ${code}`})` };
|
|
74
|
+
}
|
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',
|
|
@@ -58,7 +61,7 @@ const VERB_USAGE = {
|
|
|
58
61
|
floor: 'witness floor --show | --set <triple> --note <why>',
|
|
59
62
|
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
60
63
|
diff: 'witness diff <spec-id>',
|
|
61
|
-
drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] —
|
|
64
|
+
drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"] — runs every session itself · under autonomy it needs no terminal and reports when nothing else can move',
|
|
62
65
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
|
63
66
|
gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
|
|
64
67
|
index: 'witness index',
|
|
@@ -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/config.js
CHANGED
|
@@ -104,20 +104,87 @@ export function resolveImplement(raw) {
|
|
|
104
104
|
// once, a starved one kills a working e2e run and hands the human a false `spawn-timeout`
|
|
105
105
|
// to debug. The ceiling exists to break a hung child, not to pace a healthy one.
|
|
106
106
|
export const DEFAULT_SESSION_TIMEOUT_MS = 3_600_000;
|
|
107
|
+
// Rows 181 and 183. Drive runs up to `parallel` sessions at once, one per target (design
|
|
108
|
+
// §11 call 5). The caps park a target that will not converge; their defaults are fewhats's
|
|
109
|
+
// 2026-10-19 calibration (B5): rounds = the largest normal merged plan's 13 implement rounds
|
|
110
|
+
// plus one budget window · upstream = where the human abandoned thread-mirror-plan-1 ·
|
|
111
|
+
// spawns = half again over the busiest normal merged plan's 16 sessions · wall clock = 16 h,
|
|
112
|
+
// over the slowest normal merged plan's ~14 h in worktree sessions.
|
|
113
|
+
export const DEFAULT_PARALLEL = 2;
|
|
114
|
+
export const DEFAULT_CAPS = { rounds: 16, upstream: 6, spawns: 24, wallClockMs: 57_600_000 };
|
|
115
|
+
const DRIVE_WANTS = {
|
|
116
|
+
sessionTimeoutMs: 'an integer >= 1 — milliseconds each spawned session may take before SIGTERM',
|
|
117
|
+
parallel: 'an integer >= 1 — sessions drive runs at once · one per target',
|
|
118
|
+
};
|
|
119
|
+
const CAP_WANTS = {
|
|
120
|
+
rounds: 'an integer >= 1 — gate rounds one target may run across budget resets before drive parks it',
|
|
121
|
+
upstream: 'an integer >= 1 — upstream resets one target may take before drive parks it',
|
|
122
|
+
spawns: 'an integer >= 1 — sessions one target may take in one drive run',
|
|
123
|
+
wallClockMs: 'an integer >= 1 — milliseconds of session time one target may take in one drive run',
|
|
124
|
+
};
|
|
125
|
+
const DRIVE_KEYS = ['sessionTimeoutMs', 'parallel', 'parallelSuites', 'caps'];
|
|
126
|
+
const isCount = (val) => typeof val === 'number' && Number.isInteger(val) && val >= 1;
|
|
127
|
+
const isMap = (val) => typeof val === 'object' && val !== null && !Array.isArray(val);
|
|
128
|
+
// Strict: no consumer sets `drive:` today, so a typo'd key refusing costs nobody, while a
|
|
129
|
+
// typo'd cap silently defaulting would let a flow run past the bound its author meant.
|
|
107
130
|
export function resolveDrive(raw) {
|
|
108
131
|
const conf = raw.drive;
|
|
132
|
+
const defaults = {
|
|
133
|
+
sessionTimeoutMs: DEFAULT_SESSION_TIMEOUT_MS, parallel: DEFAULT_PARALLEL, parallelSuites: false, caps: { ...DEFAULT_CAPS },
|
|
134
|
+
};
|
|
109
135
|
if (conf === undefined)
|
|
110
|
-
return ok(
|
|
111
|
-
if (
|
|
136
|
+
return ok(defaults);
|
|
137
|
+
if (!isMap(conf))
|
|
112
138
|
return refuse([v('drive', 'invalid', String(conf), 'a map of drive settings')]);
|
|
139
|
+
const violations = [];
|
|
140
|
+
for (const key of Object.keys(conf)) {
|
|
141
|
+
if (!DRIVE_KEYS.includes(key)) {
|
|
142
|
+
violations.push(v(`drive.${key}`, 'unknown-drive-key', key, `a drive setting (${DRIVE_KEYS.join(' | ')})`));
|
|
143
|
+
}
|
|
113
144
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
145
|
+
const out = defaults;
|
|
146
|
+
for (const key of ['sessionTimeoutMs', 'parallel']) {
|
|
147
|
+
const val = conf[key];
|
|
148
|
+
if (val === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
if (!isCount(val))
|
|
151
|
+
violations.push(v(`drive.${key}`, 'invalid', String(val), DRIVE_WANTS[key]));
|
|
152
|
+
else
|
|
153
|
+
out[key] = val;
|
|
154
|
+
}
|
|
155
|
+
if (conf.parallelSuites !== undefined) {
|
|
156
|
+
if (typeof conf.parallelSuites !== 'boolean') {
|
|
157
|
+
violations.push(v('drive.parallelSuites', 'invalid', String(conf.parallelSuites), 'true | false — whether test runs in two worktrees may overlap'));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
out.parallelSuites = conf.parallelSuites;
|
|
161
|
+
}
|
|
119
162
|
}
|
|
120
|
-
|
|
163
|
+
const caps = conf.caps;
|
|
164
|
+
if (caps !== undefined) {
|
|
165
|
+
if (!isMap(caps)) {
|
|
166
|
+
violations.push(v('drive.caps', 'invalid', String(caps), 'a map of cap settings'));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
for (const key of Object.keys(caps)) {
|
|
170
|
+
if (!(key in CAP_WANTS)) {
|
|
171
|
+
violations.push(v(`drive.caps.${key}`, 'unknown-drive-key', key, `a cap (${Object.keys(CAP_WANTS).join(' | ')})`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const key of Object.keys(CAP_WANTS)) {
|
|
175
|
+
const val = caps[key];
|
|
176
|
+
if (val === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
if (!isCount(val))
|
|
179
|
+
violations.push(v(`drive.caps.${key}`, 'invalid', String(val), CAP_WANTS[key]));
|
|
180
|
+
else
|
|
181
|
+
out.caps[key] = val;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (violations.length)
|
|
186
|
+
return refuse(violations);
|
|
187
|
+
return ok(out);
|
|
121
188
|
}
|
|
122
189
|
export const DEFAULT_REVIEWER_TIMEOUT_MS = 600_000;
|
|
123
190
|
// gates.* is otherwise read loosely at its consumers (stagePin); the timeout is
|
|
@@ -188,7 +255,7 @@ export function autonomyAt(root, rev) {
|
|
|
188
255
|
}
|
|
189
256
|
}
|
|
190
257
|
const committedAutonomy = (root) => autonomyAt(root, 'HEAD');
|
|
191
|
-
const LOCAL_KEYS = ['reviewerExtensions', 'opener'];
|
|
258
|
+
const LOCAL_KEYS = ['reviewerExtensions', 'opener', 'notify'];
|
|
192
259
|
export const localConfigPath = (root) => join(root, '.witness', 'config.local.yaml');
|
|
193
260
|
export function loadLocalConfig(root) {
|
|
194
261
|
const p = localConfigPath(root);
|
|
@@ -230,9 +297,18 @@ export function loadLocalConfig(root) {
|
|
|
230
297
|
opener = obj.opener;
|
|
231
298
|
}
|
|
232
299
|
}
|
|
300
|
+
let notify;
|
|
301
|
+
if (obj.notify !== undefined) {
|
|
302
|
+
if (typeof obj.notify !== 'string' || obj.notify === '') {
|
|
303
|
+
violations.push(v('notify', 'invalid', String(obj.notify), 'a command name or path — it receives the run summary as its one argument'));
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
notify = obj.notify;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
233
309
|
if (violations.length)
|
|
234
310
|
return refuse(violations);
|
|
235
|
-
return ok({ reviewerExtensions, ...(opener !== undefined ? { opener } : {}) });
|
|
311
|
+
return ok({ reviewerExtensions, ...(opener !== undefined ? { opener } : {}), ...(notify !== undefined ? { notify } : {}) });
|
|
236
312
|
}
|
|
237
313
|
// Lenient path resolution for callers without a loaded Config (canon scan, git
|
|
238
314
|
// state scoping): a missing or broken config falls back to the defaults — the
|
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`);
|