@ecoma-io/archkeep 0.16.1 → 0.18.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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -22,8 +22,10 @@ import { formatChanges, sanitize, transitionKind } from "./snapshot-text.mjs";
22
22
  * @param {{result: {base: string, head: string,
23
23
  * revisions: {commit: string, id: string}[],
24
24
  * transitions: {from: string, to: string, architectureChanged: boolean,
25
- * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
26
- * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
25
+ * changes: object|null, policyChanged: boolean|null, policyOneSided: boolean,
26
+ * provenanceChanged: boolean|null, providerChanged: boolean,
27
+ * codeDrift: boolean, notes: string[], comparison?: object}[],
28
+ * summary?: object}, coverage: object}} input
27
29
  * @returns {string}
28
30
  */
29
31
  export function formatEvolutionReport({ result, coverage }) {
@@ -62,6 +64,17 @@ export function formatEvolutionReport({ result, coverage }) {
62
64
  for (const note of transition.notes) {
63
65
  sections.push(` ${note}`);
64
66
  }
67
+ // The per-transition comparison report — the eight questions answered on
68
+ // this one revision pair (design §6), from the envelope fields the command
69
+ // already computed. An incomparable axis is printed with its reason, never
70
+ // folded into silence.
71
+ if (transition.comparison) {
72
+ sections.push(``);
73
+ sections.push(
74
+ ` comparison for ${sanitize(transition.from.slice(0, 12))} → ${sanitize(transition.to.slice(0, 12))}`,
75
+ );
76
+ sections.push(...formatComparisonSection(transition.comparison));
77
+ }
65
78
  }
66
79
 
67
80
  if (changed === 0) {
@@ -79,5 +92,221 @@ export function formatEvolutionReport({ result, coverage }) {
79
92
  );
80
93
  }
81
94
 
95
+ // The range-wide summary — the same eight questions answered over the whole
96
+ // record, so a reader sees both the per-pair detail and the verdict carried
97
+ // by all of them together (design §8). An axis that any pair could not
98
+ // compare is reported as n/a with the reason, never as a fabricated clean
99
+ // aggregate.
100
+ if (result.summary) {
101
+ sections.push(``);
102
+ sections.push(`summary`);
103
+ sections.push(...formatSummarySection(result.summary));
104
+ }
105
+
82
106
  return sections.join("\n");
83
107
  }
