@popovych.co/witness 0.22.0 → 0.24.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/NOTICE.md +1 -1
- package/README.md +11 -7
- package/dist/abandon.js +5 -2
- package/dist/ask.js +8 -2
- package/dist/basis.js +35 -0
- package/dist/cli.js +5 -2
- package/dist/decisions.js +118 -0
- package/dist/drafts.js +168 -0
- package/dist/fm.js +1 -1
- package/dist/gates/decompose.js +16 -3
- package/dist/gates/plan.js +47 -15
- package/dist/gitio.js +6 -1
- package/dist/history.js +5 -1
- package/dist/idlists.js +31 -0
- package/dist/reviewed.js +19 -5
- package/dist/schemas.js +3 -0
- package/dist/slice.js +5 -1
- package/dist/verbs/check.js +6 -3
- package/dist/verbs/decide.js +8 -1
- package/dist/verbs/design.js +36 -3
- package/dist/verbs/diff.js +26 -1
- package/dist/verbs/draft.js +107 -0
- package/dist/verbs/init.js +1 -0
- package/dist/verbs/log.js +4 -2
- package/dist/verbs/next.js +100 -19
- package/dist/verbs/read.js +26 -2
- package/dist/verbs/recap.js +21 -31
- package/dist/verbs/write.js +27 -4
- 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 +9 -6
- 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 +6 -4
- package/plugin/skills/witness-ship/SKILL.md +2 -2
- package/prompts/plan-critic.md +2 -1
- package/prompts/slicing-critic.md +3 -2
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
|
|
@@ -274,17 +277,18 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
274
277
|
| verb | does |
|
|
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
|
-
| `recap [--amend] --file <json>` | validate + persist a scope recap; births the effort journal |
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
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 |
|
|
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 |
|
|
283
|
+
| `diff <spec-id>` | delta since the last realized state (plan pin → empty), and the decisions that moved since that plan |
|
|
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 |
|
|
281
285
|
| `check` | schema, graph, invariants, needs, trailer audit, probes |
|
|
282
286
|
| `index` | id · summary · status · depends across the canon, then each spec's plans |
|
|
283
287
|
| `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
|
|
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/abandon.js
CHANGED
|
@@ -34,10 +34,13 @@ function later(root, a, b) {
|
|
|
34
34
|
// back under a later commit, whose parent was never the write's — so an amendment read as a create
|
|
35
35
|
// and was deleted, and a create restored another effort's abandoned amendment. And the same bytes
|
|
36
36
|
// written again later, by another effort, are that effort's: who may take them back is the
|
|
37
|
-
// caller's question, asked of this answer.
|
|
38
|
-
|
|
37
|
+
// caller's question, asked of this answer. `among` narrows the efforts asked (row 198 asks the
|
|
38
|
+
// live ones first).
|
|
39
|
+
export function writeAct(root, id, sha, at = 'HEAD', among) {
|
|
39
40
|
let found;
|
|
40
41
|
for (const effort of effortStreams(root)) {
|
|
42
|
+
if (among !== undefined && !among.has(effort))
|
|
43
|
+
continue;
|
|
41
44
|
if (!readStream(root, effort).some((e) => isWrite(e, id, sha)))
|
|
42
45
|
continue;
|
|
43
46
|
for (const { entry, commit } of journalActs(root, effort, at)) {
|
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/basis.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalDecisions, carriedDecisions, decisionsSha } from './decisions.js';
|
|
3
|
+
import { latestRecap } from './journal.js';
|
|
4
|
+
import { effortOf } from './reviewed.js';
|
|
5
|
+
import { canonicalJson, canonicalSha } from './sha.js';
|
|
6
|
+
export function specBasis(root, doc) {
|
|
7
|
+
const decisions = decisionsSha(carriedDecisions(root, doc)?.decisions ?? []);
|
|
8
|
+
return { spec: canonicalSha(doc.meta, doc.body), ...(decisions !== undefined ? { decisions } : {}) };
|
|
9
|
+
}
|
|
10
|
+
export function planBasis(plan) {
|
|
11
|
+
const decisions = plan.meta['decisions-from'];
|
|
12
|
+
return { spec: String(plan.meta['derives-from']), ...(typeof decisions === 'string' ? { decisions } : {}) };
|
|
13
|
+
}
|
|
14
|
+
// The terms that moved, spec first; [] is fresh. An absent pin equals an absent current term.
|
|
15
|
+
export function basisMoved(pinned, current) {
|
|
16
|
+
const moved = [];
|
|
17
|
+
if (pinned.spec !== current.spec)
|
|
18
|
+
moved.push('spec');
|
|
19
|
+
if (pinned.decisions !== current.decisions)
|
|
20
|
+
moved.push('decisions');
|
|
21
|
+
return moved;
|
|
22
|
+
}
|
|
23
|
+
// The decisions term of a plan's reviewed identity (Builder's call 6): what its parent carries,
|
|
24
|
+
// and for a chore plan its chore's own decisions too, which the critic also reads. Never a pin —
|
|
25
|
+
// a chore's decisions in `decisions-from` would re-queue the parent spec forever (call 4).
|
|
26
|
+
// Undefined when neither carries any, so the identity's bytes are today's (c2).
|
|
27
|
+
export function planDecisionsTerm(root, plan, parent) {
|
|
28
|
+
const carried = carriedDecisions(root, parent)?.decisions ?? [];
|
|
29
|
+
const effort = effortOf(root, String(plan.meta.id));
|
|
30
|
+
const recap = effort === undefined ? undefined : latestRecap(root, effort);
|
|
31
|
+
const chore = recap?.class === 'chore' ? recap.decisions ?? [] : [];
|
|
32
|
+
if (chore.length === 0)
|
|
33
|
+
return decisionsSha(carried);
|
|
34
|
+
return createHash('sha256').update(canonicalJson({ parent: canonicalDecisions(carried), chore: canonicalDecisions(chore) })).digest('hex');
|
|
35
|
+
}
|
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>]',
|
|
@@ -75,7 +78,7 @@ const VERB_USAGE = {
|
|
|
75
78
|
migrate: 'witness migrate [--dry-run]',
|
|
76
79
|
log: 'witness log <stream> [--all] [--lineage]',
|
|
77
80
|
next: 'witness next',
|
|
78
|
-
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
|
|
81
|
+
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]',
|
|
79
82
|
recap: 'witness recap --file <recap.json> [--ask <id>] [--amend]',
|
|
80
83
|
recover: 'witness recover [--complete | --rollback]',
|
|
81
84
|
rename: 'witness rename <old-id> <new-id>',
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { writeAct } from './abandon.js';
|
|
5
|
+
import { effortAbandoned, journalRel, latestRecap, readStream, streamIds } from './journal.js';
|
|
6
|
+
import { effortOf, effortWrites } from './reviewed.js';
|
|
7
|
+
import { canonicalJson, canonicalSha } from './sha.js';
|
|
8
|
+
import { kv, list, rows } from './toon.js';
|
|
9
|
+
// Row 198 (Builder's call 2). What a doc carries is what the write that made its CURRENT
|
|
10
|
+
// content said it carries — D168's act, so this adds no second derivation. Two efforts can
|
|
11
|
+
// write identical content with different `carries`; the later act wins, and git is asked only
|
|
12
|
+
// in that rare case. An abandoned effort's act is taken back (D168): while a live effort's write
|
|
13
|
+
// made the same content, the live one carries; content only abandoned writes made is canon of
|
|
14
|
+
// record, and so is what they carried. An id the effort's latest recap no longer holds is
|
|
15
|
+
// history, not carried. A doc no journaled write made (a hand edit, an adopt) carries nothing.
|
|
16
|
+
export function carriedDecisions(root, doc) {
|
|
17
|
+
const id = String(doc.meta.id);
|
|
18
|
+
const sha = canonicalSha(doc.meta, doc.body);
|
|
19
|
+
const index = writerIndex(root);
|
|
20
|
+
const all = index.writes.get(`${id}\0${sha}`) ?? [];
|
|
21
|
+
const live = all.filter((w) => !index.abandoned.has(w.effort));
|
|
22
|
+
const writers = live.length > 0 ? live : all;
|
|
23
|
+
if (writers.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const effort = writers.length === 1 ? writers[0].effort
|
|
26
|
+
: writeAct(root, id, sha, 'HEAD', new Set(writers.map((w) => w.effort)))?.effort ?? writers[0].effort;
|
|
27
|
+
const carries = writers.find((w) => w.effort === effort)?.carries ?? [];
|
|
28
|
+
const decisions = (index.decisions.get(effort) ?? []).filter((d) => carries.includes(d.id));
|
|
29
|
+
return { effort, decisions };
|
|
30
|
+
}
|
|
31
|
+
let lastIndex;
|
|
32
|
+
function writerIndex(root) {
|
|
33
|
+
const ids = streamIds(root);
|
|
34
|
+
const key = ids.map((id) => {
|
|
35
|
+
const st = statSync(join(root, journalRel(id)), { throwIfNoEntry: false });
|
|
36
|
+
return `${id}:${st?.size ?? '-'}:${st?.mtimeMs ?? '-'}`;
|
|
37
|
+
}).join('\n');
|
|
38
|
+
if (lastIndex?.root === root && lastIndex.index.key === key)
|
|
39
|
+
return lastIndex.index;
|
|
40
|
+
const index = { key, writes: new Map(), decisions: new Map(), abandoned: new Set() };
|
|
41
|
+
for (const effort of ids) {
|
|
42
|
+
const entries = readStream(root, effort);
|
|
43
|
+
if (entries[0]?.t !== 'recap')
|
|
44
|
+
continue;
|
|
45
|
+
if (effortAbandoned(entries))
|
|
46
|
+
index.abandoned.add(effort);
|
|
47
|
+
const last = new Map();
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
if (e.t === 'recap')
|
|
50
|
+
index.decisions.set(effort, e.decisions ?? []);
|
|
51
|
+
else if (e.t === 'write')
|
|
52
|
+
last.set(`${String(e.artifact)}\0${String(e.sha)}`, (e.carries ?? []));
|
|
53
|
+
}
|
|
54
|
+
for (const [k, carries] of last)
|
|
55
|
+
index.writes.set(k, [...(index.writes.get(k) ?? []), { effort, carries }]);
|
|
56
|
+
}
|
|
57
|
+
lastIndex = { root, index };
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
61
|
+
// What a pin reads of a set of decisions: each id with its text, in id order. A recap may reorder
|
|
62
|
+
// them or keep other keys beside the text (validation reads only id and text), and neither is a
|
|
63
|
+
// changed decision — hashing the objects whole re-queued shipped specs with an empty delta.
|
|
64
|
+
export function canonicalDecisions(decisions) {
|
|
65
|
+
return [...decisions].sort(byId).map(({ id, text }) => ({ id, text }));
|
|
66
|
+
}
|
|
67
|
+
// The pin a plan takes on what its parent carries — undefined for none, so a decision-free
|
|
68
|
+
// basis is exactly the spec sha (c2).
|
|
69
|
+
export function decisionsSha(decisions) {
|
|
70
|
+
if (decisions.length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
return createHash('sha256').update(canonicalJson(canonicalDecisions(decisions))).digest('hex');
|
|
73
|
+
}
|
|
74
|
+
// Row 198. What moved in a spec's carried decisions since the base plan was derived: added,
|
|
75
|
+
// then changed, then removed, each by id; a removed row shows the text the plan was derived
|
|
76
|
+
// under. A base plan with no `decisions-from` was derived under none, so all it carries now is
|
|
77
|
+
// added. Undefined when the plan pinned decisions but its write journaled no texts — unknown,
|
|
78
|
+
// never guessed. One derivation for `witness diff` and the plan gate's delta block.
|
|
79
|
+
export function decisionDelta(root, base, spec) {
|
|
80
|
+
const effort = base.planId === undefined ? undefined : effortOf(root, base.planId);
|
|
81
|
+
const journaled = effort === undefined ? undefined : effortWrites(root, effort).get(base.planId)?.decisions;
|
|
82
|
+
const before = journaled ?? (base.decisions === undefined ? [] : undefined);
|
|
83
|
+
if (before === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
const now = carriedDecisions(root, spec)?.decisions ?? [];
|
|
86
|
+
const was = new Map(before.map((d) => [d.id, d.text]));
|
|
87
|
+
const is = new Set(now.map((d) => d.id));
|
|
88
|
+
return [
|
|
89
|
+
...now.filter((d) => !was.has(d.id)).sort(byId).map((d) => ({ id: d.id, change: 'added', text: d.text })),
|
|
90
|
+
...now.filter((d) => was.has(d.id) && was.get(d.id) !== d.text).sort(byId).map((d) => ({ id: d.id, change: 'changed', text: d.text })),
|
|
91
|
+
...before.filter((d) => !is.has(d.id)).sort(byId).map((d) => ({ id: d.id, change: 'removed', text: d.text })),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
// Row 198. `witness read <spec> --decisions`: what the spec carries and the standing limits of
|
|
95
|
+
// the effort whose write made its content. Every row prints even when empty, so a reader can
|
|
96
|
+
// tell "none" from "not shown". One rendering for the verb and the plan critic's prompt.
|
|
97
|
+
export function decisionLines(root, doc) {
|
|
98
|
+
const carried = carriedDecisions(root, doc);
|
|
99
|
+
const recap = carried === undefined ? undefined : latestRecap(root, carried.effort);
|
|
100
|
+
return [
|
|
101
|
+
carried === undefined ? 'carried-by: none — no journaled write made this content' : kv('carried-by', carried.effort),
|
|
102
|
+
...rows('decisions', ['id', 'text'], carried?.decisions ?? []),
|
|
103
|
+
...rows('constraints', ['id', 'text'], recap?.constraints ?? []),
|
|
104
|
+
...rows('non_goals', ['id', 'text'], recap?.non_goals ?? []),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
// `witness read <effort> --decisions`: the effort's whole latest recap — what a chore plan
|
|
108
|
+
// carries, since a chore writes no spec to carry it (§4.5).
|
|
109
|
+
export function recapLines(recap) {
|
|
110
|
+
return [
|
|
111
|
+
`effort: ${recap.effort} · ${recap.class}`,
|
|
112
|
+
...rows('goals', ['id', 'text'], recap.goals),
|
|
113
|
+
...rows('decisions', ['id', 'text'], recap.decisions ?? []),
|
|
114
|
+
...rows('constraints', ['id', 'text'], recap.constraints),
|
|
115
|
+
...rows('non_goals', ['id', 'text'], recap.non_goals),
|
|
116
|
+
list('slices', recap.slices),
|
|
117
|
+
];
|
|
118
|
+
}
|
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/fm.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname } from 'node:path';
|
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
4
4
|
import { ok, refuse, v } from './refusal.js';
|
|
5
5
|
const FIELD_ORDER = [
|
|
6
|
-
'id', 'type', 'status', 'summary', 'ui', 'parent', 'derives-from', 'design-from',
|
|
6
|
+
'id', 'type', 'status', 'summary', 'ui', 'parent', 'derives-from', 'design-from', 'decisions-from',
|
|
7
7
|
'supersedes', 'depends', 'needs', 'criteria', 'steps', 'pr', 'drift', 'design',
|
|
8
8
|
];
|
|
9
9
|
export function splitDoc(raw) {
|
package/dist/gates/decompose.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ok, refuse, v } from '../refusal.js';
|
|
2
2
|
import { serializeDoc } from '../fm.js';
|
|
3
|
-
import { canonicalJson
|
|
3
|
+
import { canonicalJson } from '../sha.js';
|
|
4
4
|
import { contextDigest } from '../delta.js';
|
|
5
5
|
import { snapshotFiles } from '../snapshot.js';
|
|
6
6
|
import { latestRecap, streamExists } from '../journal.js';
|
|
@@ -8,6 +8,7 @@ import { findCycle, findById } from '../scan.js';
|
|
|
8
8
|
import { effortReviewedSha, effortSpecs, effortWrites } from '../reviewed.js';
|
|
9
9
|
import { validateDoc } from '../schemas.js';
|
|
10
10
|
import { registerGate } from '../gate.js';
|
|
11
|
+
import { basisMoved, planBasis, specBasis } from '../basis.js';
|
|
11
12
|
function stampsFor(docs) {
|
|
12
13
|
return docs
|
|
13
14
|
.filter((d) => String(d.meta.status) === 'draft')
|
|
@@ -47,12 +48,19 @@ registerGate({
|
|
|
47
48
|
detail: missing.length ? `uncovered goals: ${missing.join(' ')}` : `${goals.length} goals covered` });
|
|
48
49
|
checks.push({ name: 'spec-coverage', ok: orphans.length === 0,
|
|
49
50
|
detail: orphans.length ? `specs covering no goal: ${orphans.join(' ')}` : 'every spec covers ≥ 1 goal' });
|
|
51
|
+
// Row 198. Every recap decision lands in ≥ 1 spec. No reverse rule: a spec may carry none.
|
|
52
|
+
const decisions = (recap.decisions ?? []).map((d) => d.id);
|
|
53
|
+
const carried = new Set(docs.flatMap((d) => writes.get(String(d.meta.id))?.carries ?? []));
|
|
54
|
+
const uncarried = decisions.filter((d) => !carried.has(d));
|
|
55
|
+
checks.push({ name: 'decision-coverage', ok: uncarried.length === 0,
|
|
56
|
+
detail: decisions.length === 0 ? 'the recap carries no decisions'
|
|
57
|
+
: uncarried.length ? `uncarried decisions: ${uncarried.join(' ')}` : `${decisions.length} decisions carried` });
|
|
50
58
|
const staleChildren = canon.docs
|
|
51
59
|
.filter((p) => p.meta.type === 'plan' && String(p.meta.status) === 'in-progress')
|
|
52
60
|
.filter((p) => docs.some((d) => String(d.meta.id) === String(p.meta.parent)))
|
|
53
61
|
.filter((p) => {
|
|
54
62
|
const parent = findById(canon, String(p.meta.parent));
|
|
55
|
-
return
|
|
63
|
+
return basisMoved(planBasis(p), specBasis(root, parent)).length > 0;
|
|
56
64
|
})
|
|
57
65
|
.map((p) => String(p.meta.id));
|
|
58
66
|
checks.push({ name: 'amendment-ack', ok: staleChildren.length === 0,
|
|
@@ -68,9 +76,14 @@ registerGate({
|
|
|
68
76
|
const coverageTable = docs
|
|
69
77
|
.map((d) => `${String(d.meta.id)}: covers ${(writes.get(String(d.meta.id))?.covers ?? []).join(' ') || '(none)'}`)
|
|
70
78
|
.join('\n');
|
|
79
|
+
// Only when the recap carries decisions, so a decision-free effort's prompt is today's (c2).
|
|
80
|
+
const decisionTable = decisions.length === 0 ? undefined : docs
|
|
81
|
+
.map((d) => `${String(d.meta.id)}: carries ${(writes.get(String(d.meta.id))?.carries ?? []).join(' ') || '(none)'}`)
|
|
82
|
+
.join('\n');
|
|
71
83
|
const promptBody = [
|
|
72
84
|
'### Confirmed scope recap (latest)', canonicalJson(recap),
|
|
73
85
|
'### Goal coverage (journaled)', coverageTable,
|
|
86
|
+
...(decisionTable !== undefined ? ['### Decision coverage (journaled)', decisionTable] : []),
|
|
74
87
|
...docs.flatMap((d) => [`### Spec: ${String(d.meta.id)}`, serializeDoc({ meta: d.meta, body: d.body })]),
|
|
75
88
|
].join('\n\n');
|
|
76
89
|
const { sha } = effortReviewedSha(root, canon, effort);
|
|
@@ -78,7 +91,7 @@ registerGate({
|
|
|
78
91
|
return ok({
|
|
79
92
|
class: (recap.class ?? 'feature'),
|
|
80
93
|
reviewedSha: sha,
|
|
81
|
-
...(tree ? { snapshot: { tree, context: contextDigest(canonicalJson(recap), coverageTable) } } : {}),
|
|
94
|
+
...(tree ? { snapshot: { tree, context: contextDigest(canonicalJson(recap), coverageTable, ...(decisionTable !== undefined ? [decisionTable] : [])) } } : {}),
|
|
82
95
|
reviewed: { kind: 'docs', docs: docs.map((d) => ({ id: String(d.meta.id), body: d.body })) },
|
|
83
96
|
promptBody,
|
|
84
97
|
checks,
|
package/dist/gates/plan.js
CHANGED
|
@@ -10,6 +10,9 @@ import { effortOf, planPairSha } from '../reviewed.js';
|
|
|
10
10
|
import { validateDoc } from '../schemas.js';
|
|
11
11
|
import { registerGate } from '../gate.js';
|
|
12
12
|
import { designPending, designStamp } from '../design.js';
|
|
13
|
+
import { basisMoved, planBasis, planDecisionsTerm, specBasis } from '../basis.js';
|
|
14
|
+
import { decisionDelta, decisionLines, recapLines } from '../decisions.js';
|
|
15
|
+
import { rows } from '../toon.js';
|
|
13
16
|
// D167. One derivation for the gate's stamp and `decide`'s: a pass approves a draft and nothing
|
|
14
17
|
// else. Row 95 keeps an in-flight status through a re-author, and a passing re-gate must not
|
|
15
18
|
// take it back — nor stamp a finished plan approved again.
|
|
@@ -38,12 +41,20 @@ registerGate({
|
|
|
38
41
|
const parentOk = plannableParent(parent);
|
|
39
42
|
checks.push({ name: 'parent-approved', ok: parentOk,
|
|
40
43
|
detail: `${String(parent.meta.id)} is ${parentStatus}` });
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Row 198. Fresh iff neither pin moved: the parent's content, nor the decisions it carries.
|
|
45
|
+
const pinned = planBasis(plan);
|
|
46
|
+
const current = specBasis(root, parent);
|
|
47
|
+
const parentSha = current.spec;
|
|
48
|
+
const moved = basisMoved(pinned, current);
|
|
49
|
+
const pin7 = (x) => (x === undefined ? 'none' : short(x));
|
|
50
|
+
const clause = {
|
|
51
|
+
spec: `derives-from ${short(pinned.spec)} but parent is at ${short(current.spec)} — rewrite the plan (witness write re-stamps the pin)`,
|
|
52
|
+
decisions: `decisions-from ${pin7(pinned.decisions)} but ${String(parent.meta.id)} carries decisions at ${pin7(current.decisions)} — rewrite the plan (witness write re-stamps the pins)`,
|
|
53
|
+
};
|
|
54
|
+
checks.push({ name: 'pin-fresh', ok: moved.length === 0,
|
|
55
|
+
detail: moved.length === 0
|
|
56
|
+
? `pin ${short(parentSha)}${current.decisions !== undefined ? ` · decisions ${short(current.decisions)}` : ''}`
|
|
57
|
+
: moved.map((m) => clause[m]).join(' · ') });
|
|
47
58
|
if (parent.meta.ui === true) {
|
|
48
59
|
const stamp = designStamp(parent);
|
|
49
60
|
const planPin = plan.meta['design-from'];
|
|
@@ -54,19 +65,40 @@ registerGate({
|
|
|
54
65
|
: designPending(root, parent) ? `${String(parent.meta.id)} design is stale — re-approve or reconfirm`
|
|
55
66
|
: `design-from ${short(String(planPin ?? 'absent'))} ≠ current design ${short(stamp.sha)}` });
|
|
56
67
|
}
|
|
57
|
-
const
|
|
68
|
+
const parentId = String(parent.meta.id);
|
|
69
|
+
const base = baseForSpec(root, canon, parentId, planId);
|
|
70
|
+
// Row 198 (g4). A plan re-queued because only the decisions moved is told what moved: its
|
|
71
|
+
// delta criteria are empty, so nothing else would check that it realizes the change.
|
|
72
|
+
const baseMoved = base.kind === 'empty' ? []
|
|
73
|
+
: basisMoved({ spec: base.sha, ...(base.decisions ? { decisions: base.decisions } : {}) }, current);
|
|
74
|
+
const delta = baseMoved.includes('decisions') ? decisionDelta(root, base, parent) : undefined;
|
|
75
|
+
const deltaBase = base.kind === 'empty'
|
|
76
|
+
? 'new spec — the delta is the whole spec'
|
|
77
|
+
: [
|
|
78
|
+
`previous plan ${base.planId} pinned ${short(base.sha ?? '')} — the plan must realize what changed since`,
|
|
79
|
+
...(!baseMoved.includes('decisions') ? []
|
|
80
|
+
: delta === undefined ? [`decisions moved since ${base.planId} — witness read ${parentId} --decisions`]
|
|
81
|
+
: [`decisions moved since ${base.planId}:`, ...rows('decisions-delta', ['id', 'change', 'text'], delta)]),
|
|
82
|
+
].join('\n');
|
|
83
|
+
// What the parent's slicing settled and its effort's standing limits — shown only when it
|
|
84
|
+
// holds any, so a plan on a decision-free, limit-free effort reads today's prompt (c2). A
|
|
85
|
+
// chore plan also reads its chore's whole recap: a chore writes no spec to carry it.
|
|
86
|
+
const carried = decisionLines(root, parent);
|
|
87
|
+
const sections = [
|
|
88
|
+
...(carried.some((l) => l.startsWith(' ')) ? [`### Decisions ${parentId} carries — witness read ${parentId} --decisions`, carried.join('\n')] : []),
|
|
89
|
+
...(recap?.class === 'chore' ? [`### The chore's recap — witness read ${effort} --decisions`, recapLines(recap).join('\n')] : []),
|
|
90
|
+
];
|
|
58
91
|
const promptBody = [
|
|
59
|
-
`### Parent spec: ${
|
|
60
|
-
`### Delta base`,
|
|
61
|
-
|
|
62
|
-
: `previous plan ${base.planId} pinned ${short(base.sha ?? '')} — the plan must realize what changed since`,
|
|
92
|
+
`### Parent spec: ${parentId}`, serializeDoc({ meta: parent.meta, body: parent.body }),
|
|
93
|
+
`### Delta base`, deltaBase,
|
|
94
|
+
...sections,
|
|
63
95
|
`### Plan: ${planId}`, serializeDoc({ meta: plan.meta, body: plan.body }),
|
|
64
96
|
].join('\n\n');
|
|
65
97
|
const tree = snapshotFiles(root, [{ name: `${planId}.md`, content: serializeDoc({ meta: plan.meta, body: plan.body }) }]);
|
|
66
98
|
return ok({
|
|
67
99
|
class: (recap?.class ?? 'feature'),
|
|
68
|
-
reviewedSha: planPairSha(plan, parent),
|
|
69
|
-
...(tree ? { snapshot: { tree, context: contextDigest(parentSha, canonicalJson(base)) } } : {}),
|
|
100
|
+
reviewedSha: planPairSha(plan, parent, planDecisionsTerm(root, plan, parent)),
|
|
101
|
+
...(tree ? { snapshot: { tree, context: contextDigest(parentSha, canonicalJson(base), ...(sections.length ? [sections.join('\n\n')] : [])) } } : {}),
|
|
70
102
|
artifactSha: canonicalSha(plan.meta, plan.body),
|
|
71
103
|
reviewed: {
|
|
72
104
|
kind: 'docs',
|
|
@@ -80,10 +112,10 @@ registerGate({
|
|
|
80
112
|
stamps: draftApproval(plan),
|
|
81
113
|
});
|
|
82
114
|
},
|
|
83
|
-
currentSha(
|
|
115
|
+
currentSha(root, canon, _cfg, planId) {
|
|
84
116
|
const plan = findById(canon, planId);
|
|
85
117
|
const parent = plan ? findById(canon, String(plan.meta.parent)) : undefined;
|
|
86
|
-
return plan && parent ? planPairSha(plan, parent) : undefined;
|
|
118
|
+
return plan && parent ? planPairSha(plan, parent, planDecisionsTerm(root, plan, parent)) : undefined;
|
|
87
119
|
},
|
|
88
120
|
upstreamOf(_root, canon, planId) {
|
|
89
121
|
const plan = findById(canon, planId);
|