@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 +4 -4
- package/dist/abandon.js +5 -2
- package/dist/basis.js +35 -0
- package/dist/cli.js +1 -1
- package/dist/decisions.js +118 -0
- package/dist/fm.js +1 -1
- package/dist/gates/decompose.js +16 -3
- package/dist/gates/plan.js +47 -15
- package/dist/history.js +5 -1
- package/dist/reviewed.js +19 -5
- package/dist/schemas.js +3 -0
- package/dist/verbs/check.js +6 -3
- package/dist/verbs/diff.js +26 -1
- package/dist/verbs/log.js +4 -2
- package/dist/verbs/next.js +65 -15
- package/dist/verbs/read.js +26 -2
- package/dist/verbs/recap.js +8 -2
- package/dist/verbs/write.js +27 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +6 -4
- package/plugin/skills/witness-design/SKILL.md +1 -1
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +5 -3
- package/plugin/skills/witness-ship/SKILL.md +1 -1
- package/prompts/plan-critic.md +2 -1
- package/prompts/slicing-critic.md +3 -2
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
|
-
|
|
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) {
|
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);
|
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
|
-
|
|
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
|
|
79
|
-
// created the artifact if any of its writes did.
|
|
80
|
-
out.set(w.artifact, {
|
|
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) =>
|
|
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
|
-
|
|
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) {
|
package/dist/verbs/check.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
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`));
|
package/dist/verbs/diff.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/verbs/next.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
712
|
-
|
|
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
|
|
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' ||
|
|
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.
|
package/dist/verbs/read.js
CHANGED
|
@@ -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;
|
package/dist/verbs/recap.js
CHANGED
|
@@ -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,
|
|
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-
|
|
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
|
}
|
package/dist/verbs/write.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
package/prompts/plan-critic.md
CHANGED
|
@@ -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,
|
|
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
|
|