108
+
109
+ /**
110
+ * Renders an incomparable axis marker as `n/a — <reason>` (design §6). An
111
+ * axis that could not be answered is never folded into a clean "none".
112
+ *
113
+ * @param {{available: false, reason?: string}|undefined} axis
114
+ * @returns {string}
115
+ */
116
+ function naWithReason(axis) {
117
+ return `n/a — ${axis?.reason ?? "not comparable"}`;
118
+ }
119
+
120
+ /**
121
+ * Renders one verdict value: a verdict string as-is, an incomparable marker
122
+ * as `n/a — <reason>`, and an absent value as `n/a` with a fallback reason.
123
+ *
124
+ * @param {string|{available: false, reason?: string}|undefined} value
125
+ * @param {string} [fallbackReason]
126
+ * @returns {string}
127
+ */
128
+ function renderVerdict(value, fallbackReason) {
129
+ if (value === undefined || value === null) return `n/a — ${fallbackReason ?? "unknown"}`;
130
+ if (typeof value === "object" && value.available === false) {
131
+ return `n/a — ${value.reason ?? "not comparable"}`;
132
+ }
133
+ return String(value);
134
+ }
135
+
136
+ /**
137
+ * Names an edge for the report: `source→target:type` for the object shape the
138
+ * diff produces, plain text otherwise.
139
+ *
140
+ * @param {object|string} edge
141
+ * @returns {string}
142
+ */
143
+ function edgeName(edge) {
144
+ return edge && typeof edge === "object"
145
+ ? `${edge.source}→${edge.target}${edge.type ? `:${edge.type}` : ""}`
146
+ : String(edge);
147
+ }
148
+
149
+ /**
150
+ * The report section for one transition's comparison (design §6) — the eight
151
+ * questions answered for that single revision pair. Reads the envelope fields
152
+ * the command computed; incomparable axes print `n/a — <reason>`, never a
153
+ * fabricated clean answer.
154
+ *
155
+ * @param {object} comparison One `result.transitions[i].comparison`.
156
+ * @returns {string[]} Report lines.
157
+ */
158
+ function formatComparisonSection(comparison) {
159
+ const out = [];
160
+ const observed = comparison.observed ?? {};
161
+ const projects = observed.projects ?? {};
162
+ const edges = observed.edges ?? {};
163
+ const projectName = (p) => (typeof p === "string" ? p : (p?.name ?? JSON.stringify(p)));
164
+ const listLine = (label, items) =>
165
+ items.length === 0 ? [] : [` ${label}: ${items.join(", ")}`];
166
+
167
+ out.push(` disposition: ${comparison.disposition ?? "unknown"}`);
168
+ const cls = comparison.classifications ?? [];
169
+ out.push(` classifications: ${cls.length === 0 ? "none" : cls.join(", ")}`);
170
+ out.push(` architecture changed: ${observed.architectureChanged ? "yes" : "no"}`);
171
+ out.push(...listLine("projects added", (projects.added ?? []).map(projectName)));
172
+ out.push(...listLine("projects removed", (projects.removed ?? []).map(projectName)));
173
+ out.push(...listLine("projects changed", (projects.changed ?? []).map(projectName)));
174
+ out.push(...listLine("edges added", (edges.added ?? []).map(edgeName)));
175
+ out.push(...listLine("edges removed", (edges.removed ?? []).map(edgeName)));
176
+ if (observed.policyChanged === true || observed.policyChanged === false) {
177
+ out.push(` policy changed: ${observed.policyChanged ? "yes" : "no"}`);
178
+ }
179
+ if (observed.policyOneSided === true) out.push(` policy changed: one-sided`);
180
+ if (observed.providerChanged === true || observed.providerChanged === false) {
181
+ out.push(` provider changed: ${observed.providerChanged ? "yes" : "no"}`);
182
+ }
183
+ if (observed.provenanceChanged === true) out.push(` provenance changed: yes`);
184
+
185
+ const findings = comparison.findings;
186
+ if (findings?.available === false) out.push(` drift findings: ${naWithReason(findings)}`);
187
+ else if (findings) {
188
+ out.push(
189
+ ` drift findings: introduced ${findings.introduced.length}, resolved ${findings.resolved.length}` +
190
+ (findings.unknown?.length ? `, unknown ${findings.unknown.length}` : ""),
191
+ );
192
+ }
193
+
194
+ const debt = comparison.debt;
195
+ if (debt?.available === false) out.push(` debt: ${naWithReason(debt)}`);
196
+ else if (debt)
197
+ out.push(` debt: introduced ${debt.introduced.length}, resolved ${debt.resolved.length}`);
198
+
199
+ const fitness = comparison.fitness;
200
+ if (fitness?.available === false) out.push(` fitness: ${naWithReason(fitness)}`);
201
+ else if (fitness?.verdictDeltas) {
202
+ out.push(` fitness:`);
203
+ for (const delta of fitness.verdictDeltas) {
204
+ out.push(` ${delta.id}: ${renderVerdict(delta.base)} → ${renderVerdict(delta.head)}`);
205
+ }
206
+ }
207
+
208
+ const cov = comparison.coverage;
209
+ if (cov) {
210
+ out.push(
211
+ ` coverage: base ${cov.base?.projects ?? 0} project${cov.base?.projects === 1 ? "" : "s"} / ` +
212
+ `${cov.base?.analyzedFiles ?? 0} files / ${cov.base?.imports ?? 0} imports; ` +
213
+ `head ${cov.head?.projects ?? 0} / ${cov.head?.analyzedFiles ?? 0} / ${cov.head?.imports ?? 0}`,
214
+ );
215
+ }
216
+
217
+ const affected = comparison.affected;
218
+ if (affected) {
219
+ if (affected.projects?.length) out.push(` affected projects: ${affected.projects.join(", ")}`);
220
+ if (affected.boundaries?.length)
221
+ out.push(` affected boundaries: ${affected.boundaries.join(", ")}`);
222
+ if (affected.constraints?.length)
223
+ out.push(` affected constraints: ${affected.constraints.join(", ")}`);
224
+ if (affected.decisions?.length)
225
+ out.push(` affected decisions: ${affected.decisions.join(", ")}`);
226
+ if (
227
+ !["projects", "boundaries", "constraints", "decisions", "lineage"].some(
228
+ (key) => affected[key]?.length,
229
+ )
230
+ ) {
231
+ out.push(` affected: none`);
232
+ }
233
+ }
234
+
235
+ for (const note of comparison.notes ?? []) out.push(` note: ${note}`);
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * The report section for the whole-range summary (design §8) — the same eight
241
+ * questions answered over every transition together. Any axis with an
242
+ * incomparable transition prints `n/a — <reason>` naming that transition.
243
+ *
244
+ * @param {object} summary `result.summary`.
245
+ * @returns {string[]} Report lines.
246
+ */
247
+ function formatSummarySection(summary) {
248
+ const out = [];
249
+ out.push(` transitions: ${summary.transitions}`);
250
+ out.push(` disposition: ${summary.disposition ?? "unknown"}`);
251
+ const cls = summary.classifications ?? [];
252
+ out.push(` classifications: ${cls.length === 0 ? "none" : cls.join(", ")}`);
253
+
254
+ const observed = summary.observed ?? {};
255
+ const projects = observed.projects ?? {};
256
+ const edges = observed.edges ?? {};
257
+ const listLine = (label, items) =>
258
+ items.length === 0 ? [] : [` ${label}: ${items.join(", ")}`];
259
+ out.push(
260
+ ` architecture changed in ${observed.architectureChanged ?? 0} transition${
261
+ observed.architectureChanged === 1 ? "" : "s"
262
+ }`,
263
+ );
264
+ out.push(...listLine("projects added", projects.added ?? []));
265
+ out.push(...listLine("projects removed", projects.removed ?? []));
266
+ out.push(...listLine("projects changed", projects.changed ?? []));
267
+ out.push(...listLine("edges added", (edges.added ?? []).map(edgeName)));
268
+ out.push(...listLine("edges removed", (edges.removed ?? []).map(edgeName)));
269
+ const policyChanged = observed.policyChanged;
270
+ if (policyChanged !== undefined && policyChanged !== null) {
271
+ out.push(
272
+ typeof policyChanged === "object" && policyChanged.available === false
273
+ ? ` policy: ${naWithReason(policyChanged)}`
274
+ : ` policy changed: ${policyChanged}`,
275
+ );
276
+ }
277
+ const findings = summary.findings;
278
+ if (findings?.available === false) out.push(` drift findings: ${naWithReason(findings)}`);
279
+ else if (findings) {
280
+ out.push(
281
+ ` drift findings: introduced ${findings.introduced.length}, resolved ${findings.resolved.length}`,
282
+ );
283
+ }
284
+
285
+ const debt = summary.debt;
286
+ if (debt?.available === false) out.push(` debt: ${naWithReason(debt)}`);
287
+ else if (debt)
288
+ out.push(` debt: introduced ${debt.introduced.length}, resolved ${debt.resolved.length}`);
289
+
290
+ const fitness = summary.fitness;
291
+ if (fitness?.available === false) out.push(` fitness: ${naWithReason(fitness)}`);
292
+ else if (fitness?.verdictDeltas) {
293
+ out.push(` fitness:`);
294
+ for (const delta of fitness.verdictDeltas) {
295
+ out.push(` ${delta.id}: ${renderVerdict(delta.base)} → ${renderVerdict(delta.head)}`);
296
+ }
297
+ }
298
+
299
+ const affected = summary.affected;
300
+ if (affected) {
301
+ if (affected.projects?.length) out.push(` affected projects: ${affected.projects.join(", ")}`);
302
+ if (affected.boundaries?.length)
303
+ out.push(` affected boundaries: ${affected.boundaries.join(", ")}`);
304
+ if (affected.constraints?.length)
305
+ out.push(` affected constraints: ${affected.constraints.join(", ")}`);
306
+ if (affected.decisions?.length)
307
+ out.push(` affected decisions: ${affected.decisions.join(", ")}`);
308
+ }
309
+
310
+ for (const note of summary.notes ?? []) out.push(` note: ${note}`);
311
+ return out;
312
+ }
@@ -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,108 @@ 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
+ }
155
+
156
+ /**
157
+ * The lineage-comparison disclosure — `explain`'s optional base-registry
158
+ * seam, rendered only when the caller supplied a base registry and the
159
+ * resolved-site explanation carries a `decisionChange` field. The fact is
160
+ * stated either way — superseded or not — because "the lineage was compared
161
+ * and did not move" must read differently from "no comparison happened"
162
+ * (`../../../../AGENTS.md`: an empty result is a claim, never a shrug);
163
+ * every disclosure note rides appended lines. Appended only: an explanation
164
+ * that has no `decisionChange` field renders exactly as it did before.
165
+ *
166
+ * @param {{superseded: boolean, comparable: boolean, notes: string[]}} change
167
+ * A `detectDecisionChange` result. `superseded` is meaningful only when
168
+ * `comparable` is `true`; a non-comparable comparison (one-sided or
169
+ * unreadable) renders "could not compare", never "did not move", because
170
+ * the latter would assert a fact the comparison could not hold.
171
+ * @returns {string[]}
172
+ */
173
+ function formatDecisionChange(change) {
174
+ const lines = [];
175
+ if (change.comparable === false) {
176
+ lines.push(
177
+ `${DETAIL}decisionChange could not compare — the decision lineage was not established between the compared registry states`,
178
+ );
179
+ } else if (change.superseded) {
180
+ lines.push(
181
+ `${DETAIL}decisionChange superseded — the decision lineage moved between the compared registry states`,
182
+ );
183
+ } else {
184
+ lines.push(
185
+ `${DETAIL}decisionChange none — the decision lineage did not move between the compared registry states`,
186
+ );
187
+ }
188
+ for (const note of change.notes) {
189
+ lines.push(`${DETAIL}decisionChange ${note}`);
190
+ }
191
+ return lines;
192
+ }
193
+
87
194
  /**
88
195
  * The whole explain report.
89
196
  *
@@ -165,6 +272,20 @@ export function formatExplainReport({ explanation, coverage }) {
165
272
  } else {
166
273
  sections.push(`${DETAIL}verdict allowed — no constraint was violated`);
167
274
  }
275
+ // The lineage-comparison disclosure — present only when the explanation
276
+ // carries it (the caller supplied a base registry); otherwise nothing
277
+ // here renders, and the report is byte-for-byte what it was.
278
+ if (explanation.decisionChange !== undefined) {
279
+ sections.push(...formatDecisionChange(explanation.decisionChange));
280
+ }
281
+
282
+ // The "why does this constraint exist" chain — one block per governing
283
+ // decision the matched rows name. Additive: an explanation whose rows
284
+ // carry no `decisionRef` has no `decisions` list, and renders exactly as
285
+ // it did before this section existed.
286
+ for (const entry of explanation.decisions ?? []) {
287
+ sections.push(...formatDecisionEntry(entry));
288
+ }
168
289
  }
169
290
 
170
291
  // Coverage — same shape as every other command's footer.
@@ -12,7 +12,7 @@
12
12
  * becomes prose, so the two commands cannot disagree about it.
13
13
  */
