@popovych.co/witness 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +1 -1
- package/README.md +7 -3
- package/dist/ask.js +8 -2
- package/dist/cli.js +4 -1
- package/dist/config-file.js +5 -2
- package/dist/drafts.js +168 -0
- package/dist/gitio.js +6 -1
- package/dist/idlists.js +31 -0
- package/dist/install.js +3 -1
- package/dist/released-comments.js +79 -0
- package/dist/slice.js +5 -1
- package/dist/verbs/decide.js +8 -1
- package/dist/verbs/design.js +36 -3
- package/dist/verbs/draft.js +107 -0
- package/dist/verbs/init.js +1 -0
- package/dist/verbs/next.js +35 -4
- package/dist/verbs/recap.js +13 -29
- package/dist/worktree.js +8 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +4 -4
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +73 -28
- package/plugin/skills/witness-decompose/SKILL.md +4 -3
- package/plugin/skills/witness-design/SKILL.md +34 -9
- package/plugin/skills/witness-implement/SKILL.md +2 -2
- package/plugin/skills/witness-plan/SKILL.md +2 -2
- package/plugin/skills/witness-ship/SKILL.md +2 -2
- package/dist/legacy-comments.js +0 -30
package/NOTICE.md
CHANGED
|
@@ -28,7 +28,7 @@ From **superpowers** (obra) — `LICENSES/superpowers-MIT.txt`:
|
|
|
28
28
|
|
|
29
29
|
From **mattpocock/skills** — `LICENSES/mattpocock-skills-MIT.txt`:
|
|
30
30
|
|
|
31
|
-
- `plugin/skills/witness-brainstorm/SKILL.md` (← grill-me: one
|
|
31
|
+
- `plugin/skills/witness-brainstorm/SKILL.md` (← grill-me: the whole decision tree, one question at a time with a recommended answer, the codebase explored instead of asking)
|
|
32
32
|
- `plugin/skills/witness-implement/SKILL.md` (← tdd: red/green loop discipline)
|
|
33
33
|
|
|
34
34
|
From **no-mistakes / axi family** (kunchenguid) — `LICENSES/no-mistakes-MIT.txt`:
|
package/README.md
CHANGED
|
@@ -235,7 +235,10 @@ check set has held still for `ship.ciSettleMs`: gh's watch ends the moment nothi
|
|
|
235
235
|
a check that registers late would otherwise be missed. Green means at least one check passed and
|
|
236
236
|
none failed, was cancelled or is pending. Brainstorm and design stay conversations with a human in
|
|
237
237
|
every mode: `/witness` starts drive for the machine work and keeps the human's own items —
|
|
238
|
-
decisions left for them, asks to grill, looks to design — in the chat.
|
|
238
|
+
decisions left for them, asks to grill, looks to design — in the chat. Brainstorm is a grill: one
|
|
239
|
+
plain-words question per turn with up to three ranked answers, the repo explored first, and a local
|
|
240
|
+
draft after every settled answer, so a dead session resumes. Design shows up to three rendered,
|
|
241
|
+
checked directions on one page before it asks which.
|
|
239
242
|
|
|
240
243
|
**`drive`.** `drive.parallelSuites: false` makes test runs in two worktrees take turns, because
|
|
241
244
|
suites that boot servers or browsers collide. A target that reaches any `drive.caps.*` bound is
|
|
@@ -275,6 +278,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
275
278
|
| --- | --- |
|
|
276
279
|
| `init [--agent claude-code\|pi\|auto]` | scaffold config, principles, journal — one trailer commit; `--agent` also installs that harness's engine, guard and dashboard (idempotent) |
|
|
277
280
|
| `recap [--amend] --file <json>` | validate + persist a scope recap — class, goals, non-goals, constraints and numbered decisions; births the effort journal |
|
|
281
|
+
| `draft <key> [--file <json>]` | a grill's or design session's local draft — saved after every settled answer, read back to resume; never committed or journaled |
|
|
278
282
|
| `write <id> --effort <e> --meta <json> --body <md>` | validated manifest → spec/plan on disk + journal entry; a spec's `covers` and `carries` map it to the recap's goals and decisions |
|
|
279
283
|
| `diff <spec-id>` | delta since the last realized state (plan pin → empty), and the decisions that moved since that plan |
|
|
280
284
|
| `read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]` | 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; `--decisions` prints the decisions a spec carries with its effort's constraints and non-goals, or an effort's whole recap |
|
|
@@ -284,7 +288,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
284
288
|
| `log <id>` | render a journal stream |
|
|
285
289
|
| `gate <decompose\|plan\|implement\|ship\|design> <id>` | run the reviewer gate; journals the round, stamps on pass |
|
|
286
290
|
| `decide <gate> <id> --approve\|--revise [--pin <policy>]` | record the human decision on a stopped gate; `--pin` adds a standing content policy |
|
|
287
|
-
| `design <spec-id> --file <html>\|--open\|--reconfirm
|
|
291
|
+
| `design <spec-id> --file <html>\|--open\|--reconfirm\|--preview <html>` | register/show a `ui` spec's approved look; `--preview` opens a page and records nothing |
|
|
288
292
|
| `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 |
|
|
289
293
|
| `next` | the one next action across every effort |
|
|
290
294
|
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach]` | 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`; `--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 |
|
|
@@ -312,7 +316,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
312
316
|
- **The journal is history** — `.witness/journal/<id>.jsonl`, append-only, committed, never compacted.
|
|
313
317
|
- **Every state commit carries `Witness-State: 1`** — `witness check` audits that spec/plan diffs appear only in trailer-bearing commits.
|
|
314
318
|
- **Canon roots are configurable** — `paths: { specs: docs/specs, plans: docs/plans }` in `witness.config.yaml` (defaults: `specs/`, `plans/`); scan, commit scoping, the guard hook, and criteria excludes all follow it. `git mv` existing docs when changing it.
|
|
315
|
-
- Local, never committed: `.witness/{lock,txn.json,allow.json,calibration.local.yaml}`.
|
|
319
|
+
- Local, never committed: `.witness/{lock,txn.json,allow.json,calibration.local.yaml,drafts/}`.
|
|
316
320
|
|
|
317
321
|
## Development
|
|
318
322
|
|
package/dist/ask.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { draftLine, dropDraft } from './drafts.js';
|
|
1
2
|
import { FLOOR_STREAM } from './floor.js';
|
|
2
3
|
import { stateCommit } from './gitio.js';
|
|
3
4
|
import { appendEntry, effortStreams, entryLine, journalRel, readStream } from './journal.js';
|
|
@@ -127,15 +128,20 @@ export function withdrawAsk(ctx, root, id, note) {
|
|
|
127
128
|
});
|
|
128
129
|
if (!res.ok)
|
|
129
130
|
return res;
|
|
131
|
+
// Row 199. The conversation that would have answered it is over; its draft goes with it.
|
|
132
|
+
dropDraft(root, { kind: 'ask', id });
|
|
130
133
|
return ok({ already: false });
|
|
131
134
|
}
|
|
132
135
|
finally {
|
|
133
136
|
lock.value();
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
|
-
// The one derivation of which ask is routed next: the oldest still open
|
|
139
|
+
// The one derivation of which ask is routed next: the oldest still open — except that an ask whose
|
|
140
|
+
// grill has a draft comes first, the oldest drafted ask among several (row 199: finish what you
|
|
141
|
+
// started). A draft that does not read gives no priority.
|
|
137
142
|
export function nextAsk(root) {
|
|
138
|
-
|
|
143
|
+
const open = askQueue(root).filter((a) => a.state === 'unanswered');
|
|
144
|
+
return open.find((a) => draftLine(root, { kind: 'ask', id: a.id }) !== undefined) ?? open[0];
|
|
139
145
|
}
|
|
140
146
|
// The ask an effort answers: what the decompose arbiter judges a slicing against beside the
|
|
141
147
|
// recap itself (row 177). Named by the recap that birthed the effort — an amend carries no
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ const VERBS = {
|
|
|
15
15
|
recap: () => import('./verbs/recap.js'),
|
|
16
16
|
// Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
|
|
17
17
|
ask: () => import('./verbs/ask.js'),
|
|
18
|
+
// Row 199. A conversation's local draft: saved after every settled answer, never committed.
|
|
19
|
+
draft: () => import('./verbs/draft.js'),
|
|
18
20
|
write: () => import('./verbs/write.js'),
|
|
19
21
|
// Row 132's read route. Canon lives at the primary root and nowhere else, so reading it
|
|
20
22
|
// is a verb rather than a path — which is also what lets a worktree carry no copy.
|
|
@@ -63,9 +65,10 @@ const VERB_USAGE = {
|
|
|
63
65
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
64
66
|
check: 'witness check',
|
|
65
67
|
clean: 'witness clean',
|
|
68
|
+
draft: 'witness draft <ask-<id>|amend-<effort>|design-<spec-id>> [--file <json>]',
|
|
66
69
|
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--via affirmation] [--show]',
|
|
67
70
|
floor: 'witness floor --show | --set <triple> --note <why>',
|
|
68
|
-
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
71
|
+
design: 'witness design <spec-id> --file <html> | --reconfirm | --open | --preview <html>',
|
|
69
72
|
diff: 'witness diff <spec-id>',
|
|
70
73
|
drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] — runs every session itself · under autonomy it needs no terminal and reports when nothing else can move',
|
|
71
74
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
package/dist/config-file.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Lexer, isCollection, isMap, isScalar, parseDocument } from 'yaml';
|
|
2
|
-
import {
|
|
2
|
+
import { RELEASED_COMMENTS } from './released-comments.js';
|
|
3
3
|
import { CHANGE_CLASSES, FIRST_LINE, GROUP_HEADINGS, SCHEMA_COMMENT, SETTINGS, getIn, inlineValue, } from './settings.js';
|
|
4
4
|
import { sectionsOf } from './migration.js';
|
|
5
5
|
const EMPTY = { top: [], above: new Map(), end: [] };
|
|
@@ -73,6 +73,9 @@ const CURRENT_COMMENTS = new Set([
|
|
|
73
73
|
FIRST_LINE.slice(1).trim(),
|
|
74
74
|
...Object.values(GROUP_HEADINGS).map((h) => h.slice(1).trim()),
|
|
75
75
|
]);
|
|
76
|
+
// What a released witness wrote and this one no longer does: derived from the one record, so a
|
|
77
|
+
// reworded comment's old text can never be left out of it.
|
|
78
|
+
const LEGACY_COMMENTS = new Set([...RELEASED_COMMENTS].filter((t) => !CURRENT_COMMENTS.has(t)));
|
|
76
79
|
// Where a human line can be printed: every setting and section, and a per-class reviewer line.
|
|
77
80
|
const PRINTABLE = new Set([
|
|
78
81
|
...SETTINGS.map((s) => s.path),
|
|
@@ -150,7 +153,7 @@ export function readComments(text) {
|
|
|
150
153
|
const comment = token.trimEnd();
|
|
151
154
|
const body = comment.slice(1).trim();
|
|
152
155
|
const ownLine = src.slice(lineStarts[line], at).trim() === '';
|
|
153
|
-
if (
|
|
156
|
+
if (RELEASED_COMMENTS.has(body) || CURRENT_COMMENTS.has(body)) {
|
|
154
157
|
// A line of its own vanishes; one on a setting's line, or the first line, is replaced.
|
|
155
158
|
if (ownLine && line !== 0 && LEGACY_COMMENTS.has(body))
|
|
156
159
|
legacy++;
|
package/dist/drafts.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ID_RE } from './dsl.js';
|
|
4
|
+
import { idTextList } from './idlists.js';
|
|
5
|
+
import { ok, refuse, v } from './refusal.js';
|
|
6
|
+
import { ensureExcluded } from './worktree.js';
|
|
7
|
+
// Row 199. A conversation's local draft: the grill's settled answers, or the design session's
|
|
8
|
+
// direction, answers and page, saved after every settled answer so a session that dies, hits its
|
|
9
|
+
// limit or relays loses nothing. Local, never committed and never journaled: the recap or the
|
|
10
|
+
// stamped look is the record, and a draft is only the memory of the conversation that reaches it.
|
|
11
|
+
// Keyed by what the conversation is about. This module reaches no ask.ts, scan.ts or verb, so
|
|
12
|
+
// each of those may import it.
|
|
13
|
+
export const DRAFTS_DIR = '.witness/drafts';
|
|
14
|
+
export function parseKey(key) {
|
|
15
|
+
const ask = /^ask-(a[0-9]+)$/.exec(key);
|
|
16
|
+
if (ask)
|
|
17
|
+
return { kind: 'ask', id: ask[1] };
|
|
18
|
+
for (const kind of ['amend', 'design']) {
|
|
19
|
+
const rest = key.startsWith(`${kind}-`) ? key.slice(kind.length + 1) : undefined;
|
|
20
|
+
if (rest === undefined || !ID_RE.test(rest))
|
|
21
|
+
continue;
|
|
22
|
+
return kind === 'amend' ? { kind, effort: rest } : { kind, spec: rest };
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function keyName(k) {
|
|
27
|
+
return k.kind === 'ask' ? `ask-${k.id}` : k.kind === 'amend' ? `amend-${k.effort}` : `design-${k.spec}`;
|
|
28
|
+
}
|
|
29
|
+
export const draftRel = (k) => `${DRAFTS_DIR}/${keyName(k)}.json`;
|
|
30
|
+
const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
31
|
+
const unreadable = (k) => refuse([v('key', 'draft-unreadable', draftRel(k), 'a draft witness wrote — save over it with --file')]);
|
|
32
|
+
// ok(undefined) is "none saved". A file that does not parse as a stored draft is refused, never
|
|
33
|
+
// read as empty: the draft is a resumed session's only memory.
|
|
34
|
+
export function readDraft(root, k) {
|
|
35
|
+
const path = join(root, draftRel(k));
|
|
36
|
+
if (!existsSync(path))
|
|
37
|
+
return ok(undefined);
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return unreadable(k);
|
|
44
|
+
}
|
|
45
|
+
if (!isObject(raw) || raw.v !== 1 || !isObject(raw.draft))
|
|
46
|
+
return unreadable(k);
|
|
47
|
+
const read = Array.isArray(raw.read) ? raw.read.filter((x) => typeof x === 'string') : [];
|
|
48
|
+
return ok({
|
|
49
|
+
v: 1, key: keyName(k), draft: raw.draft,
|
|
50
|
+
...(typeof raw.session === 'string' ? { session: raw.session } : {}),
|
|
51
|
+
...(read.length ? { read } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const writeStored = (root, k, stored) => {
|
|
55
|
+
const path = join(root, draftRel(k));
|
|
56
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify(stored, null, 2)}\n`);
|
|
57
|
+
renameSync(`${path}.tmp`, path);
|
|
58
|
+
};
|
|
59
|
+
// Written aside and renamed into place, so a crash never leaves half a draft. The exclusion
|
|
60
|
+
// first: a repo whose .gitignore predates drafts must never show one to `git status`.
|
|
61
|
+
export function saveDraft(root, k, draft, session) {
|
|
62
|
+
ensureExcluded(root);
|
|
63
|
+
mkdirSync(join(root, DRAFTS_DIR), { recursive: true });
|
|
64
|
+
writeStored(root, k, { v: 1, key: keyName(k), ...(session !== undefined ? { session } : {}), draft });
|
|
65
|
+
}
|
|
66
|
+
// A read-back by a session other than the saver records it: that session has now seen every answer
|
|
67
|
+
// in this content, so its next save may drop or reword one. Not an act (row 191 reads `session`
|
|
68
|
+
// only), and a new save clears the list, because its content is unseen again.
|
|
69
|
+
export function markRead(root, k, stored, session) {
|
|
70
|
+
if (stored.session === undefined || stored.session === session || stored.read?.includes(session))
|
|
71
|
+
return;
|
|
72
|
+
writeStored(root, k, { ...stored, read: [...(stored.read ?? []), session] });
|
|
73
|
+
}
|
|
74
|
+
export function dropDraft(root, k) {
|
|
75
|
+
rmSync(join(root, draftRel(k)), { force: true });
|
|
76
|
+
}
|
|
77
|
+
// Row 191 reads these beside the journal: a settled answer saved is the session's stage work.
|
|
78
|
+
export function draftSessions(root) {
|
|
79
|
+
const dir = join(root, DRAFTS_DIR);
|
|
80
|
+
if (!existsSync(dir))
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
|
|
84
|
+
const k = parseKey(f.slice(0, -'.json'.length));
|
|
85
|
+
if (k === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
const d = readDraft(root, k);
|
|
88
|
+
if (d.ok && d.value?.session !== undefined)
|
|
89
|
+
out.push(d.value.session);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
const GRILL_FIELDS = ['effort', 'class', 'goals', 'non_goals', 'constraints', 'decisions', 'slices', 'open'];
|
|
94
|
+
const DESIGN_FIELDS = ['direction', 'answers', 'open', 'page'];
|
|
95
|
+
const ID_LISTS = [['goals', 'g'], ['non_goals', 'n'], ['constraints', 'c'], ['decisions', 'd']];
|
|
96
|
+
const stringList = (raw, field) => raw === undefined || (Array.isArray(raw) && raw.every((s) => typeof s === 'string' && s.trim() !== ''))
|
|
97
|
+
? []
|
|
98
|
+
: [v(field, 'shape', JSON.stringify(raw), 'list of non-empty strings')];
|
|
99
|
+
// The shape, never completeness: a draft is the recap so far. An unknown field is refused, because
|
|
100
|
+
// a field dropped without a word would be a lost answer.
|
|
101
|
+
export function validateDraft(k, raw) {
|
|
102
|
+
if (!isObject(raw)) {
|
|
103
|
+
return [v('draft', 'shape', Array.isArray(raw) ? 'array' : raw === null ? 'null' : typeof raw, 'JSON object')];
|
|
104
|
+
}
|
|
105
|
+
const allowed = k.kind === 'design' ? DESIGN_FIELDS : GRILL_FIELDS;
|
|
106
|
+
const out = Object.keys(raw).filter((f) => !allowed.includes(f))
|
|
107
|
+
.map((f) => v(f, 'unknown-field', f, `one of ${allowed.join(' ')}`));
|
|
108
|
+
if (k.kind === 'design') {
|
|
109
|
+
if (raw.direction !== undefined && (typeof raw.direction !== 'string' || raw.direction.trim() === '')) {
|
|
110
|
+
out.push(v('direction', 'shape', JSON.stringify(raw.direction), 'non-empty string'));
|
|
111
|
+
}
|
|
112
|
+
out.push(...stringList(raw.answers, 'answers'), ...stringList(raw.open, 'open'));
|
|
113
|
+
if (raw.page !== undefined && typeof raw.page !== 'string')
|
|
114
|
+
out.push(v('page', 'shape', typeof raw.page, 'string'));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
if (raw.effort !== undefined) {
|
|
118
|
+
if (typeof raw.effort !== 'string' || !ID_RE.test(raw.effort)) {
|
|
119
|
+
out.push(v('effort', 'id-charset', String(raw.effort), '[a-z0-9-]+'));
|
|
120
|
+
}
|
|
121
|
+
else if (k.kind === 'amend' && raw.effort !== k.effort) {
|
|
122
|
+
out.push(v('effort', 'effort-mismatch', raw.effort, `${k.effort} — the effort this draft amends`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (raw.class !== undefined && raw.class !== 'feature' && raw.class !== 'fix' && raw.class !== 'chore') {
|
|
126
|
+
out.push(v('class', 'enum', String(raw.class), 'feature | fix | chore'));
|
|
127
|
+
}
|
|
128
|
+
for (const [field, prefix] of ID_LISTS)
|
|
129
|
+
out.push(...idTextList(raw[field], field, prefix, false));
|
|
130
|
+
out.push(...stringList(raw.slices, 'slices'), ...stringList(raw.open, 'open'));
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
const list = (x) => (Array.isArray(x) ? x : []);
|
|
134
|
+
// What a save by a session that has not seen this draft must keep of it (Builder's call 11), by
|
|
135
|
+
// content: two chats both number their first answer g1, so an id alone cannot tell their answers
|
|
136
|
+
// apart. A grill's items are its four lists' `id: text`; a look's are its chosen direction, each
|
|
137
|
+
// settled answer, and whether a page is saved (the page itself changes with every save).
|
|
138
|
+
export function settledItems(k, draft) {
|
|
139
|
+
if (k.kind === 'design') {
|
|
140
|
+
return [
|
|
141
|
+
...(typeof draft.direction === 'string' && draft.direction !== '' ? [`direction: ${draft.direction}`] : []),
|
|
142
|
+
...list(draft.answers).filter((a) => typeof a === 'string').map((a) => `answer: ${a}`),
|
|
143
|
+
...(typeof draft.page === 'string' && draft.page !== '' ? ['page'] : []),
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
return ID_LISTS.flatMap(([field]) => list(draft[field]))
|
|
147
|
+
.map((e) => (isObject(e) && typeof e.id === 'string' ? `${e.id}: ${String(e.text)}` : undefined))
|
|
148
|
+
.filter((item) => item !== undefined);
|
|
149
|
+
}
|
|
150
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
151
|
+
export function draftSummary(k, draft) {
|
|
152
|
+
if (k.kind === 'design') {
|
|
153
|
+
const chosen = typeof draft.direction === 'string' && draft.direction !== '';
|
|
154
|
+
const page = typeof draft.page === 'string' && draft.page !== '';
|
|
155
|
+
return `direction ${chosen ? 'chosen' : 'open'} · ${count(list(draft.answers).length, 'answer')} settled · page ${page ? 'saved' : 'none'}`;
|
|
156
|
+
}
|
|
157
|
+
const items = ID_LISTS.reduce((n, [field]) => n + list(draft[field]).length, 0);
|
|
158
|
+
const open = list(draft.open).length;
|
|
159
|
+
return `${count(items, 'item')} settled${open > 0 ? ` · ${open} open` : ''}`;
|
|
160
|
+
}
|
|
161
|
+
// `next`'s line for a conversation that has a draft. None, or one that does not read, prints
|
|
162
|
+
// nothing: an unreadable draft is refused by name where it is read back, never here.
|
|
163
|
+
export function draftLine(root, k) {
|
|
164
|
+
const d = readDraft(root, k);
|
|
165
|
+
if (!d.ok || d.value === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
return `${draftSummary(k, d.value.draft)} · resume with witness draft ${keyName(k)}`;
|
|
168
|
+
}
|
package/dist/gitio.js
CHANGED
|
@@ -110,6 +110,11 @@ const LOCAL_STATE_FILES = new Set([
|
|
|
110
110
|
// declared extensions exist to unblock.
|
|
111
111
|
'.witness/config.local.yaml',
|
|
112
112
|
]);
|
|
113
|
+
// Row 199. A conversation's drafts are local by directory, as row 90's machine config is by file,
|
|
114
|
+
// and the exemption is load-bearing for the same reason: init writes its .gitignore block only
|
|
115
|
+
// when scaffolding, so on every repo older than the row a draft is untracked-but-visible under a
|
|
116
|
+
// state dir, and an unrelated-dirty refusal there would block the very recap the grill lands.
|
|
117
|
+
const LOCAL_STATE_DIRS = ['.witness/drafts/'];
|
|
113
118
|
export function dirtyStatePaths(root) {
|
|
114
119
|
const res = tryGit(root, 'status', '--porcelain', '--untracked-files=all', '--', ...stateDirs(root));
|
|
115
120
|
if (!res.ok || res.out === '')
|
|
@@ -119,7 +124,7 @@ export function dirtyStatePaths(root) {
|
|
|
119
124
|
// a rename (e.g. from `git mv`) porcelain-formats as "old -> new" on one line —
|
|
120
125
|
// split it into its two paths so each is checked against the caller's planned set
|
|
121
126
|
.flatMap((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> '))
|
|
122
|
-
.filter((p) => !LOCAL_STATE_FILES.has(p));
|
|
127
|
+
.filter((p) => !LOCAL_STATE_FILES.has(p) && !LOCAL_STATE_DIRS.some((d) => p.startsWith(d)));
|
|
123
128
|
}
|
|
124
129
|
export const TRAILER = 'Witness-State: 1';
|
|
125
130
|
// Row 87's rule: nothing to COMMIT is the same legal silent success as nothing to write.
|
package/dist/idlists.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { v } from './refusal.js';
|
|
2
|
+
// A recap's `{id, text}` lists, checked one way wherever one is read: the recap itself and a
|
|
3
|
+
// conversation's draft (row 199). Its own module so drafts.ts never imports a verb — through
|
|
4
|
+
// recap.ts that would close drafts → recap → ask → drafts.
|
|
5
|
+
export function idTextList(raw, field, prefix, required) {
|
|
6
|
+
if (raw === undefined)
|
|
7
|
+
return required ? [v(field, 'required', 'absent', `>=1 {id, text} with ids ${prefix}1..`)] : [];
|
|
8
|
+
if (!Array.isArray(raw))
|
|
9
|
+
return [v(field, 'shape', typeof raw, 'list of {id, text}')];
|
|
10
|
+
if (required && raw.length === 0)
|
|
11
|
+
return [v(field, 'required', 'empty', `>=1 {id, text} with ids ${prefix}1..`)];
|
|
12
|
+
const out = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
raw.forEach((item, i) => {
|
|
15
|
+
const at = `${field}[${i}]`;
|
|
16
|
+
const e = (typeof item === 'object' && item !== null ? item : {});
|
|
17
|
+
if (typeof e.id !== 'string' || !new RegExp(`^${prefix}[0-9]+$`).test(e.id)) {
|
|
18
|
+
out.push(v(`${at}.id`, 'id-prefix', String(e.id ?? 'absent'), `${prefix}<number>`));
|
|
19
|
+
}
|
|
20
|
+
else if (seen.has(e.id)) {
|
|
21
|
+
out.push(v(`${at}.id`, 'id-unique', e.id, `unique within ${field}`));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
seen.add(e.id);
|
|
25
|
+
}
|
|
26
|
+
if (typeof e.text !== 'string' || e.text.trim() === '') {
|
|
27
|
+
out.push(v(`${at}.text`, 'required', String(e.text ?? 'absent'), 'non-empty text'));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return out;
|
|
31
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { version } from './cli.js';
|
|
5
5
|
import { tryGit } from './gitio.js';
|
|
6
6
|
import { ok, refuse, v } from './refusal.js';
|
|
7
|
+
import { SETTINGS } from './settings.js';
|
|
7
8
|
import { NPX_LATEST, compareTriple, pinIn } from './version.js';
|
|
8
9
|
// dist/install.js and src/install.ts both sit one level under the package root, so the
|
|
9
10
|
// same '..' works built and under vitest — the pattern model.ts:14 already uses. This is
|
|
@@ -258,7 +259,8 @@ export function recordHarness(configText, name) {
|
|
|
258
259
|
return { text: configText, changed: false, declared: found[1] };
|
|
259
260
|
const suffix = configText.endsWith('\n') ? '' : '\n';
|
|
260
261
|
return {
|
|
261
|
-
|
|
262
|
+
// The setting's own comment, as a fresh init writes it: one source of witness's comment text.
|
|
263
|
+
text: `${configText}${suffix}harness: ${name} # ${SETTINGS.find((s) => s.path === 'harness').comment}\n`,
|
|
262
264
|
changed: true,
|
|
263
265
|
};
|
|
264
266
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Row 193 §4.5. Every comment text a released witness wrote into witness.config.yaml — the one
|
|
2
|
+
// record of which comments are witness's. A comment line whose trimmed text is one of these is
|
|
3
|
+
// witness's wherever it sits: `migrate` writes the current text on a setting's line and drops an
|
|
4
|
+
// old one on a line of its own, since what it described now exists for real. An edited copy no
|
|
5
|
+
// longer matches, so it is the human's. Append-only: rewording a comment in `src/settings.ts`
|
|
6
|
+
// adds its new text here and leaves the old one, which is what keeps every file an older witness
|
|
7
|
+
// wrote readable (tests/config-file.test.ts fails on a written text missing from the record).
|
|
8
|
+
// What this witness no longer writes is derived (config-file.ts), never a second list to keep.
|
|
9
|
+
export const RELEASED_COMMENTS = new Set([
|
|
10
|
+
// ── Before 0.21.0: each version of `init`'s template (git log -p --follow -- src/verbs/init.ts,
|
|
11
|
+
// the specflow era included; a continuation line is its own entry) and both of
|
|
12
|
+
// `recordHarness`'s texts (src/install.ts).
|
|
13
|
+
'any gate block may pin its own model (wins over the global pin), e.g.',
|
|
14
|
+
'conventions: [docs/code/architecture.md] # → injected into code-reviewer at implement + ship (cache-keyed)',
|
|
15
|
+
'declared beats the ambient session (init --agent writes it once)',
|
|
16
|
+
'decompose: { reviewers: [slicing-critic], model: claude-opus-4-8 }',
|
|
17
|
+
'docs: # repo docs registry — enumerated keys, unknown keys refused',
|
|
18
|
+
"harness: pi # the judge — which harness runs this repo's gate reviewers;",
|
|
19
|
+
'or: runner: full-suite + report: junit:**/reports/junit.xml',
|
|
20
|
+
'paths: { specs: docs/specs, plans: docs/plans } # optional canon roots (defaults: specs, plans); git mv existing docs when changing',
|
|
21
|
+
'per change class; a plain list = all classes',
|
|
22
|
+
'reviewer model pin — exact id, aliases refused',
|
|
23
|
+
'reviewerTimeoutMs: 600000 # ms per reviewer invocation (machine auth/extension knobs live in .witness/config.local.yaml)',
|
|
24
|
+
'specflow.config.yaml — the whole surface',
|
|
25
|
+
'witness.config.yaml — the whole surface',
|
|
26
|
+
"the judge — which harness runs this repo's gate reviewers; declared wins",
|
|
27
|
+
'detection wins; this is the fallback',
|
|
28
|
+
// The end-of-line comments of the template's commented-out settings, which the template meant
|
|
29
|
+
// the human to uncomment (final review I3).
|
|
30
|
+
'optional canon roots (defaults: specs, plans); git mv existing docs when changing',
|
|
31
|
+
'repo docs registry — enumerated keys, unknown keys refused',
|
|
32
|
+
'→ injected into code-reviewer at implement + ship (cache-keyed)',
|
|
33
|
+
"the judge — which harness runs this repo's gate reviewers;",
|
|
34
|
+
'ms per reviewer invocation (machine auth/extension knobs live in .witness/config.local.yaml)',
|
|
35
|
+
// ── Since 0.21.0 the template is `src/settings.ts`: every setting's comment, the per-class
|
|
36
|
+
// `for a …` lines, the schema line, the first line and the group headings, in every release.
|
|
37
|
+
// Collected from the published tarballs 0.21.0, 0.21.1, 0.22.0, 0.23.0 and 0.24.0.
|
|
38
|
+
"% of the context window at which a session hands over to a fresh one · 1–100",
|
|
39
|
+
"code docs handed to code-reviewer at implement and ship · file list, [] = none",
|
|
40
|
+
"design docs handed to design-critic and design-reviewer · file list, [] = none",
|
|
41
|
+
"extra globs never scanned for criteria tags · [] = none",
|
|
42
|
+
"folder for approved designs, repo-relative · git mv the docs when you change it",
|
|
43
|
+
"folder for plans, repo-relative · git mv the docs when you change it",
|
|
44
|
+
"folder for specs, repo-relative · git mv the docs when you change it",
|
|
45
|
+
"for a chore",
|
|
46
|
+
"for a feature",
|
|
47
|
+
"for a fix",
|
|
48
|
+
"format version · witness migrate raises it; never edit by hand",
|
|
49
|
+
"gate rounds one target may run across budget resets, then it is parked · ≥ 1",
|
|
50
|
+
"how a spec's criteria run · a command with {id}, or full-suite",
|
|
51
|
+
"how ship merges its own PR under autonomy · merge | squash | rebase",
|
|
52
|
+
"let test runs in two worktrees overlap · true | false",
|
|
53
|
+
"model for the arbiter that settles stops and checks recaps under autonomy · exact id, no aliases",
|
|
54
|
+
"model for the arbiter that settles stops under autonomy · exact id, no aliases",
|
|
55
|
+
"model for this gate's reviewers AND for the agent that writes the code",
|
|
56
|
+
"model for this gate's reviewers · exact id, [provider/]id[:thinking], no aliases",
|
|
57
|
+
"model for this gate's reviewers · exact id, no aliases",
|
|
58
|
+
"model for this gate's reviewers · runs only on specs marked ui",
|
|
59
|
+
"ms green CI must hold still before an autonomous merge · 0–1800000",
|
|
60
|
+
"ms of session time one target may take in one run (16 h), then it is parked · ≥ 1",
|
|
61
|
+
"ms one reviewer, or the arbiter, may run before it is stopped (10 min) · ≥ 1",
|
|
62
|
+
"ms one spawned session may run before it is stopped (1 h) · ≥ 1",
|
|
63
|
+
"on = the CLI settles its own stops and merges green PRs · on | off · counts once committed",
|
|
64
|
+
"reviewers this gate runs · one list, or one list per class",
|
|
65
|
+
"reviewers this gate runs · one list, or one list per class (feature, fix, chore)",
|
|
66
|
+
"sessions drive runs at once, one per target · ≥ 1",
|
|
67
|
+
"sessions one target may take in one run, then it is parked · ≥ 1",
|
|
68
|
+
"steps an implement session takes when its context fill can't be read · ≥ 1",
|
|
69
|
+
"the branch witness ships into",
|
|
70
|
+
"the judge: the agent CLI that runs this repo's gate reviewers and arbiter · claude-code | pi",
|
|
71
|
+
"the junit report witness reads · junit:<glob>, or none · full-suite needs one",
|
|
72
|
+
"the lint command ship runs",
|
|
73
|
+
"the suite command ship runs · full-suite criteria run it too",
|
|
74
|
+
"upstream resets one target may take, then it is parked · ≥ 1",
|
|
75
|
+
"witness.config.yaml — every setting witness reads. Change a value in place; `witness migrate` keeps the file current.",
|
|
76
|
+
"── Behavior: how witness works here ─────────────────────────────────────────",
|
|
77
|
+
"── Setup: what this repo is ─────────────────────────────────────────────────",
|
|
78
|
+
"── Tuning: witness drive ────────────────────────────────────────────────────",
|
|
79
|
+
]);
|
package/dist/slice.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from './config.js';
|
|
2
2
|
import { pctOf, reached, readFill, sessionHash, sessionIdOf, windowLabel } from './context.js';
|
|
3
|
+
import { draftSessions } from './drafts.js';
|
|
3
4
|
import { primaryRoot } from './gitio.js';
|
|
4
5
|
import { relayLine, resolveDriver } from './harness.js';
|
|
5
6
|
import { readStream, streamIds } from './journal.js';
|
|
@@ -19,8 +20,11 @@ const stampOf = (e) => typeof e.ctx === 'object' && e.ctx !== null ? e.ctx : {};
|
|
|
19
20
|
// a fresh session from handing over before it has done anything. A vacuous red is no act (final
|
|
20
21
|
// review I3): it observed the behavior already there, so a session opened over the limit that only
|
|
21
22
|
// re-ran one would hand over, and so would every fresh session after it.
|
|
23
|
+
// Row 199: a settled answer saved to a draft is stage work too — the grill's and the design
|
|
24
|
+
// session's boundary — so it counts beside the journal. Reading a draft back is not: a relayed
|
|
25
|
+
// session that has only resumed has done nothing yet.
|
|
22
26
|
export function sessionActed(root, hash) {
|
|
23
|
-
return streamIds(root).some((id) => readStream(root, id).some((e) => ACT_KINDS.has(e.t) && !vacuousEvidence(e) && stampOf(e).session === hash));
|
|
27
|
+
return draftSessions(root).includes(hash) || streamIds(root).some((id) => readStream(root, id).some((e) => ACT_KINDS.has(e.t) && !vacuousEvidence(e) && stampOf(e).session === hash));
|
|
24
28
|
}
|
|
25
29
|
// The entries that closed a step, in stream order. A legacy plan's closes are not the CLI's to
|
|
26
30
|
// see, so it has none.
|
package/dist/verbs/decide.js
CHANGED
|
@@ -3,6 +3,7 @@ import { EXIT } from '../cli.js';
|
|
|
3
3
|
import { grantCommands, untrustedCmdsFor } from '../allowlist.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { designUnseen } from '../design.js';
|
|
6
|
+
import { dropDraft } from '../drafts.js';
|
|
6
7
|
import '../gates/index.js';
|
|
7
8
|
import { gateSpec, liveExits, renderGateRun } from '../gate.js';
|
|
8
9
|
import { newDeferralId } from '../deferral.js';
|
|
@@ -642,6 +643,12 @@ export async function applyDecision(ctx, act) {
|
|
|
642
643
|
finally {
|
|
643
644
|
lockR.value();
|
|
644
645
|
}
|
|
646
|
+
// Row 200 (Builder's call 5). The look is stamped, so the design session that chose it is over:
|
|
647
|
+
// its draft goes now and not when the look registered, because a behavior note at the approval
|
|
648
|
+
// stop re-opens the look and must find the direction and answers still there. D154's rule below:
|
|
649
|
+
// after the decision is durable, never before.
|
|
650
|
+
if (gate === 'design' && entry.decision === 'approve')
|
|
651
|
+
dropDraft(root, { kind: 'design', spec: target });
|
|
645
652
|
// D154. After the decision is durable, never before: a refused or crashed decide must
|
|
646
653
|
// not leave a grant behind that nobody chose.
|
|
647
654
|
if (trustCmds) {
|
|
@@ -686,7 +693,7 @@ export async function applyDecision(ctx, act) {
|
|
|
686
693
|
ctx.out(kv('upstream', `${upstream} is owned by effort ${entry.upstream.artifact} — the decompose reopen is booked there`));
|
|
687
694
|
}
|
|
688
695
|
if (gate === 'decompose')
|
|
689
|
-
ctx.out(`help: scope itself implicated → witness recap --amend
|
|
696
|
+
ctx.out(`help: scope itself implicated → witness draft amend-${target} seeds the amendment from the latest recap · witness recap --amend --file <recap.json> lands it`);
|
|
690
697
|
else if (entry.upstream)
|
|
691
698
|
ctx.out(`help: reopened ${entry.upstream.artifact} (${entry.upstream.gate} stage) — linked via caused_by`);
|
|
692
699
|
}
|
package/dist/verbs/design.js
CHANGED
|
@@ -5,6 +5,7 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
import { EXIT } from '../cli.js';
|
|
6
6
|
import { loadConfig, loadLocalConfig } from '../config.js';
|
|
7
7
|
import { designPending, designRel, designStamp, htmlSha, validateDesignArtifact } from '../design.js';
|
|
8
|
+
import { dropDraft } from '../drafts.js';
|
|
8
9
|
import { writeDoc } from '../fm.js';
|
|
9
10
|
import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
|
|
10
11
|
import { appendEntry, entryLine, journalRel, latestRecap } from '../journal.js';
|
|
@@ -19,11 +20,11 @@ import { crashPoint, guardTxn, withTxn } from '../txn.js';
|
|
|
19
20
|
export async function run(ctx, argv) {
|
|
20
21
|
const { values, positionals } = parseArgs({
|
|
21
22
|
args: argv, allowPositionals: true,
|
|
22
|
-
options: { file: { type: 'string' }, reconfirm: { type: 'boolean' }, open: { type: 'boolean' } },
|
|
23
|
+
options: { file: { type: 'string' }, reconfirm: { type: 'boolean' }, open: { type: 'boolean' }, preview: { type: 'string' } },
|
|
23
24
|
});
|
|
24
25
|
const specId = positionals[0];
|
|
25
26
|
if (!specId) {
|
|
26
|
-
ctx.err('usage: witness design <spec-id> --file <html> | --reconfirm | --open');
|
|
27
|
+
ctx.err('usage: witness design <spec-id> --file <html> | --reconfirm | --open | --preview <html>');
|
|
27
28
|
return EXIT.REFUSED;
|
|
28
29
|
}
|
|
29
30
|
const rootR = primaryRoot(ctx.cwd);
|
|
@@ -48,6 +49,8 @@ export async function run(ctx, argv) {
|
|
|
48
49
|
}
|
|
49
50
|
if (values.open)
|
|
50
51
|
return openOnly(ctx, root, designRel(cfgR.value.paths, specId), specId);
|
|
52
|
+
if (values.preview !== undefined)
|
|
53
|
+
return previewOnly(ctx, root, specId, values.preview);
|
|
51
54
|
if (spec.meta.ui !== true) {
|
|
52
55
|
renderRefusal([v('spec', 'not-ui', specId, 'a ui-flagged spec — set ui: true at decompose to earn a design stage')]).forEach(ctx.err);
|
|
53
56
|
return EXIT.REFUSED;
|
|
@@ -74,7 +77,34 @@ export async function run(ctx, argv) {
|
|
|
74
77
|
lock.value();
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
|
-
// The
|
|
80
|
+
// Row 200. The design session shows before it asks: its directions page, and the full look before
|
|
81
|
+
// it registers, open through the same opener as --open (`opener:`, row 90). A preview takes no
|
|
82
|
+
// lock and records nothing: `design-shown` is the witness that registered bytes were seen (row 72),
|
|
83
|
+
// and these bytes are not registered.
|
|
84
|
+
function previewOnly(ctx, root, id, file) {
|
|
85
|
+
const abs = resolve(ctx.cwd, file);
|
|
86
|
+
if (!existsSync(abs)) {
|
|
87
|
+
renderRefusal([v('--preview', 'input-unreadable', abs, 'a readable HTML file')]).forEach(ctx.err);
|
|
88
|
+
return EXIT.REFUSED;
|
|
89
|
+
}
|
|
90
|
+
const localR = loadLocalConfig(root);
|
|
91
|
+
if (!localR.ok) {
|
|
92
|
+
renderRefusal(localR.violations).forEach(ctx.err);
|
|
93
|
+
return EXIT.REFUSED;
|
|
94
|
+
}
|
|
95
|
+
const { outcome, command } = openArtifact(localR.value.opener, abs);
|
|
96
|
+
if (outcome === 'failed') {
|
|
97
|
+
renderRefusal([v('design', 'opener-failed', `${command} did not resolve`, `a working platform opener, or opener: in .witness/config.local.yaml — meanwhile open it yourself: file://${abs}`)]).forEach(ctx.err);
|
|
98
|
+
return EXIT.REFUSED;
|
|
99
|
+
}
|
|
100
|
+
ctx.out(kv('previewed', id));
|
|
101
|
+
ctx.out(kv('path', abs));
|
|
102
|
+
ctx.out(kv('opener', command));
|
|
103
|
+
ctx.out(kv('note', `a preview registers nothing — witness design ${id} --file <html> registers the look`));
|
|
104
|
+
return EXIT.OK;
|
|
105
|
+
}
|
|
106
|
+
// The one place a registered look is ever spawned at a human (a preview, above, shows bytes that
|
|
107
|
+
// are not registered and witnesses nothing). Journals a sha-keyed witness that
|
|
78
108
|
// `gate design`, `decide --approve` and `next` all consult. Deliberately reachable for
|
|
79
109
|
// any registered artifact — re-showing a file that exists needs no ui/feature eligibility,
|
|
80
110
|
// and gating it on those would dead-end the very refusals that point here.
|
|
@@ -202,6 +232,9 @@ function reconfirm(ctx, root, spec, specSha) {
|
|
|
202
232
|
renderRefusal(res.violations).forEach(ctx.err);
|
|
203
233
|
return EXIT.REFUSED;
|
|
204
234
|
}
|
|
235
|
+
// Row 200. Reconfirming stamps the look as an approve does, so the amend-mode session that chose
|
|
236
|
+
// it is over and its draft goes too — once the stamp is committed.
|
|
237
|
+
dropDraft(root, { kind: 'design', spec: id });
|
|
205
238
|
ctx.out(kv('reconfirmed', id));
|
|
206
239
|
ctx.out(kv('design', `re-pinned to ${short(specSha)} (artifact unchanged)`));
|
|
207
240
|
return EXIT.OK;
|