@popovych.co/witness 0.22.0 → 0.23.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 CHANGED
@@ -274,10 +274,10 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
274
274
  | verb | does |
275
275
  | --- | --- |
276
276
  | `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
- | `write <id> --effort <e> --meta <json> --body <md>` | validated manifest → spec/plan on disk + journal entry |
279
- | `diff <spec-id>` | delta since the last realized state (plan pin → empty) |
280
- | `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 |
277
+ | `recap [--amend] --file <json>` | validate + persist a scope recap — class, goals, non-goals, constraints and numbered decisions; births the effort journal |
278
+ | `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
+ | `diff <spec-id>` | delta since the last realized state (plan pin → empty), and the decisions that moved since that plan |
280
+ | `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
281
  | `check` | schema, graph, invariants, needs, trailer audit, probes |
282
282
  | `index` | id · summary · status · depends across the canon, then each spec's plans |
283
283
  | `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
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
- export function writeAct(root, id, sha, at = 'HEAD') {
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/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
@@ -75,7 +75,7 @@ const VERB_USAGE = {
75
75
  migrate: 'witness migrate [--dry-run]',
76
76
  log: 'witness log <stream> [--all] [--lineage]',
77
77
  next: 'witness next',
78
- read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
78
+ read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]',
79
79
  recap: 'witness recap --file <recap.json> [--ask <id>] [--amend]',
80
80
  recover: 'witness recover [--complete | --rollback]',
81
81
  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/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) {
@@ -1,6 +1,6 @@
1
1
  import { ok, refuse, v } from '../refusal.js';
2
2
  import { serializeDoc } from '../fm.js';
3
- import { canonicalJson, canonicalSha } from '../sha.js';
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 String(p.meta['derives-from']) !== canonicalSha(parent.meta, parent.body);
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,
@@ -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
- const parentSha = canonicalSha(parent.meta, parent.body);
42
- const pin = String(plan.meta['derives-from']);
43
- checks.push({ name: 'pin-fresh', ok: pin === parentSha,
44
- detail: pin === parentSha
45
- ? `pin ${short(parentSha)}`
46
- : `derives-from ${short(pin)} but parent is at ${short(parentSha)} — rewrite the plan (witness write re-stamps the pin)` });
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 base = baseForSpec(root, canon, String(parent.meta.id), planId);
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: ${String(parent.meta.id)}`, serializeDoc({ meta: parent.meta, body: parent.body }),
60
- `### Delta base`, base.kind === 'empty'
61
- ? 'new spec — the delta is the whole spec'
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(_root, canon, _cfg, planId) {
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);
package/dist/history.js CHANGED
@@ -41,5 +41,9 @@ export function baseForSpec(root, canon, specId, excludePlanId) {
41
41
  });
42
42
  stamped.sort((a, b) => b.at - a.at || String(b.p.meta.id).localeCompare(String(a.p.meta.id)));
43
43
  const latest = stamped[0].p;
44
- return { kind: 'plan-pin', sha: String(latest.meta['derives-from']), planId: String(latest.meta.id) };
44
+ const decisions = latest.meta['decisions-from'];
45
+ return {
46
+ kind: 'plan-pin', sha: String(latest.meta['derives-from']), planId: String(latest.meta.id),
47
+ ...(typeof decisions === 'string' ? { decisions } : {}),
48
+ };
45
49
  }
package/dist/reviewed.js CHANGED
@@ -75,9 +75,12 @@ export function effortWrites(root, effort) {
75
75
  if (e.t !== 'write')
76
76
  continue;
77
77
  const w = e;
78
- // D168. Latest write wins for sha and covers, but a revision does not un-create: the effort
79
- // created the artifact if any of its writes did.
80
- out.set(w.artifact, { sha: w.sha, covers: w.covers, created: w.created ?? out.get(w.artifact)?.created });
78
+ // D168. Latest write wins for sha, covers and carries, but a revision does not un-create:
79
+ // the effort created the artifact if any of its writes did.
80
+ out.set(w.artifact, {
81
+ sha: w.sha, covers: w.covers, carries: w.carries, decisions: w.decisions,
82
+ created: w.created ?? out.get(w.artifact)?.created,
83
+ });
81
84
  }
82
85
  return out;
83
86
  }
