@ecoma-io/archkeep 0.16.1 → 0.17.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.
@@ -15,10 +15,90 @@ import { ADR_DIR, ADR_STATUSES } from "../governance/adr-registry.mjs";
15
15
  /** A status label for a record, in the text a human reads. */
16
16
  function statusLabel(status) {
17
17
  if (status === "accepted") return "accepted";
18
+ if (status === "active") return "active";
18
19
  if (status === "superseded") return "superseded";
20
+ if (status === "retired") return "retired";
19
21
  return "proposed";
20
22
  }
21
23
 
24
+ /**
25
+ * The `fitness:` line for one record — the per-decision verification level
26
+ * the command derives (`../governance/decision-fitness.mjs` owns the
27
+ * vocabulary). The line is always printed with its reason when the level has
28
+ * one; an `enforced` level carries none and says so. This command alone can
29
+ * never verify anything (its header's "What it cannot assert" owns why), so
30
+ * the level comes in precomputed from the caller — absent it, the line is an
31
+ * explicit "(not measured)" that cannot be mistaken for a verdict.
32
+ *
33
+ * @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
34
+ * @returns {string}
35
+ */
36
+ function fitnessLine(fitness) {
37
+ if (fitness === undefined) return `fitness: ${"(not measured)"}`;
38
+ if (fitness.verified)
39
+ return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
40
+ if (typeof fitness.reason === "string") {
41
+ return `fitness: ${fitness.level} — ${fitness.reason}`;
42
+ }
43
+ return `fitness: ${fitness.level}`;
44
+ }
45
+
46
+ /**
47
+ * The `created:` / `updated:` metadata lines for one record, when the record
48
+ * carries them. Absent frontmatter stays absent — these are the decision's
49
+ * own committed timeline, never a wall-clock guess.
50
+ *
51
+ * @param {object} record
52
+ * @returns {string[]}
53
+ */
54
+ function timelineLines(record) {
55
+ const lines = [];
56
+ if (typeof record.created === "string") lines.push(`created: ${record.created}`);
57
+ if (typeof record.updated === "string") lines.push(`updated: ${record.updated}`);
58
+ return lines;
59
+ }
60
+
61
+ /**
62
+ * The reverse lineage line — the records whose `supersedes` names this one,
63
+ * derived at load by the registry and therefore always consistent with the
64
+ * forward `supersedes:` line on the other record.
65
+ *
66
+ * @param {object} record
67
+ * @returns {string[]}
68
+ */
69
+ function supersededByLines(record) {
70
+ if (!Array.isArray(record.supersededBy) || record.supersededBy.length === 0) return [];
71
+ return [`supersededBy: ${record.supersededBy.join(", ")}`];
72
+ }
73
+
74
+ /**
75
+ * One prose field rendered for the single-record view: the label on the
76
+ * first line, continued lines aligned under the prose. A field that is
77
+ * absent or blank renders nothing — the record's own body decides.
78
+ *
79
+ * @param {string} label The 12-wide `label:` prefix.
80
+ * @param {string} text The field's markdown prose.
81
+ * @returns {string[]}
82
+ */
83
+ function proseLines(label, text) {
84
+ const content = text.split("\n").map((line) => line.trimEnd());
85
+ const out = [`${label}${content[0]}`];
86
+ for (const line of content.slice(1)) {
87
+ out.push(`${" ".repeat(label.length)}${line}`);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** The prose fields of one record, in the order §3.2 of the wave 2 contract names them. */
93
+ const PROSE_FIELDS = [
94
+ ["context: ", "context"],
95
+ ["decision: ", "decision"],
96
+ ["rationale: ", "rationale"],
97
+ ["alternatives:", "alternatives"],
98
+ ["consequences:", "consequences"],
99
+ ["assumptions: ", "assumptions"],
100
+ ];
101
+
22
102
  /**
23
103
  * The `bindings:` line for one record — the ONE place either face decides how
24
104
  * a binding is rendered.
@@ -54,21 +134,26 @@ function bindingsLine(record, known) {
54
134
  * Renders the whole registry: one block per record, each naming its status,
55
135
  * its supersession chain, and the rules/fitnesses it binds.
56
136
  *
57
- * @param {{records: object[], knownFitness?: Set<string>}} result
137
+ * @param {{records: object[], knownFitness?: Set<string>, fitnessById?:
138
+ * Map<string, {level: string, verified: boolean, reason?: string}>}} result
58
139
  * @returns {string}
59
140
  */
60
- export function formatAdrDump({ records, knownFitness }) {
141
+ export function formatAdrDump({ records, knownFitness, fitnessById }) {
61
142
  if (records.length === 0) {
62
143
  return `no ADRs in ${ADR_DIR}/ — nothing is recorded, and nothing is enforceable through it`;
63
144
  }
64
145
  const known = knownFitness ?? new Set();
146
+ const fitnessMap = fitnessById ?? new Map();
65
147
  const blocks = records.map((record) => {
66
148
  const header = `${record.id} (${statusLabel(record.status)})`;
67
149
  const lines = [header, "-".repeat(header.length)];
68
150
  if (record.supersedes.length > 0) {
69
151
  lines.push(`supersedes: ${record.supersedes.join(", ")}`);
70
152
  }
153
+ lines.push(...supersededByLines(record));
154
+ lines.push(...timelineLines(record));
71
155
  lines.push(bindingsLine(record, known));
156
+ lines.push(fitnessLine(fitnessMap.get(record.id)));
72
157
  lines.push(`status set: ${ADR_STATUSES.join(", ")}`);
73
158
  return lines.join("\n");
74
159
  });
@@ -110,20 +195,40 @@ export function formatAdrMissing({ adrId }) {
110
195
  * a reader who asked about one record sees strictly less than one who dumped
111
196
  * the registry otherwise, and what they stop seeing is the marker.
112
197
  *
113
- * @param {{id: string, status: string, supersedes: string[], bindings: string[]}} record
198
+ * Beyond the dump's lines it also renders the record's own prose (context,
199
+ * decision, rationale, alternatives, consequences, assumptions) when the body
200
+ * records them — this is the view a reader asking "why was this decided" gets.
201
+ *
202
+ * @param {{id: string, status: string, supersedes: string[], supersededBy?:
203
+ * string[], bindings: string[], created?: string, updated?: string, context?:
204
+ * string, decision?: string, rationale?: string, alternatives?: string,
205
+ * consequences?: string, assumptions?: string}} record
114
206
  * @param {Set<string>} [knownFitness] The ids the registry's records mention.
115
207
  * Optional only so the parameter can be omitted where there is nothing to
116
208
  * compare against; an absent set marks every binding rather than none,
117
209
  * because "nothing corroborates this" is the honest answer when no set was
118
210
  * supplied — never the quiet one.
211
+ * @param {Map<string, {level: string, verified: boolean, reason?: string}>}
212
+ * [fitnessById] The fitness level per record id, computed by the caller
213
+ * (`../commands/adr.mjs`). Absent, a record's fitness line reads as the
214
+ * explicit "(not measured)" — never as a verdict this renderer invented.
119
215
  * @returns {string}
120
216
  */
121
- export function formatAdrRecord(record, knownFitness) {
217
+ export function formatAdrRecord(record, knownFitness, fitnessById) {
122
218
  const header = `${record.id} (${statusLabel(record.status)})`;
123
219
  const lines = [header, "-".repeat(header.length)];
124
220
  if (record.supersedes.length > 0) {
125
221
  lines.push(`supersedes: ${record.supersedes.join(", ")}`);
126
222
  }
223
+ lines.push(...supersededByLines(record));
224
+ lines.push(...timelineLines(record));
127
225
  lines.push(bindingsLine(record, knownFitness ?? new Set()));
226
+ lines.push(fitnessLine(fitnessById?.get(record.id)));
227
+ for (const [label, key] of PROSE_FIELDS) {
228
+ const text = record[key];
229
+ if (typeof text === "string" && text.trim() !== "") {
230
+ lines.push(...proseLines(label, text));
231
+ }
232
+ }
128
233
  return lines.join("\n");
129
234
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The `decisions` command's text renderer — the deterministic chain
3
+ * decision → governed rows → projects → findings → fitness, one function a
4
+ * test drives for both the resolved and the unresolved walk.
5
+ *
6
+ * The renderer owns the invariant: a chain that could not walk every hop
7
+ * (`walk.ok === false`) renders a loud unresolved block, never a clean-looking
8
+ * chain. The walk (`../governance/decision-graph.mjs`) reports each
9
+ * unresolved reference with a reason; this face prints them all, so a reader
10
+ * sees which hop broke and why — the same refrain as every other surface in
11
+ * this wave ("an empty result is a claim, not a shrug").
12
+ *
13
+ * It is a pure function of the walk result + the record + the fitness level;
14
+ * it reads no files. The fitness line reuses the `fitness:` byte convention
15
+ * `./adr-text.mjs` establishes, so the two decision surfaces cannot disagree
16
+ * about what a level means.
17
+ */
18
+
19
+ /** A status label for a record, in the same terms `./adr-text.mjs` uses. */
20
+ function statusLabel(status) {
21
+ if (status === "accepted") return "accepted";
22
+ if (status === "active") return "active";
23
+ if (status === "superseded") return "superseded";
24
+ if (status === "retired") return "retired";
25
+ return "proposed";
26
+ }
27
+
28
+ /**
29
+ * The `fitness:` line — byte-identical to `./adr-text.mjs`'s own, so a fitness
30
+ * level means the same thing on the `adr` face and here.
31
+ *
32
+ * @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
33
+ * @returns {string}
34
+ */
35
+ function fitnessLine(fitness) {
36
+ if (fitness === undefined) return `fitness: ${"(not measured)"}`;
37
+ if (fitness.verified)
38
+ return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
39
+ if (typeof fitness.reason === "string") {
40
+ return `fitness: ${fitness.level} — ${fitness.reason}`;
41
+ }
42
+ return `fitness: ${fitness.level}`;
43
+ }
44
+
45
+ /** One prose field rendered for a record, first line carrying the label. */
46
+ function proseLines(label, text) {
47
+ const first = text.split("\n")[0];
48
+ const rest = text.split("\n").slice(1);
49
+ const out = [`${label}${first}`];
50
+ for (const line of rest) out.push(`${" ".repeat(label.length)}${line}`);
51
+ return out;
52
+ }
53
+
54
+ /** The prose fields of one record, in the order the wave-2 contract names them. */
55
+ const PROSE_FIELDS = [
56
+ ["context: ", "context"],
57
+ ["decision: ", "decision"],
58
+ ["rationale: ", "rationale"],
59
+ ["alternatives:", "alternatives"],
60
+ ["consequences:", "consequences"],
61
+ ["assumptions: ", "assumptions"],
62
+ ];
63
+
64
+ /** The kind label a governed row node renders as. */
65
+ const ROW_KIND_LABEL = {
66
+ intent: "intent row",
67
+ constraint: "constraint",
68
+ fitness: "fitness rule",
69
+ };
70
+
71
+ /**
72
+ * Renders the deterministic decision chain for one record.
73
+ *
74
+ * @param {object} args
75
+ * @param {string} args.decisionId The id the caller asked about.
76
+ * @param {object} args.record The ADR record the chain is about.
77
+ * @param {object} args.walk The `forwardDecision` walk
78
+ * (`../governance/decision-graph.mjs`): `{ok, nodes, edges, unresolved}`.
79
+ * @param {{level: string, verified: boolean, reason?: string}|undefined}
80
+ * args.fitness The per-decision fitness level, computed by the caller.
81
+ * @returns {string}
82
+ */
83
+ export function formatDecisionChain({ decisionId, record, walk, fitness }) {
84
+ const lines = [];
85
+
86
+ // An id the registry does not know is a named unknown, never a clean chain:
87
+ // the record is null, so there is no header to derive — the walk's
88
+ // unresolved block below is the whole answer. The renderer must not throw
89
+ // over a null record; that is precisely the case it exists to report.
90
+ const header =
91
+ record === null || record === undefined
92
+ ? `${decisionId} (unknown)`
93
+ : `${record.id} (${statusLabel(record.status)})`;
94
+ lines.push(header, "-".repeat(header.length));
95
+
96
+ if (record !== null && record !== undefined) {
97
+ for (const [label, key] of PROSE_FIELDS) {
98
+ const text = record[key];
99
+ if (typeof text === "string" && text.trim() !== "") {
100
+ lines.push(...proseLines(label, text));
101
+ }
102
+ }
103
+
104
+ if ((record.supersedes ?? []).length > 0) {
105
+ lines.push(`supersedes: ${record.supersedes.join(", ")}`);
106
+ }
107
+
108
+ lines.push(fitnessLine(fitness));
109
+ }
110
+
111
+ // The governed rows the decision attaches (decisionRef citations and
112
+ // bindings): the "who stands on this decision" half of the chain.
113
+ const rowNodes = walk.nodes.filter(
114
+ (node) => node.kind === "constraint" || node.kind === "intent" || node.kind === "fitness",
115
+ );
116
+ if (rowNodes.length > 0) {
117
+ lines.push("governs:");
118
+ for (const node of rowNodes) {
119
+ const governed = walk.edges
120
+ .filter((edge) => edge.kind === "governs" && edge.from === node.id)
121
+ .map((edge) => edge.to);
122
+ const kind = ROW_KIND_LABEL[node.kind] ?? node.kind;
123
+ const target = governed.length > 0 ? ` → ${governed.join(", ")}` : "";
124
+ lines.push(` ${node.id} (${kind})${target}`);
125
+ }
126
+ } else {
127
+ lines.push("governs: (none — recorded but not enforceable)");
128
+ }
129
+
130
+ // The evidence leg: every project the rows govern, and each project's
131
+ // current findings. A project with no findings is a fact, stated as such.
132
+ const projectNodes = walk.nodes.filter((node) => node.kind === "project");
133
+ if (projectNodes.length > 0) {
134
+ lines.push("evidence:");
135
+ for (const project of projectNodes) {
136
+ const findings = walk.nodes
137
+ .filter((node) => node.kind === "finding")
138
+ .filter((finding) =>
139
+ walk.edges.some(
140
+ (edge) => edge.kind === "finding" && edge.from === project.id && edge.to === finding.id,
141
+ ),
142
+ );
143
+ if (findings.length === 0) {
144
+ lines.push(` ${project.id}: no current findings`);
145
+ } else {
146
+ for (const finding of findings) {
147
+ const ruleId = finding.data?.ruleId ?? "";
148
+ lines.push(` ${project.id}: ${finding.label}${ruleId ? ` (${ruleId})` : ""}`);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // The loud unresolved block — a walk that could not resolve every hop never
155
+ // reads as a clean chain.
156
+ if (!walk.ok) {
157
+ lines.push("unresolved:");
158
+ for (const entry of walk.unresolved) {
159
+ lines.push(` ${entry.ref}: ${entry.reason}`);
160
+ }
161
+ }
162
+
163
+ return lines.join("\n");
164
+ }
@@ -7,7 +7,7 @@
7
7
  * shape `./text.mjs` uses for violations. Everything after it is indented,
8
8
  * so the position line stands alone.
9
9
  *
10
- * Seven things are printed, each with a reader in mind:
10
+ * Eight things are printed, each with a reader in mind:
11
11
  *
12
12
  * - the import specifier and its kind (what was written)
13
13
  * - the source project and its tags (who wrote it)
@@ -19,6 +19,11 @@
19
19
  * and a remediation line that is the author's declared guidance verbatim —
20
20
  * or, when none is declared, an explicit pointer at the constraint row and
21
21
  * its `decisionRef`, never a fix this renderer composed
22
+ * - for a matched row carrying a `decisionRef`: the governing decision's
23
+ * status and authority, its context/rationale prose, and its supersession
24
+ * lineage — or, when the ref or the registry cannot be resolved, a loud
25
+ * UNRESOLVED line naming the reason (an empty result here would read as
26
+ * "no decision behind this row", which is a different claim)
22
27
  * - coverage information (whether this explanation is complete)
23
28
  *
24
29
  * This module decides nothing. A formatter that filtered would be a rule
@@ -84,6 +89,69 @@ function formatMatchedConstraint(constraint) {
84
89
  return formatConstraint(constraint);
85
90
  }
86
91
 
92
+ /**
93
+ * The status line for one governing decision: `id (status — authority)`.
94
+ *
95
+ * @param {object} entry A resolved `"adr"` chain entry.
96
+ * @returns {string}
97
+ */
98
+ function decisionStatusLine(entry) {
99
+ const authority = entry.authority ? " — has authority" : " — no authority";
100
+ return `${DETAIL}decision ${entry.record.id} (${entry.record.status}${authority})`;
101
+ }
102
+
103
+ /**
104
+ * The first non-empty line of a prose field, trimmed to a display width.
105
+ * The excerpt is a pointer, never a paraphrase — a reader who needs the whole
106
+ * context opens the ADR.
107
+ *
108
+ * @param {string} text
109
+ * @returns {string}
110
+ */
111
+ function proseExcerpt(text) {
112
+ const first = text.split("\n").find((line) => line.trim() !== "") ?? "";
113
+ const trimmed = first.trim();
114
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}…` : trimmed;
115
+ }
116
+
117
+ /**
118
+ * One decision-chain entry as report lines. The chain never renders blank:
119
+ * a ref the registry cannot resolve, and a ref that cannot be read at all,
120
+ * both render as UNRESOLVED naming the reason (AGENTS.md: "an empty result
121
+ * is a claim, not a shrug").
122
+ *
123
+ * @param {object} entry One `explanation.decisions` entry.
124
+ * @returns {string[]}
125
+ */
126
+ function formatDecisionEntry(entry) {
127
+ if (entry.resolution === "unknown") {
128
+ return [`${DETAIL}decisionRef ${entry.ref} (UNRESOLVED — ${entry.reason})`];
129
+ }
130
+ if (entry.resolution === "fitness") {
131
+ return [`${DETAIL}decisionRef ${entry.ref} — a fitness rule this law declares`];
132
+ }
133
+ const lines = [decisionStatusLine(entry)];
134
+ if (typeof entry.record.context === "string") {
135
+ lines.push(`${DETAIL}context ${proseExcerpt(entry.record.context)}`);
136
+ }
137
+ if (typeof entry.record.rationale === "string") {
138
+ lines.push(`${DETAIL}rationale ${proseExcerpt(entry.record.rationale)}`);
139
+ }
140
+ const supersedes = Array.isArray(entry.record.supersedes) ? entry.record.supersedes : [];
141
+ const supersededBy = Array.isArray(entry.record.supersededBy) ? entry.record.supersededBy : [];
142
+ if (supersedes.length > 0 || supersededBy.length > 0) {
143
+ const parts = [];
144
+ if (supersedes.length > 0) parts.push(`supersedes: ${supersedes.join(", ")}`);
145
+ if (supersededBy.length > 0) parts.push(`superseded by: ${supersededBy.join(", ")}`);
146
+ lines.push(`${DETAIL}lineage ${parts.join(" · ")}`);
147
+ } else {
148
+ lines.push(`${DETAIL}lineage none — no supersession chain is recorded for this decision`);
149
+ }
150
+ for (const gap of entry.lineage?.unresolved ?? []) {
151
+ lines.push(`${DETAIL}lineage UNRESOLVED — ${gap.reason}`);
152
+ }
153
+ return lines;
154
+ }
87
155
  /**
88
156
  * The whole explain report.
89
157
  *
@@ -165,6 +233,14 @@ export function formatExplainReport({ explanation, coverage }) {
165
233
  } else {
166
234
  sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
167
235
  }
236
+
237
+ // The "why does this constraint exist" chain — one block per governing
238
+ // decision the matched rows name. Additive: an explanation whose rows
239
+ // carry no `decisionRef` has no `decisions` list, and renders exactly as
240
+ // it did before this section existed.
241
+ for (const entry of explanation.decisions ?? []) {
242
+ sections.push(...formatDecisionEntry(entry));
243
+ }
168
244
  }
169
245
 
170
246
  // Coverage — same shape as every other command's footer.
@@ -9,6 +9,13 @@
9
9
  * `../commands/provenance-command.mjs` already resolved — no wall-clock time
10
10
  * and no `localeCompare` enter here, matching every other report renderer.
11
11
  *
12
+ * A fourth section carries the decision lifecycle: every recorded decision's
13
+ * status, authority, committed timeline, lineage, and bindings, attributed
14
+ * with WHO recorded it (the record file's own git history). A decision with
15
+ * no attributable history is named `no origin recorded — cannot attest`,
16
+ * never silently passed; the section renders only when at least one decision
17
+ * exists, the same "no fact, no claim" bargain the row arms state.
18
+ *
12
19
  * This module decides nothing. A formatter that filtered would be a rule
13
20
  * wearing a formatter's name (`./README.md`).
14
21
  */
@@ -19,10 +26,17 @@
19
26
  * rowsTotal: number,
20
27
  * unattested: {kind: string, label: string, note: string}[],
21
28
  * decisionRefTotal: number,
22
- * unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[]}} input
29
+ * unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
30
+ * decisionLifecycle?: {id: string, status: string, authority: boolean,
31
+ * created: string|null, updated: string|null, supersedes: string[],
32
+ * supersededBy: string[], bindings: string[],
33
+ * attribution: {createdBy: object|null, lastChangedBy: object|null}|null,
34
+ * attested: boolean, note: string|null}[]}} input
23
35
  * `decisionRefTotal` is how many governance rows cite a `decisionRef` at
24
36
  * all — the resolution section renders only when it is non-zero, the same
25
37
  * "no fact, no claim" bargain every optional axis in this tool states.
38
+ * `decisionLifecycle` (optional, default `[]`) is the decision-lifecycle
39
+ * section — it renders only when non-empty.
26
40
  * @returns {string}
27
41
  */
28
42
  export function formatProvenanceReport({
@@ -32,6 +46,7 @@ export function formatProvenanceReport({
32
46
  unattested,
33
47
  decisionRefTotal,
34
48
  unresolvedDecisionRefs,
49
+ decisionLifecycle = [],
35
50
  }) {
36
51
  const attestedCount = rowsTotal - unattested.length;
37
52
  const text = [];
@@ -74,5 +89,56 @@ export function formatProvenanceReport({
74
89
  );
75
90
  }
76
91
  }
92
+ // PR E — the decision lifecycle. "No fact, no claim", like the resolution
93
+ // arm: the section renders only when the registry holds at least one
94
+ // decision, and a decision with no attributable history is listed under a
95
+ // cannot-attest heading, never silently passed.
96
+ const decidedCount = decisionLifecycle.length;
97
+ if (decidedCount > 0) {
98
+ const attributedCount = decisionLifecycle.filter((d) => d.attested).length;
99
+ const unattributedCount = decidedCount - attributedCount;
100
+ text.push(
101
+ `decisions ${decidedCount} recorded — ${attributedCount} attributed, ` +
102
+ `${unattributedCount} without attribution`,
103
+ );
104
+ for (const decision of decisionLifecycle) {
105
+ if (!decision.attested) continue;
106
+ const createdBy = decision.attribution?.createdBy ?? null;
107
+ const lastChangedBy = decision.attribution?.lastChangedBy ?? null;
108
+ const facts = [
109
+ createdBy === null
110
+ ? "created — no origin recorded — cannot attest"
111
+ : `created by ${createdBy.by} on ${createdBy.on}`,
112
+ lastChangedBy === null
113
+ ? "changed — no origin recorded — cannot attest"
114
+ : `changed by ${lastChangedBy.by} on ${lastChangedBy.on}`,
115
+ ];
116
+ if (decision.supersedes.length > 0) {
117
+ facts.push(`supersedes ${decision.supersedes.join(", ")}`);
118
+ }
119
+ if (decision.supersededBy.length > 0) {
120
+ facts.push(`superseded by ${decision.supersededBy.join(", ")}`);
121
+ }
122
+ if (decision.bindings.length > 0) {
123
+ facts.push(`binds ${decision.bindings.join(", ")}`);
124
+ }
125
+ if (decision.created !== null || decision.updated !== null) {
126
+ facts.push(`timeline ${decision.created ?? "?"} → ${decision.updated ?? "?"}`);
127
+ }
128
+ text.push(` ${decision.status.padEnd(11)} ${decision.id} ${facts.join("; ")}`);
129
+ }
130
+ if (unattributedCount > 0) {
131
+ text.push("unattributed lifecycle (no origin recorded — cannot attest):");
132
+ for (const decision of decisionLifecycle) {
133
+ if (!decision.attested) text.push(` ${decision.id}`);
134
+ }
135
+ text.push(`${unattributedCount} of them carry no recorded origin behind their lifecycle`);
136
+ } else {
137
+ text.push(
138
+ `✔ every decision's lifecycle is attributed — each change names who ` +
139
+ `recorded it and with what tool`,
140
+ );
141
+ }
142
+ }
77
143
  return text.join("\n");
78
144
  }
@@ -155,7 +155,13 @@ function fitnessLines(fitness) {
155
155
  */
156
156
  function decisionLines(decisions) {
157
157
  const lines = [surfaceLine("decisions", decisions)];
158
- if (decisions.registry.count !== null) {
158
+ // "does not resolve" is a claim about the REF — that nothing in the
159
+ // workspace answers to it. When the registry itself could not be read, that
160
+ // claim is not one this run established: it could not look. The two get
161
+ // different sentences, for the same reason `unknown` and `not_applicable`
162
+ // do (`count: null` is how the command says the registry was unreadable).
163
+ const registryUnread = decisions.registry.count === null;
164
+ if (!registryUnread) {
159
165
  lines.push(
160
166
  ` ${decisions.registry.count} record${decisions.registry.count === 1 ? "" : "s"} in ` +
161
167
  `${decisions.registry.dir}/`,
@@ -165,29 +171,58 @@ function decisionLines(decisions) {
165
171
  record.bindings.length > 0
166
172
  ? `binds ${record.bindings.join(", ")}`
167
173
  : "binds nothing — not yet enforceable";
174
+ // The record row is byte-identical to the pre-wave-2 line; the fitness
175
+ // and "stands on" lines below it are additive surface (contract items
176
+ // 3 + F/B), never a rewrite of what a longer-maintained reader expects.
168
177
  lines.push(` ${record.id} (${record.status}) ${binds}`);
178
+ if (record.fitness !== undefined) {
179
+ const fit = record.fitness;
180
+ const reasonString =
181
+ typeof fit.reason === "string"
182
+ ? ` — ${fit.reason}`
183
+ : fit.verified
184
+ ? " — verified true: bound constraints resolve and pass"
185
+ : "";
186
+ lines.push(` fitness: ${fit.level}${reasonString}`);
187
+ }
188
+ if (record.authority) {
189
+ if (record.constraints.length > 0) {
190
+ const refs = record.constraints.map((c) => `${c.kind} ${c.label}`).join(", ");
191
+ lines.push(` stands on: ${refs}`);
192
+ } else {
193
+ lines.push(" stands on: no governed row cites this decision");
194
+ }
195
+ }
169
196
  }
170
197
  }
198
+ // The citations render even when the registry could not be read: that is
199
+ // exactly the case where "does not resolve" must NOT be claimed and the
200
+ // registry-unreadable sentence must. Skipping them here would print the
201
+ // "unknown" verdict with no citation lines to back it — the silent direction.
171
202
  if (decisions.citations.length === 0) {
172
203
  lines.push(" no governed row cites a decisionRef");
173
- return lines;
204
+ } else {
205
+ for (const citation of decisions.citations) {
206
+ const target =
207
+ citation.resolution === "adr" && citation.adr !== null
208
+ ? `${citation.adr.id} (${citation.adr.status})`
209
+ : citation.resolution === "fitness"
210
+ ? `${citation.decisionRef} — a fitness rule this law declares`
211
+ : registryUnread
212
+ ? `${citation.decisionRef} — unresolved: the decision registry could not be read`
213
+ : `${citation.decisionRef} — does not resolve`;
214
+ lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
215
+ }
174
216
  }
175
- // "does not resolve" is a claim about the REF — that nothing in the
176
- // workspace answers to it. When the registry itself could not be read, that
177
- // claim is not one this run established: it could not look. The two get
178
- // different sentences, for the same reason `unknown` and `not_applicable`
179
- // do (`count: null` is how the command says the registry was unreadable).
180
- const registryUnread = decisions.registry.count === null;
181
- for (const citation of decisions.citations) {
182
- const target =
183
- citation.resolution === "adr" && citation.adr !== null
184
- ? `${citation.adr.id} (${citation.adr.status})`
185
- : citation.resolution === "fitness"
186
- ? `${citation.decisionRef} — a fitness rule this law declares`
187
- : registryUnread
188
- ? `${citation.decisionRef} — unresolved: the decision registry could not be read`
189
- : `${citation.decisionRef} — does not resolve`;
190
- lines.push(` ${citation.resolution.padEnd(10)}${citation.label} → ${target}`);
217
+ if (
218
+ !registryUnread &&
219
+ Array.isArray(decisions.unresolvedDecisionRefs) &&
220
+ decisions.unresolvedDecisionRefs.length > 0
221
+ ) {
222
+ lines.push(" unresolved decisionRefs:");
223
+ for (const ref of decisions.unresolvedDecisionRefs) {
224
+ lines.push(` ${ref.kind} ${ref.label} → ${ref.reason}`);
225
+ }
191
226
  }
192
227
  return lines;
193
228
  }