14
14
 
15
- import { formatChanges, transitionKind } from "./snapshot-text.mjs";
15
+ import { formatChanges, formatClassifications, transitionKind } from "./snapshot-text.mjs";
16
16
 
17
17
  /**
18
18
  * The whole history report.
@@ -20,8 +20,9 @@ import { formatChanges, transitionKind } from "./snapshot-text.mjs";
20
20
  * @param {{evolution: {dir: string, captured: object|null,
21
21
  * snapshots: {name: string, id: string}[],
22
22
  * transitions: {from: string, to: string, architectureChanged: boolean,
23
- * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
24
- * codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
23
+ * changes: object|null, policyChanged: boolean|null, policyOneSided: boolean,
24
+ * provenanceChanged: boolean|null, providerChanged: boolean,
25
+ * codeDrift: boolean, notes: string[], classifications?: string[]}[]}, coverage: object}} input
25
26
  * @returns {string}
26
27
  */
27
28
  export function formatHistoryReport({ evolution, coverage }) {
@@ -61,6 +62,11 @@ export function formatHistoryReport({ evolution, coverage }) {
61
62
  if (transition.architectureChanged) changed += 1;
62
63
  const kind = transitionKind(transition);
63
64
  sections.push(`~ ${transition.from} → ${transition.to} (${kind})`);
65
+ // The canonical classes, appended as their own line so the kind label
66
+ // (a display shorthand) and the classification (a fact) both print.
67
+ for (const line of formatClassifications(transition.classifications)) {
68
+ sections.push(` ${line}`);
69
+ }
64
70
  if (transition.changes) {
65
71
  for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
66
72
  }
@@ -76,6 +76,68 @@ export function formatPlanContextReport({ project, coverage, unresolvedDecisionR
76
76
  );
77
77
  }
78
78
 
79
+ // Decisions / ADRs in scope (resolved decision lineage).
80
+ if (plan.decisions && plan.decisions.length > 0) {
81
+ sections.push(
82
+ `Decisions (${plan.decisions.length} resolved reference${plan.decisions.length === 1 ? "" : "s"})`,
83
+ );
84
+ for (const d of plan.decisions) {
85
+ const title = d.record ? `${d.record.id}: ${d.record.title}` : d.decisionRef;
86
+ const status = d.record ? d.record.status : "unresolved";
87
+ sections.push(`${DETAIL}${title} (${status})`);
88
+ if (d.record?.context) {
89
+ sections.push(`${DETAIL} context: ${d.record.context}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ // Fitness.
95
+ if (plan.fitness) {
96
+ sections.push("Fitness");
97
+ if (!plan.fitness.verified) {
98
+ sections.push(`${DETAIL}no-verdict — ${plan.fitness.error ?? "could not be evaluated"}`);
99
+ } else {
100
+ sections.push(`${DETAIL}overall verdict: ${plan.fitness.overall.verdict}`);
101
+ if (plan.fitness.decisions.length > 0) {
102
+ for (const d of plan.fitness.decisions) {
103
+ sections.push(
104
+ `${DETAIL}${d.name ?? d.id}: ${d.verdict}${d.reason ? ` — ${d.reason}` : ""}`,
105
+ );
106
+ }
107
+ }
108
+ }
109
+ } else {
110
+ sections.push("Fitness (no fitness functions declared in the boundary policy)");
111
+ }
112
+
113
+ // Waivers.
114
+ sections.push("Waivers");
115
+ if (!plan.waivers.declared) {
116
+ sections.push(`${DETAIL}no suppressions declared — every boundary is enforced`);
117
+ } else {
118
+ const waiverCount = plan.waivers.waivers.length;
119
+ const permCount = plan.waivers.permanentSuppressions.length;
120
+ sections.push(
121
+ `${DETAIL}${waiverCount} waiver${waiverCount === 1 ? "" : "s"}, ` +
122
+ `${permCount} permanent suppression${permCount === 1 ? "" : "s"}, ` +
123
+ `${plan.waivers.covered} covering violations, ` +
124
+ `${plan.waivers.expired} expired, ` +
125
+ `${plan.waivers.stale} stale`,
126
+ );
127
+ if (waiverCount > 0) {
128
+ for (const w of plan.waivers.waivers) {
129
+ const term = w.expiresAt
130
+ ? `expires ${w.expiresAt} (${w.status}, ${w.remainingMs}ms remaining)`
131
+ : "permanent";
132
+ sections.push(
133
+ `${DETAIL}${w.path} ${term} covers ${w.covered} violation${w.covered === 1 ? "" : "s"}`,
134
+ );
135
+ if (w.reason) sections.push(`${DETAIL} reason: ${w.reason}`);
136
+ if (w.origin) sections.push(`${DETAIL} origin: ${w.origin}`);
137
+ }
138
+ }
139
+ }
140
+
79
141
  // Affected projects.
80
142
  sections.push("Architecture");
81
143
  sections.push(
@@ -135,6 +197,38 @@ export function formatPlanContextReport({ project, coverage, unresolvedDecisionR
135
197
  sections.push(`${DETAIL}${plan.intent.verdict}: ${verdict} (${plan.intent.rows} rows)`);
136
198
  }
137
199
 
200
+ // Architecture debt.
201
+ if (plan.debt) {
202
+ sections.push("Architecture debt");
203
+ if (!plan.debt.available) {
204
+ sections.push(
205
+ `${DETAIL}not available — ${plan.debt.reason ?? "history directory unavailable"}`,
206
+ );
207
+ } else {
208
+ const entries = plan.debt.entries;
209
+ const open = plan.debt.total.open;
210
+ const resolved = plan.debt.total.resolved;
211
+ sections.push(
212
+ `${DETAIL}${entries.length} entry${entries.length === 1 ? "" : "s"} ` +
213
+ `(${open} open, ${resolved} resolved, ${plan.debt.total.total} total) ` +
214
+ `across ${plan.debt.snapshots} snapshot` +
215
+ `${plan.debt.snapshots === 1 ? "" : "s"}`,
216
+ );
217
+ if (plan.debt.byKind && Object.keys(plan.debt.byKind).length > 0) {
218
+ const kinds = Object.entries(plan.debt.byKind)
219
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
220
+ .map(([kind, count]) => `${kind}: ${count}`);
221
+ sections.push(`${DETAIL}by kind: ${kinds.join(", ")}`);
222
+ }
223
+ if (plan.debt.agings && plan.debt.agings.meanDays !== null) {
224
+ sections.push(
225
+ `${DETAIL}mean age: ${plan.debt.agings.meanDays.toFixed(1)} days` +
226
+ `${plan.debt.agings.maxDays !== null ? `, max ${plan.debt.agings.maxDays.toFixed(1)} days` : ""}`,
227
+ );
228
+ }
229
+ }
230
+ }
231
+
138
232
  // Verification commands.
139
233
  sections.push("Verify after the change");
140
234
  for (const command of plan.verify) {
@@ -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
  }