@@ -110,14 +113,25 @@ function sha256(parts) {
110
113
  export function effortReviewedSha(root, canon, effort) {
111
114
  const recap = latestRecap(root, effort);
112
115
  const docs = effortSpecs(root, canon, effort);
116
+ const writes = effortWrites(root, effort);
117
+ // Row 198 (Builder's call 7). The decision mapping is judged content, so a carries-only
118
+ // rewrite moves the identity rather than resuming into row 112's `cached` replay — a spec
119
+ // that carries nothing keeps today's bytes (c2). `covers` keeps the replay row 112 records.
113
120
  const lines = docs
114
- .map((d) => `${String(d.meta.id)}:${canonicalSha(d.meta, d.body)}`)
121
+ .map((d) => {
122
+ const carries = writes.get(String(d.meta.id))?.carries ?? [];
123
+ return `${String(d.meta.id)}:${canonicalSha(d.meta, d.body)}${carries.length ? `:carries=${carries.join('+')}` : ''}`;
124
+ })
115
125
  .sort();
116
126
  return { sha: sha256([canonicalJson(recap ?? {}), '\n', lines.join('\n')]), docs };
117
127
  }
118
- export function planPairSha(plan, parent) {
128
+ // Row 198. `decisions` is the plan's decisions term (basis.ts's `planDecisionsTerm`), taken as a
129
+ // sha so this module never imports decisions.ts: design.ts → reviewed.ts sits inside abandon.ts's
130
+ // import closure, which decisions.ts imports. Undefined hashes exactly today's two parts (c2).
131
+ export function planPairSha(plan, parent, decisions) {
119
132
  return sha256([
120
133
  `${String(plan.meta.id)}:${canonicalSha(plan.meta, plan.body)}\n`,
121
134
  `${String(parent.meta.id)}:${canonicalSha(parent.meta, parent.body)}`,
135
+ ...(decisions !== undefined ? [`\ndecisions:${decisions}`] : []),
122
136
  ]);
123
137
  }
package/dist/schemas.js CHANGED
@@ -73,6 +73,9 @@ export function validateDoc(meta, body) {
73
73
  if (meta['design-from'] !== undefined && (typeof meta['design-from'] !== 'string' || !/^[0-9a-f]{64}$/.test(String(meta['design-from'])))) {
74
74
  out.push(v('design-from', 'shape', String(meta['design-from']), '64-hex design artifact sha'));
75
75
  }
76
+ if (meta['decisions-from'] !== undefined && (typeof meta['decisions-from'] !== 'string' || !/^[0-9a-f]{64}$/.test(String(meta['decisions-from'])))) {
77
+ out.push(v('decisions-from', 'shape', String(meta['decisions-from']), '64-hex carried-decisions sha'));
78
+ }
76
79
  out.push(...validateSteps(meta.steps, body));
77
80
  }
78
81
  if (type === 'principles' && meta.criteria !== undefined) {
@@ -15,6 +15,7 @@ import { designPending } from '../design.js';
15
15
  import { ASK_STREAM, reservedId } from '../ask.js';
16
16
  import { runDrift } from '../drift.js';
17
17
  import { auditStateCommits, dirtyStatePaths, divergence, primaryRoot, stateDirs, tryGit } from '../gitio.js';
18
+ import { basisMoved, planBasis, specBasis } from '../basis.js';
18
19
  import { contentAtSha } from '../history.js';
19
20
  import { effortStreams, readStream } from '../journal.js';
20
21
  import { sourceTags } from '../matcher.js';
@@ -24,7 +25,6 @@ import { renderRefusal } from '../refusal.js';
24
25
  import { pendingDecision } from '../rounds.js';
25
26
  import { criteriaExcludes } from '../runner.js';
26
27
  import { findById, findCycle, loadCanon, planFinished } from '../scan.js';
27
- import { canonicalSha } from '../sha.js';
28
28
  import { lazyStamp } from '../stamp.js';
29
29
  import { kv, rows } from '../toon.js';
30
30
  import { pendingTxn } from '../txn.js';
@@ -175,8 +175,11 @@ export async function run(ctx, argv = []) {
175
175
  const id = String(plan.meta.id);
176
176
  if (String(plan.meta.status) === 'in-progress') {
177
177
  const parent = findById(canon, String(plan.meta.parent));
178
- if (parent && canonicalSha(parent.meta, parent.body) !== String(plan.meta['derives-from'])) {
179
- findings.push(f('warn', 'motion', id, 'mid-flight-amendment', `parent ${String(parent.meta.id)} moved off the pin — ship will re-verify against current content`));
178
+ const moved = parent ? basisMoved(planBasis(plan), specBasis(root, parent)) : [];
179
+ if (moved.length > 0) {
180
+ findings.push(f('warn', 'motion', id, 'mid-flight-amendment', moved.includes('spec')
181
+ ? `parent ${String(parent.meta.id)} moved off the pin — ship will re-verify against current content`
182
+ : `the decisions ${String(parent.meta.id)} carries moved off the pin — the plan was derived under the old ones · witness diff ${String(parent.meta.id)}`));
180
183
  }
181
184
  if (!existsSync(worktreePath(root, id))) {
182
185
  findings.push(f('warn', 'motion', id, 'missing-worktree', `witness start ${id} recreates it`));
@@ -5,6 +5,8 @@ import { EXIT } from '../cli.js';
5
5
  import { serializeDoc } from '../fm.js';
6
6
  import { primaryRoot, tryGit } from '../gitio.js';
7
7
  import { baseForSpec, contentAtSha } from '../history.js';
8
+ import { basisMoved, specBasis } from '../basis.js';
9
+ import { carriedDecisions, decisionDelta } from '../decisions.js';
8
10
  import { renderRefusal, v } from '../refusal.js';
9
11
  import { findById, loadCanon } from '../scan.js';
10
12
  import { canonicalSha, short, VOLATILE_FIELDS } from '../sha.js';
@@ -37,13 +39,35 @@ export async function run(ctx, argv) {
37
39
  ctx.out(kv('base', 'empty (never realized)'));
38
40
  ctx.out(kv('sha', short(current)));
39
41
  ctx.out('delta: full content is the delta — new spec');
42
+ const carried = carriedDecisions(root, spec)?.decisions.length ?? 0;
43
+ if (carried > 0)
44
+ ctx.out(`decisions: ${carried} carried — all are the delta · witness read ${id} --decisions`);
40
45
  return EXIT.OK;
41
46
  }
42
47
  ctx.out(kv('base', `plan-pin ${base.planId} @ ${short(base.sha)}`));
43
- if (base.sha === current) {
48
+ // Row 198. The delta is against the base plan's whole basis: its content pin and the
49
+ // decisions the spec carried when it was derived.
50
+ const moved = basisMoved({ spec: base.sha, ...(base.decisions ? { decisions: base.decisions } : {}) }, specBasis(root, spec));
51
+ if (moved.length === 0) {
44
52
  ctx.out('delta: none — no delta since the last plan');
45
53
  return EXIT.OK;
46
54
  }
55
+ const decisionsBlock = () => {
56
+ if (!moved.includes('decisions'))
57
+ return;
58
+ const delta = decisionDelta(root, base, spec);
59
+ if (delta === undefined) {
60
+ ctx.out(`decisions: moved since ${base.planId} — witness read ${id} --decisions`);
61
+ return;
62
+ }
63
+ ctx.out(`decisions: moved since ${base.planId}`);
64
+ rows('decisions-delta', ['id', 'change', 'text'], delta).forEach(ctx.out);
65
+ };
66
+ if (!moved.includes('spec')) {
67
+ ctx.out('delta: decisions only — the spec content is unchanged since the last plan');
68
+ decisionsBlock();
69
+ return EXIT.OK;
70
+ }
47
71
  const baseDoc = contentAtSha(root, spec.rel, base.sha);
48
72
  if (!baseDoc) {
49
73
  rows('findings', ['field', 'rule', 'detail'], [
@@ -56,5 +80,6 @@ export async function run(ctx, argv) {
56
80
  writeFileSync(join(tmp, 'current.md'), canonicalRender(spec.meta, spec.body));
57
81
  const diff = tryGit(root, 'diff', '--no-index', '--', join(tmp, 'base.md'), join(tmp, 'current.md'));
58
82
  ctx.out(diff.out);
83
+ decisionsBlock();
59
84
  return EXIT.OK;
60
85
  }
package/dist/verbs/log.js CHANGED
@@ -14,11 +14,13 @@ function detail(e) {
14
14
  switch (e.t) {
15
15
  case 'recap': {
16
16
  const size = (k) => (Array.isArray(e[k]) ? e[k].length : 0);
17
- return `class=${String(e.class)} goals=${size('goals')} non_goals=${size('non_goals')} slices=${size('slices')}`;
17
+ const decisions = size('decisions') > 0 ? ` decisions=${size('decisions')}` : '';
18
+ return `class=${String(e.class)} goals=${size('goals')}${decisions} non_goals=${size('non_goals')} slices=${size('slices')}`;
18
19
  }
19
20
  case 'write': {
20
21
  const covers = Array.isArray(e.covers) && e.covers.length ? ` covers=${e.covers.join('+')}` : '';
21
- return `artifact=${String(e.artifact)} sha=${short(String(e.sha))}${covers}`;
22
+ const carries = Array.isArray(e.carries) && e.carries.length ? ` carries=${e.carries.join('+')}` : '';
23
+ return `artifact=${String(e.artifact)} sha=${short(String(e.sha))}${covers}${carries}`;
22
24
  }
23
25
  case 'write-refused': {
24
26
  const pairs = Array.isArray(e.rules)
@@ -11,7 +11,7 @@ import { designArtifactCurrent, designCurrentSha, designPending, designUnseen }
11
11
  import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
12
12
  import { diffReviewedSha, effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
13
13
  import { baseForSpec } from '../history.js';
14
- import { canonicalSha } from '../sha.js';
14
+ import { basisMoved, planDecisionsTerm, specBasis } from '../basis.js';
15
15
  import { changedFiles, diffBase, evidenceForDiff, isTestPath } from '../evidence.js';
16
16
  import { greenOwedOn, greenOwedStep, hasCriteriaStep, implementGated, lastRedVacuous, redCycleClosed, redInFlight, stepProgress, stepsLine, } from '../steps.js';
17
17
  import { SESSION_DEFAULT, stagePin } from '../model.js';
@@ -234,6 +234,49 @@ function liveEfforts(root) {
234
234
  .filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
235
235
  .sort((a, b) => a.slug.localeCompare(b.slug));
236
236
  }
237
+ // Row 52 (Builder's call 8, D198). An amendment no round has judged: ≥ 2 recaps, the last
238
+ // after the last SETTLING decompose event — a passed round or an approve. Measured against
239
+ // the last gate run instead, a stopped round followed by `revise` handed the effort back to
240
+ // the fixture shortcut and `next` routed the plan write again.
241
+ function amendedSinceSettled(entries) {
242
+ let recaps = 0;
243
+ let lastRecap = -1;
244
+ let lastSettled = -1;
245
+ entries.forEach((e, i) => {
246
+ if (e.t === 'recap') {
247
+ recaps++;
248
+ lastRecap = i;
249
+ return;
250
+ }
251
+ if (e.gate !== 'decompose')
252
+ return;
253
+ if ((e.t === 'gate-run' && e.outcome === 'passed') || (e.t === 'human-decision' && e.decision === 'approve'))
254
+ lastSettled = i;
255
+ });
256
+ return recaps >= 2 && lastRecap > lastSettled;
257
+ }
258
+ // A finished effort keeps the shortcut: re-arming one spends a critic round on shipped work,
259
+ // and under autonomy a blocking finding would send a headless decompose session to rewrite
260
+ // live specs (c2). A finished effort's change is a new effort.
261
+ function effortFinished(specs, plans) {
262
+ const ids = new Set(specs.map((d) => String(d.meta.id)));
263
+ return specs.every((d) => String(d.meta.status) === 'live') &&
264
+ !plans.some((p) => ids.has(String(p.meta.parent)) && !planFinished(p));
265
+ }
266
+ // Whether an effort's decompose is owed. Settled either by a real gate-run+approve, or
267
+ // (fixture/seed shortcuts) by every effort spec already being past draft — a plan can't
268
+ // legally target a draft parent, so non-draft specs are proof decompose's outcome was
269
+ // reached one way or another. The shortcut stands in for a gate-run, so an OPEN REOPEN
270
+ // un-discharges it too: otherwise it short-circuits past gateSettled and the reopen is
271
+ // never routed. Row 52: so does an amendment no round has settled, unless the effort is
272
+ // finished.
273
+ function decomposeOwed(root, canon, e, specs, plans) {
274
+ const reopened = openReopen(e.entries, 'decompose') !== undefined;
275
+ const specsApproved = !reopened && specs.length > 0 && specs.every((d) => String(d.meta.status) !== 'draft') &&
276
+ (!amendedSinceSettled(e.entries) || effortFinished(specs, plans));
277
+ return !specsApproved && !gateSettled(e.entries, 'decompose', effortReviewedSha(root, canon, e.slug).sha) &&
278
+ !gateParked(e.entries, 'decompose');
279
+ }
237
280
  // The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
238
281
  // `witness start` and ends at the merge stamp. Anything else returns undefined and is
239
282
  // not a flow. Exported because `--flow` and the dashboard must answer with the SAME
@@ -708,17 +751,8 @@ export function computeNext(root, ctx, canon, cfg, opts = {}) {
708
751
  stage: 'decompose', target: e.slug,
709
752
  };
710
753
  }
711
- // Settled either by a real gate-run+approve, or (fixture/seed shortcuts) by every
712
- // effort spec already being past draft — a plan can't legally target a draft parent,
713
- // so non-draft specs are proof decompose's outcome was reached one way or another.
714
- // The shortcut stands in for a gate-run, so an OPEN REOPEN un-discharges it too:
715
- // otherwise it short-circuits past gateSettled and the reopen is never routed.
716
- const specs = effortSpecs(root, canon, e.slug);
717
- const reopened = openReopen(e.entries, 'decompose') !== undefined;
718
- const specsApproved = !reopened && specs.length > 0 && specs.every((d) => String(d.meta.status) !== 'draft');
719
- const effortSha = effortReviewedSha(root, canon, e.slug).sha;
720
- if (!specsApproved && !gateSettled(e.entries, 'decompose', effortSha) &&
721
- !gateParked(e.entries, 'decompose')) {
754
+ if (decomposeOwed(root, canon, e, effortSpecs(root, canon, e.slug), plans)) {
755
+ const effortSha = effortReviewedSha(root, canon, e.slug).sha;
722
756
  return authoringOwed(e.entries, 'decompose', effortSha)
723
757
  ? {
724
758
  line: `witness write <spec-id> --effort ${e.slug} --meta m.json --body b.md`,
@@ -729,11 +763,24 @@ export function computeNext(root, ctx, canon, cfg, opts = {}) {
729
763
  ...noteOf(judgeNote(e.entries, 'decompose', judge)) };
730
764
  }
731
765
  }
766
+ // Row 52 (Builder's call 8). While an effort owes the re-slice an amendment re-armed, the specs
767
+ // it wrote wait for it: a walk scoped to a spec never meets its effort's rung (`admits(e.slug)`
768
+ // is false there), so without this drive would plan a spec mid-re-slice and the chat would open
769
+ // a design session on the pre-amendment spec. Only the amendment's: a decompose owed for another
770
+ // reason (a sibling written after an approve) leaves the approved specs moving, as before. An
771
+ // admitted effort that owes decompose returned above, so only the efforts this walk does not
772
+ // admit can still owe it here. Flows, draft-plan gates and starts keep row 18's soft lock.
773
+ const reslicing = efforts
774
+ .filter((e) => !admits(e.slug) && latestRecap(root, e.slug)?.class !== 'chore' && effortWrites(root, e.slug).size > 0)
775
+ .filter((e) => amendedSinceSettled(e.entries) && decomposeOwed(root, canon, e, effortSpecs(root, canon, e.slug), plans));
776
+ const resliced = new Set(reslicing.flatMap((e) => [...effortWrites(root, e.slug).keys()]));
732
777
  for (const spec of specs) {
733
778
  if (String(spec.meta.status) !== 'approved')
734
779
  continue;
735
780
  if (!admits(String(spec.meta.id)))
736
781
  continue;
782
+ if (resliced.has(String(spec.meta.id)))
783
+ continue;
737
784
  if (!designPending(root, spec))
738
785
  continue;
739
786
  const id = String(spec.meta.id);
@@ -776,14 +823,17 @@ export function computeNext(root, ctx, canon, cfg, opts = {}) {
776
823
  // prints, because the effort ledger is not a readiness signal: an amendment can be
777
824
  // realized by a plan booked under a different effort (issue #25), and a plan count of
778
825
  // zero proves nothing either way. No pin at all means never realized, which is owed.
779
- // Reaching this rung proves decompose is settled: rung 6 returns first otherwise.
826
+ // Reaching this rung proves decompose is settled for every effort the walk admits: rung 6
827
+ // returns first otherwise. A scoped walk relies on `resliced` for the efforts it does not.
780
828
  const unrealizedDelta = (d) => {
781
829
  const base = baseForSpec(root, canon, String(d.meta.id));
782
- return base.kind === 'empty' || base.sha !== canonicalSha(d.meta, d.body);
830
+ return base.kind === 'empty' ||
831
+ basisMoved({ spec: base.sha, ...(base.decisions ? { decisions: base.decisions } : {}) }, specBasis(root, d)).length > 0;
783
832
  };
784
833
  const planless = canon.docs
785
834
  .filter((d) => d.meta.type === 'spec' && plannableParent(d))
786
835
  .filter((d) => admits(String(d.meta.id)))
836
+ .filter((d) => !resliced.has(String(d.meta.id)))
787
837
  .filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) && !planFinished(p)))
788
838
  .filter((d) => (d.meta.depends ?? []).every(ready))
789
839
  // last on purpose: the only filter that costs a subprocess — the in-memory ones above
@@ -822,7 +872,7 @@ export function computeNext(root, ctx, canon, cfg, opts = {}) {
822
872
  if (!admits(id))
823
873
  continue;
824
874
  const parent = findById(canon, String(plan.meta.parent));
825
- const planSha = parent ? planPairSha(plan, parent) : undefined;
875
+ const planSha = parent ? planPairSha(plan, parent, planDecisionsTerm(root, plan, parent)) : undefined;
826
876
  const entries = readStream(root, id);
827
877
  // Parked skips for the same reason settled does — the gate is off the board until a
828
878
  // fresh run brings it back, and `status` is where it stays visible meanwhile.
@@ -7,6 +7,8 @@ import { serializeDoc } from '../fm.js';
7
7
  import { primaryRoot } from '../gitio.js';
8
8
  import { renderRefusal, v } from '../refusal.js';
9
9
  import { findById, loadCanon } from '../scan.js';
10
+ import { latestRecap } from '../journal.js';
11
+ import { decisionLines, recapLines } from '../decisions.js';
10
12
  import { rows } from '../toon.js';
11
13
  // Row 132. `--outline` + `--lines` rather than `--section <anchor>`, deliberately: the
12
14
  // implement skill's fat-artifact rule was *grep for the anchors, then read those offsets*
@@ -47,11 +49,18 @@ function mdOutline(lines) {
47
49
  export async function run(ctx, argv) {
48
50
  const { values, positionals } = parseArgs({
49
51
  args: argv, allowPositionals: true,
50
- options: { design: { type: 'boolean' }, outline: { type: 'boolean' }, lines: { type: 'string' } },
52
+ options: { design: { type: 'boolean' }, outline: { type: 'boolean' }, lines: { type: 'string' }, decisions: { type: 'boolean' } },
51
53
  });
52
54
  const id = positionals[0];
53
55
  if (!id) {
54
- ctx.err('usage: witness read <id> [--design] [--outline] [--lines <a>-<b>]');
56
+ ctx.err('usage: witness read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]');
57
+ return EXIT.REFUSED;
58
+ }
59
+ // Row 198. A separate mode, never mixed into the artifact's lines: those are one array behind
60
+ // whole, outline and slice, and an extra row would shift every range `--outline` names.
61
+ const mixed = [values.design && '--design', values.outline && '--outline', values.lines !== undefined && '--lines'].filter(Boolean);
62
+ if (values.decisions && mixed.length > 0) {
63
+ renderRefusal([v('--decisions', 'exclusive', mixed.join(' '), "witness read <id> --decisions alone — a separate mode, never mixed into the artifact's lines")]).forEach(ctx.err);
55
64
  return EXIT.REFUSED;
56
65
  }
57
66
  // primaryRoot, never ctx.cwd: this verb exists BECAUSE a worktree carries no canon, so
@@ -64,6 +73,21 @@ export async function run(ctx, argv) {
64
73
  const root = rootR.value;
65
74
  const canon = loadCanon(root);
66
75
  const doc = findById(canon, id);
76
+ // Before the unknown-id refusal: an effort slug is the one non-canon id this mode takes (§11).
77
+ if (values.decisions) {
78
+ if (doc?.meta.type === 'plan') {
79
+ renderRefusal([v('id', 'not-a-spec', `${id} is a plan`, `its parent's — witness read ${String(doc.meta.parent)} --decisions`)]).forEach(ctx.err);
80
+ return EXIT.REFUSED;
81
+ }
82
+ const recap = doc === undefined ? latestRecap(root, id) : undefined;
83
+ if (doc === undefined && recap === undefined) {
84
+ renderRefusal([v('id', 'unknown-id', id, 'an id from witness index — or an effort slug with --decisions')]).forEach(ctx.err);
85
+ return EXIT.REFUSED;
86
+ }
87
+ ;
88
+ (doc !== undefined ? decisionLines(root, doc) : recapLines(recap)).forEach(ctx.out);
89
+ return EXIT.OK;
90
+ }
67
91
  if (!doc) {
68
92
  renderRefusal([v('id', 'unknown-id', id, 'an id from witness index')]).forEach(ctx.err);
69
93
  return EXIT.REFUSED;
@@ -52,6 +52,7 @@ export function validateRecap(raw) {
52
52
  out.push(...idTextList(e.goals, 'goals', 'g', true));
53
53
  out.push(...idTextList(e.non_goals, 'non_goals', 'n', false));
54
54
  out.push(...idTextList(e.constraints, 'constraints', 'c', false));
55
+ out.push(...idTextList(e.decisions, 'decisions', 'd', false));
55
56
  if (e.slices !== undefined && (!Array.isArray(e.slices) || e.slices.some((s) => typeof s !== 'string' || s === ''))) {
56
57
  out.push(v('slices', 'shape', JSON.stringify(e.slices), 'list of non-empty strings (non-binding)'));
57
58
  }
@@ -164,7 +165,10 @@ export async function run(ctx, argv) {
164
165
  }
165
166
  const entry = {
166
167
  t: 'recap', effort: recap.effort, class: recap.class,
167
- goals: recap.goals, non_goals: recap.non_goals, constraints: recap.constraints, slices: recap.slices,
168
+ goals: recap.goals, non_goals: recap.non_goals, constraints: recap.constraints,
169
+ // Row 198: no key when there are none, so a decision-free recap journals today's bytes (c2).
170
+ ...(recap.decisions?.length ? { decisions: recap.decisions } : {}),
171
+ slices: recap.slices,
168
172
  ...(recap.ask !== undefined ? { ask: recap.ask } : {}),
169
173
  };
170
174
  // entryLine, never a second inline JSON.stringify — completeTxn re-appends when this
@@ -184,10 +188,12 @@ export async function run(ctx, argv) {
184
188
  ctx.out(kv('effort', recap.effort));
185
189
  ctx.out(kv('class', recap.class));
186
190
  ctx.out(kv('goals', recap.goals.length));
191
+ if (recap.decisions?.length)
192
+ ctx.out(kv('decisions', recap.decisions.length));
187
193
  if (recap.ask !== undefined)
188
194
  ctx.out(kv('ask', `${recap.ask} — answered`));
189
195
  if (values.amend)
190
- ctx.out(kv('amended', 'latest recap wins; coverage re-validates against it'));
196
+ ctx.out(kv('amended', 'latest recap wins; coverage and decisions re-validate against it'));
191
197
  ctx.out(`next: witness write --effort ${recap.effort} --meta <m.json> --body <b.md> <spec-id>`);
192
198
  return EXIT.OK;
193
199
  }
@@ -15,6 +15,7 @@ import { humanCall, renderRefusal, v } from '../refusal.js';
15
15
  import { baseForSpec, contentAtSha } from '../history.js';
16
16
  import { findById, findCycle, loadCanon, planFinished, plansOn } from '../scan.js';
17
17
  import { canonicalJson, canonicalSha, short } from '../sha.js';
18
+ import { carriedDecisions, decisionsSha } from '../decisions.js';
18
19
  import { kv } from '../toon.js';
19
20
  import { crashPoint, guardTxn, withTxn } from '../txn.js';
20
21
  import { validateDoc } from '../schemas.js';
@@ -30,6 +31,15 @@ function buildSpecMeta(root, id, manifest, recap, canon, existing, body) {
30
31
  if (!goalIds.has(g))
31
32
  violations.push(v(`covers[${i}]`, 'unknown-goal', String(g), `a goal id from the latest recap: ${[...goalIds].join(' ')}`));
32
33
  });
34
+ // Row 198: the decisions this spec realizes ride covers' rail — journal only, never frontmatter.
35
+ // Journaled as a set in id order, so a reordered or repeated list is the same mapping.
36
+ const listed = Array.isArray(manifest.carries) ? manifest.carries : [];
37
+ const decisionIds = new Set((recap.decisions ?? []).map((d) => d.id));
38
+ listed.forEach((d, i) => {
39
+ if (!decisionIds.has(d))
40
+ violations.push(v(`carries[${i}]`, 'unknown-decision', String(d), `a decision id from the latest recap: ${[...decisionIds].join(' ') || '(none)'}`));
41
+ });
42
+ const carries = [...new Set(listed)].sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
33
43
  const meta = {
34
44
  id,
35
45
  type,
@@ -59,7 +69,7 @@ function buildSpecMeta(root, id, manifest, recap, canon, existing, body) {
59
69
  if (typeof manifest.supersedes === 'string' && !findById(canon, manifest.supersedes) && !streamExists(root, manifest.supersedes)) {
60
70
  violations.push(v('supersedes', 'unknown-ref', manifest.supersedes, 'an existing spec id or frozen journal stream'));
61
71
  }
62
- return { meta, covers, violations, warnings: [] };
72
+ return { meta, covers, carries, violations, warnings: [] };
63
73
  }
64
74
  export async function run(ctx, argv) {
65
75
  const { values, positionals } = parseArgs({
@@ -237,7 +247,8 @@ export async function run(ctx, argv) {
237
247
  : canonicalSha(built.meta, body);
238
248
  const entry = {
239
249
  t: 'write', effort, artifact: id, sha,
240
- ...(manifest.type === 'plan' ? {} : { covers: built.covers }),
250
+ ...(manifest.type === 'plan' ? {} : { covers: built.covers, ...(built.carries.length ? { carries: built.carries } : {}) }),
251
+ ...(built.decisions ? { decisions: built.decisions } : {}),
241
252
  ...(existing === undefined ? { created: true } : {}),
242
253
  ...(supersedes !== undefined ? { supersedes } : {}),
243
254
  };
@@ -304,7 +315,7 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
304
315
  const parent = parentId ? findById(canon, parentId) : undefined;
305
316
  if (!parent) {
306
317
  violations.push(v('parent', 'unknown-parent', parentId || 'absent', 'an existing spec id (or principles for chores)'));
307
- return { meta: {}, covers: [], violations, warnings };
318
+ return { meta: {}, covers: [], carries: [], violations, warnings };
308
319
  }
309
320
  if (parent.meta.type === 'principles' && recap.class !== 'chore') {
310
321
  violations.push(v('parent', 'class-mismatch', `principles parent on a ${recap.class} effort`, 'feature and fix plans derive from a spec'));
@@ -320,6 +331,17 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
320
331
  if (typeof supplied === 'string' && supplied !== computed) {
321
332
  violations.push(v('derives-from', 'stale-derivation', short(supplied), `current parent content is ${short(computed)} — re-derive from witness diff ${parentId}`));
322
333
  }
334
+ // Row 198. The decisions the parent carries pin beside its content, absent when it carries
335
+ // none, so a decision-free plan keeps today's bytes (c2). A chore's own decisions never pin
336
+ // here (Builder's call 4): the chore plan would become its parent's base with a pin the
337
+ // parent's carried set never matches, and the parent would re-queue forever.
338
+ const carried = carriedDecisions(root, parent)?.decisions ?? [];
339
+ const decisionsFrom = decisionsSha(carried);
340
+ const suppliedDecisions = manifest['decisions-from'];
341
+ if (suppliedDecisions !== undefined && suppliedDecisions !== decisionsFrom) {
342
+ const pin7 = (x) => (x === undefined ? 'none' : short(String(x)));
343
+ violations.push(v('decisions-from', 'stale-decisions', pin7(suppliedDecisions), `the decisions ${parentId} carries are at ${pin7(decisionsFrom)} — omit decisions-from; witness write stamps it`));
344
+ }
323
345
  const parentUi = parent.meta.ui === true;
324
346
  const suppliedDesignFrom = manifest['design-from'];
325
347
  if (!parentUi && suppliedDesignFrom !== undefined) {
@@ -364,6 +386,7 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
364
386
  parent: parentId,
365
387
  'derives-from': computed,
366
388
  ...(designFrom !== undefined ? { 'design-from': designFrom } : {}),
389
+ ...(decisionsFrom !== undefined ? { 'decisions-from': decisionsFrom } : {}),
367
390
  depends: manifest.depends ?? [],
368
391
  needs: manifest.needs ?? [],
369
392
  steps: manifest.steps,
@@ -399,7 +422,7 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
399
422
  const cycle = findCycle(canon, { id, depends });
400
423
  if (cycle)
401
424
  violations.push(v('depends', 'cycle', cycle.join(' -> '), 'an acyclic depends graph'));
402
- return { meta, covers: [], violations, warnings };
425
+ return { meta, covers: [], carries: [], ...(carried.length ? { decisions: carried } : {}), violations, warnings };
403
426
  }
404
427
  function deltaCriteriaIds(root, canon, parent, excludePlanId) {
405
428
  const current = Array.isArray(parent.meta.criteria)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witness",
3
3
  "description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
4
- "version": "0.22.0",
4
+ "version": "0.23.0",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -12,7 +12,7 @@ Any other words in `$ARGUMENTS` are the human's ask: run `$WITNESS ask "<those w
12
12
  Resolve the CLI once:
13
13
 
14
14
  ```bash
15
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
15
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
16
16
  ```
17
17
 
18
18
  If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
@@ -3,4 +3,4 @@
3
3
  # context). Silent everywhere that isn't a witness repo; failures swallowed:
4
4
  # session start must never hang or die on this.
5
5
  [ -f witness.config.yaml ] || exit 0
6
- ${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0} 2>/dev/null || true
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
10
10
  Resolve the CLI once per session:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
14
14
  ```
15
15
 
16
16
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -27,7 +27,7 @@ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
27
27
  ## Inputs (rebuild them, never remember them)
28
28
 
29
29
  ```bash
30
- $WITNESS log <effort> # the latest recap entry is your contract: class, goals g*, non-goals n*, constraints c*
30
+ $WITNESS read <effort> --decisions # the latest recap: class, goals g*, decisions d*, constraints c*, non-goals n*
31
31
  $WITNESS index # live canon: id · summary · status · depends, grouped by dir
32
32
  $WITNESS decide decompose <effort> --show # ONLY when re-entered after a revise
33
33
  ```
@@ -47,7 +47,7 @@ The class comes from the recap. Never ask for it again.
47
47
  - Amending a living spec is the same write with the existing id — its status resets to `draft` (re-approval owed). If the CLI warns the spec has an in-flight child plan, surface that warning at the gate stop.
48
48
  - Boundary changes (split, merge, extraction out of a live spec) → the new spec's manifest carries `supersedes: <old-id>`; rewrite every dependent's `depends` in the same effort — the CLI refuses dangling edges. It also refuses `dangling-parent` while a plan of the old spec is still in motion: whether that plan ships first or is abandoned is the human's call, so stop and surface it — never abandon it yourself.
49
49
  - Cross-cutting changes → sequenced per-slice amendments ordered via `depends`, expand-contract style (accept both → switch → drop old), each step independently shippable.
50
- - Self-check totality before gating: every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal.
50
+ - Self-check totality before gating: every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal, every decision carried by ≥ 1 spec. A decision no slice can carry was filed wrong — it is a constraint or a non-goal; say so at the gate stop, where `--revise --upstream` re-opens the recap.
51
51
 
52
52
  ## Author each spec
53
53
 
@@ -65,7 +65,8 @@ cat > "$DIR/meta.json" <<'EOF'
65
65
  { "id": "ac-rotate", "test": "@spec:auth-refresh" },
66
66
  { "id": "ac-smoke", "cmd": "npm run smoke:auth" }
67
67
  ],
68
- "covers": ["g1"]
68
+ "covers": ["g1"],
69
+ "carries": ["d1"]
69
70
  }
70
71
  EOF
71
72
  cat > "$DIR/body.md" <<'EOF'
@@ -87,6 +88,7 @@ Get these right the first time — the dashboard trends your first-try valid rat
87
88
  - `summary` ≤ 120 chars: what the slice **is** (Motivation carries the why).
88
89
  - `criteria`: ≥ 1 entry, unique ids, each exactly one of `test` (value must be `@spec:<this-spec-id>`) or `cmd`. A criterion that cannot fail is not a criterion.
89
90
  - `covers`: the recap goal ids this spec honestly advances — the critic judges the mapping's quality.
91
+ - `carries`: the recap decision ids this spec realizes. Every decision is carried by ≥ 1 spec (the gate's `decision-coverage`). A behavior decision is stated in that spec's `## Behavior`; an approach decision is not contradicted. The journal holds it, never frontmatter. Omit it when the recap has no decisions.
90
92
  - Body: exactly the `## Motivation` and `## Behavior` headings; behavior-only — internal interfaces never appear in a spec.
91
93
  - `id`: `[a-z0-9-]+`; new ids land at `specs/<id>.md` (or the repo's configured specs dir). An id that already names something is refused `id-taken` — a superseded spec, an effort's slug, witness's `floor` — and so is a path another doc holds (`path-taken`): choose a new id.
92
94
  - `needs`: external prerequisites only, machine-checkable (`env:` / `cmd:` / `manual:` + `satisfied`). A manual need the slice can still be built and shipped without carries an `id` and names the `cmd:` criteria it holds — `{ "id": "ses-prod", "manual": "SES production access", "satisfied": false, "holds": ["ac-baseline"] }` — and while it is unmet those criteria report `held` instead of failing. A need the slice cannot even start without belongs on its plan, where it blocks `witness start`.
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
20
20
  ```
21
21
 
22
22
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -36,6 +36,8 @@ Everything below is rebuilt from `witness diff` and the CLI's read verbs — nev
36
36
  ```bash
37
37
  $WITNESS diff <spec-id> # the delta this plan must realize (base: previous plan's pin → last live → empty)
38
38
  $WITNESS read <spec-id> # the parent spec, current content (reading is fine — writing is not)
39
+ $WITNESS read <spec-id> --decisions # the decisions the spec carries + its effort's constraints and non-goals — the plan contradicts none (the critic blocks it)
40
+ $WITNESS read <effort> --decisions # a chore plan: the chore's whole recap, too
39
41
  $WITNESS index # the plans table names this spec's prior plans, if any
40
42
  $WITNESS read <plan-id> # …then read the one you care about
41
43
  $WITNESS decide plan <plan-id> --show # ONLY when re-entered after a revise
@@ -47,7 +49,7 @@ $WITNESS decide plan <plan-id> --show # ONLY when re-entered after a revis
47
49
 
48
50
  ## Author the plan
49
51
 
50
- Every criterion in the delta must be realized by ≥ 1 step; every step maps to ≥ 1 criterion **or** is honestly `scaffolding: true` (rigging only — fixtures, wiring, config; never behavior a criterion owns). `derives-from` is **stamped by the CLI** from the parent's current content — never put it in the manifest; a supplied stale pin refuses.
52
+ Every criterion in the delta must be realized by ≥ 1 step; every step maps to ≥ 1 criterion **or** is honestly `scaffolding: true` (rigging only — fixtures, wiring, config; never behavior a criterion owns). `derives-from` and `decisions-from` are **stamped by the CLI** from the parent's current content and the decisions it carries — never put either in the manifest; a supplied stale pin refuses.
51
53
 
52
54
  If the parent spec is `ui`-flagged, its **design must already be approved** (the design stage runs between decompose and plan). Read it with `$WITNESS read <spec-id> --design` (`--outline`, then `--lines <a>-<b>`, when it is fat) — your steps derive from that approved look, not a fresh invention — and put its approved artifact sha in the manifest as `"design-from"` (the CLI refuses a plan whose pin is missing, stale, or present on a non-ui parent; get it from the spec's `design.sha` stamp via `$WITNESS log <spec-id>`). A UI step names the design section (`design#<id>`) it realizes alongside its `@spec:` browser test.
53
55
 
@@ -94,6 +96,6 @@ $WITNESS gate plan <plan-id> # append --manual when the run asked for it
94
96
 
95
97
  - **Auto-pass** → done; hand back to /witness.
96
98
  - **Stop** → render the gate output verbatim and in full, including its ranked options and `run:` line, and END YOUR TURN.
97
- - **Re-entered after `--revise`** → `decide --show` gives the verdict + note (findings anchor to `<plan-id> > ## Step: <id>`); rewrite via `witness write` with the same plan id; re-gate. A parent amended mid-flight fails `pin-fresh` — rewriting through `witness write` re-stamps the pin to current content; your body must then realize the *new* delta (`$WITNESS diff` again). `--show` also emits `state:` and `exits:` — a `reopened` or `settled` state means the verdict above it is history, so act on the `exits:` line, not on remembered findings.
99
+ - **Re-entered after `--revise`** → `decide --show` gives the verdict + note (findings anchor to `<plan-id> > ## Step: <id>`); rewrite via `witness write` with the same plan id; re-gate. A parent amended mid-flight, or a decision it carries changed, fails `pin-fresh` — rewriting through `witness write` re-stamps the pin to current content; your body must then realize the *new* delta (`$WITNESS diff` again). `--show` also emits `state:` and `exits:` — a `reopened` or `settled` state means the verdict above it is history, so act on the `exits:` line, not on remembered findings.
98
100
  - Findings implicate the **spec** (plan faithful, spec wrong)? Tell the human `--revise --upstream <spec-id>` reopens decompose for it.
99
101
  - **Under `autonomy: on`** (printed by `gate`; not on a `--manual` run): the CLI settles this stop and prints `decided:`. If a `relay:` line printed under it, this session has reached its context limit: print that line verbatim and END YOUR TURN — a fresh session takes the revise. Otherwise, on `→ revise`, rewrite from the revise-context it printed and re-gate; on `→ approve`, `→ revise-upstream` or `→ stop`, hand back to /witness. Never run `witness decide`. End your turn only at a `kept-stop:` line, a `relay:` line, or a refusal you cannot repair.
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.22.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.23.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -5,10 +5,11 @@ You judge a plan against its parent spec and the delta it claims to realize. You
5
5
  1. **Delta faithfulness.** The plan must realize the whole delta and nothing beyond it. Two blocking shapes: a step whose body implements something the delta never asked for (scope invention — plans derive from specs, never extend them), and a delta criterion whose mapped step is mapped on paper only (the body prose does something other than what would make that criterion pass).
6
6
  2. **Step quality.** Each step must be independently checkable: concrete behavior, an observable end state, right-sized. "Make it robust", "improve the handling", "clean up X" with no observable outcome — blocking. A step that presumes an artifact a *later* step creates (order broken) — blocking.
7
7
  3. **Honest scaffolding.** `scaffolding: true` exists for genuine rigging — fixtures, wiring, config. A scaffolding-marked step whose body carries behavior some criterion should own is dodging the mapping — blocking.
8
+ 4. **Decision fidelity.** When present, the decisions block lists what the parent's slicing settled (the recap decisions the parent carries) and its effort's standing limits (constraints and non-goals); a chore plan also gets the chore's whole recap. A step whose body builds what a non-goal rules out, breaks a constraint, or takes an approach a decision ruled out is blocking. When the delta base lists decisions added or changed since the previous plan, a plan whose steps realize none of them is blocking. Otherwise silence is not contradiction: a step need not restate a decision it does not touch. Anchor findings to `<plan-id> > ## Step: <id>`.
8
9
 
9
10
  Judge the body prose, not just the manifest: totality was checked mechanically over ids; you are the only reader comparing what the step *says it will do* against what the delta *needs done*.
10
11
 
11
- The reviewed content arrives as documents: the parent spec, the delta rendering, and the plan. Anchor findings to heading paths — plan findings usually anchor to `<plan-id> > ## Step: <id>`.
12
+ The reviewed content arrives as documents: the parent spec, the delta rendering, the decisions block when the parent carries decisions or its effort has standing limits, and the plan. Anchor findings to heading paths — plan findings usually anchor to `<plan-id> > ## Step: <id>`.
12
13
 
13
14
  ## Verdict contract
14
15
 
@@ -1,6 +1,6 @@
1
1
  # Slicing critic — witness decompose gate
2
2
 
3
- You judge a decompose result: the effort's confirmed scope recap (goals / non-goals / constraints as JSON, each item carrying an id like `g1`/`n1`/`c1`), the goal-coverage mapping, and every spec written for the effort. Mechanical checks already passed before you were invoked — schema, DAG acyclicity, criteria parse, and coverage totality (every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal). You judge what machines cannot: quality. Scope approval is a human's call; your findings are what the human sees first.
3
+ You judge a decompose result: the effort's confirmed scope recap (goals / non-goals / constraints / decisions as JSON, each item carrying an id like `g1`/`n1`/`c1`/`d1`), the goal-coverage mapping, the decision-carrying mapping when the recap has decisions, and every spec written for the effort. Mechanical checks already passed before you were invoked — schema, DAG acyclicity, criteria parse, coverage totality (every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal), and decision-coverage totality (every decision carried by ≥ 1 spec). You judge what machines cannot: quality. Scope approval is a human's call; your findings are what the human sees first.
4
4
 
5
5
  Work every lens below over every spec, in this order:
6
6
 
@@ -12,12 +12,13 @@ Work every lens below over every spec, in this order:
12
12
  6. **Summary accuracy.** The frontmatter `summary` must say what the slice *is* and match the Behavior section. It feeds `witness index`, which routes every future fix — a summary promising more, less, or different misroutes forever: blocking.
13
13
  7. **Depends refs.** Missing edges are the dangerous direction: a spec whose behavior presumes another slice landed, with no `depends` edge, is blocking. Decorative extra edges are notes.
14
14
  8. **UI-flag honesty.** A spec whose `## Behavior` describes browser-rendered or interactive surface (a screen, a form, client-side interaction) must carry `ui: true` — it earns a design stage before its plan. A spec so flagged whose behavior is purely non-visual (a backend job, a data contract) must not. A missing flag on a UI slice, or a spurious flag on a non-UI slice, is blocking — the flag routes the whole design stage.
15
+ 9. **Decision fidelity.** A recap decision is a settled answer; a spec that `carries` it realizes it. A spec that contradicts a decision it carries is blocking. A decision that states observable behavior must appear in the `## Behavior` of every spec that carries it — missing is blocking. An approach decision needs no Behavior line, but no spec may contradict it. Anchor findings to the spec's heading.
15
16
 
16
17
  Also watch for **contradictions**: two specs stating rules that cannot both hold for the same subject — the same trigger with opposite outcomes, or two thresholds for one fact — with no spec stating which wins. Always **blocking**: duplication *may* drift, but a contradiction is already wrong, and a canon that contradicts itself is lying today. The remedy is a spec, never a later stage: which rule wins is behavior, and behavior lives in `## Behavior`. Anchor to the heading in either spec.
17
18
 
18
19
  Also watch for **duplicated facts**: one fact (a threshold, format, protocol) written into two specs. Recommend extraction into its own spec — blocking when the duplication would drift, a note otherwise.
19
20
 
20
- The reviewed content arrives as documents: the recap JSON, the coverage table, and each spec (frontmatter + body). Anchor doc findings to heading paths within the spec's document id.
21
+ The reviewed content arrives as documents: the recap JSON, the coverage table, the decision table when the recap carries decisions, and each spec (frontmatter + body). Anchor doc findings to heading paths within the spec's document id.
21
22
 
22
23
  ## Verdict contract
23
24