devflow-kit 3.0.0 → 3.1.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 (134) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/agents/git.md +2 -2
  3. package/dist/cli/agents-view/index.js +1 -1
  4. package/dist/cli/agents-view/render.js +2 -2
  5. package/dist/cli/agents-view/state.js +2 -2
  6. package/dist/cli/agents-view/terminal.js +5 -5
  7. package/dist/cli/commands/agents.js +7 -6
  8. package/dist/cli/commands/ambient.js +1 -1
  9. package/dist/cli/commands/attribution-prompts.js +8 -8
  10. package/dist/cli/commands/capture.js +1 -1
  11. package/dist/cli/commands/compliance-prompts.js +8 -8
  12. package/dist/cli/commands/compliance.js +8 -7
  13. package/dist/cli/commands/flags.js +33 -31
  14. package/dist/cli/commands/hud.js +1 -1
  15. package/dist/cli/commands/init-seed.js +9 -9
  16. package/dist/cli/commands/init.js +34 -32
  17. package/dist/cli/commands/install-report.js +10 -10
  18. package/dist/cli/commands/learning.js +267 -129
  19. package/dist/cli/commands/memory.js +1 -1
  20. package/dist/cli/commands/proxy.js +23 -23
  21. package/dist/cli/commands/rules.js +6 -5
  22. package/dist/cli/commands/tracker-prompts.js +6 -6
  23. package/dist/cli/commands/tracker.js +9 -9
  24. package/dist/cli/commands/uninstall.js +20 -20
  25. package/dist/cli/flags-view/render.js +5 -5
  26. package/dist/cli/flags-view/state.js +9 -9
  27. package/dist/cli/flags-view/terminal.js +4 -4
  28. package/dist/cli/tui/cells.js +1 -1
  29. package/dist/cli/tui/terminal.js +6 -6
  30. package/dist/commands/dynamic-build.md +18 -4
  31. package/dist/commands/dynamic-plan.md +19 -5
  32. package/dist/commands/dynamic-profile.md +17 -3
  33. package/dist/commands/dynamic-tickets.md +18 -4
  34. package/dist/commands/release.md +15 -1
  35. package/dist/commands/research.md +1 -1
  36. package/dist/commands/resolve.md +8 -9
  37. package/dist/core/agent-frontmatter.js +3 -3
  38. package/dist/core/agent-models.js +6 -6
  39. package/dist/core/agent-state.js +2 -2
  40. package/dist/core/ansi.js +2 -2
  41. package/dist/core/cache.js +7 -8
  42. package/dist/core/codex-auth-inspect.js +4 -4
  43. package/dist/core/compliance-compose.js +3 -3
  44. package/dist/core/compliance.js +3 -4
  45. package/dist/core/evidence-policy.js +14 -13
  46. package/dist/core/external-models.js +1 -1
  47. package/dist/core/feature-config.js +3 -3
  48. package/dist/core/feature-switch.js +3 -3
  49. package/dist/core/flags.js +25 -25
  50. package/dist/core/fs-atomic.js +6 -7
  51. package/dist/core/learning-queue-cleanup.js +16 -80
  52. package/dist/core/learning-store.js +61 -0
  53. package/dist/core/manifest.js +5 -5
  54. package/dist/core/mds-variants.js +13 -13
  55. package/dist/core/model-discovery.js +8 -8
  56. package/dist/core/observations.js +17 -101
  57. package/dist/core/orphan-sweep.js +4 -4
  58. package/dist/core/plugins.js +4 -5
  59. package/dist/core/project-paths.js +9 -13
  60. package/dist/core/proxy-log.js +8 -8
  61. package/dist/core/proxy-state.js +3 -3
  62. package/dist/core/reference-sweep.js +6 -6
  63. package/dist/core/teammate-mode-cleanup.js +1 -1
  64. package/dist/core/tracker.js +14 -14
  65. package/dist/hud/colors.js +2 -2
  66. package/dist/hud/components/learning-counts.js +2 -16
  67. package/dist/hud/components/version-badge.js +1 -1
  68. package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
  69. package/dist/targets/claude-code/compliance-install.js +17 -15
  70. package/dist/targets/claude-code/hooks.js +2 -2
  71. package/dist/targets/claude-code/installer.js +24 -24
  72. package/dist/targets/claude-code/legacy.js +1 -1
  73. package/dist/targets/claude-code/post-install.js +25 -11
  74. package/dist/targets/claude-code/tracker-install.js +2 -2
  75. package/package.json +1 -1
  76. package/src/assets/agents/code.md +1 -4
  77. package/src/assets/agents/design.md +2 -2
  78. package/src/assets/agents/diagnose.md +1 -1
  79. package/src/assets/agents/git.mds +2 -2
  80. package/src/assets/agents/knowledge.md +3 -3
  81. package/src/assets/agents/learning.md +281 -196
  82. package/src/assets/agents/research.md +1 -1
  83. package/src/assets/agents/review.md +3 -3
  84. package/src/assets/agents/scrutinize.md +1 -1
  85. package/src/assets/agents/skim.md +1 -1
  86. package/src/assets/agents/triage.md +9 -9
  87. package/src/assets/commands/_partials/_decisions.mds +8 -3
  88. package/src/assets/commands/_partials/_docs_root.mds +3 -3
  89. package/src/assets/commands/_partials/_engine.mds +1 -1
  90. package/src/assets/commands/_partials/_preamble.mds +6 -2
  91. package/src/assets/commands/_partials/_settings.mds +2 -2
  92. package/src/assets/commands/dynamic-build.mds +1 -1
  93. package/src/assets/commands/dynamic-plan.mds +3 -3
  94. package/src/assets/commands/dynamic-profile.mds +1 -1
  95. package/src/assets/commands/dynamic-tickets.mds +2 -2
  96. package/src/assets/commands/release.md +15 -1
  97. package/src/assets/commands/research.mds +1 -1
  98. package/src/assets/commands/resolve.mds +8 -9
  99. package/src/assets/mds/git/_pr.mds +3 -3
  100. package/src/assets/mds/tracker/_common.mds +1 -1
  101. package/src/assets/mds/tracker/_github.mds +1 -1
  102. package/src/assets/mds/tracker/_jira.mds +1 -1
  103. package/src/assets/mds/tracker/_linear.mds +1 -1
  104. package/src/assets/mds/tracker/_mcp.mds +6 -5
  105. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +1 -0
  106. package/src/assets/scripts/hooks/background-memory-update +28 -22
  107. package/src/assets/scripts/hooks/capture-turn +1 -17
  108. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  109. package/src/assets/scripts/hooks/ensure-proxy +5 -6
  110. package/src/assets/scripts/hooks/ensure-root-gitignore +1 -1
  111. package/src/assets/scripts/hooks/is-hex-sha +1 -1
  112. package/src/assets/scripts/hooks/json-helper.cjs +348 -814
  113. package/src/assets/scripts/hooks/json-parse +3 -2
  114. package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
  115. package/src/assets/scripts/hooks/lib/learning-store.cjs +3102 -0
  116. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
  117. package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
  118. package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
  119. package/src/assets/scripts/hooks/queue-append +2 -2
  120. package/src/assets/scripts/hooks/resolve-project-root +3 -4
  121. package/src/assets/scripts/hooks/session-start-context +40 -18
  122. package/src/assets/scripts/lib/project-config.cjs +2 -2
  123. package/src/assets/scripts/pr-evidence.cjs +3 -3
  124. package/src/assets/scripts/redact-secrets.cjs +20 -20
  125. package/src/assets/scripts/release-trace.cjs +1 -1
  126. package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
  127. package/src/assets/scripts/resolve-settings.cjs +3 -3
  128. package/src/assets/scripts/verify-evidence.cjs +2 -2
  129. package/src/assets/skills/apply-decisions/SKILL.md +37 -17
  130. package/src/assets/skills/docs-framework/SKILL.md +2 -2
  131. package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
  132. package/src/targets/claude-code/templates/managed-settings.json +3 -3
  133. package/dist/core/observation-io.js +0 -50
  134. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -34,12 +34,13 @@ json_field() {
34
34
 
35
35
  # Extract a field from a JSON file. Usage: json_field_file "/path/to/file.json" "field" "default"
36
36
  # Note: uses if/then/else to preserve boolean false (jq // operator would replace false with default)
37
+ # The node fallback reads the file on stdin: json-helper takes no file path.
37
38
  json_field_file() {
38
39
  local file="$1" field="$2" default="${3:-}"
39
40
  if [ "$_HAS_JQ" = "true" ]; then
40
41
  jq -r "if (.$field | type) == \"null\" then \"$default\" else (.$field | tostring) end" "$file" 2>/dev/null
41
42
  else
42
- node "$_JSON_HELPER" get-field-file "$file" "$field" "$default"
43
+ node "$_JSON_HELPER" get-field "$field" "$default" < "$file"
43
44
  fi
44
45
  }
45
46
 
@@ -132,7 +133,7 @@ json_slurp_cap() {
132
133
  if [ "$_HAS_JQ" = "true" ]; then
133
134
  jq -c '.' "$file" | jq -s "sort_by(.$field) | reverse | .[0:$limit][]" 2>/dev/null
134
135
  else
135
- node "$_JSON_HELPER" slurp-cap "$file" "$field" "$limit"
136
+ node "$_JSON_HELPER" slurp-cap "$field" "$limit" < "$file"
136
137
  fi
137
138
  }
138
139
 
@@ -2,14 +2,16 @@
2
2
  //
3
3
  // Shared pure formatting helpers for decisions.md and pitfalls.md output.
4
4
  //
5
- // DESIGN: Shared pure formatting helpers used by assign-anchor (via json-helper.cjs)
6
- // and render-decisions.cjs so both share the EXACT same format functions. This is
7
- // the single source of truth for the byte-compat output strings — any drift here
8
- // will break the renderer/session-start-context TL;DR parser.
5
+ // DESIGN: Shared pure formatting helpers for render-decisions.cjs, the one
6
+ // renderer: its CLI and every learning-store writer render through it, so all of
7
+ // them share the EXACT same format functions. This is the single source of truth
8
+ // for the byte-compat output strings — any drift here will break the
9
+ // renderer/session-start-context TL;DR parser.
9
10
  //
10
11
  // BYTE-COMPAT CONTRACT (must not change without updating all consumers):
12
+ // v1 entries — ledger rows without schema 2 — keep these bytes (D-V1-BYTE-STABLE):
11
13
  // Decision heading: \n## {anchorId}: {title}\n
12
- // Decision fields: - **Date**: YYYY-MM-DD\n (empty string when absent — render purity, ADR-022: never clock-read in a formatter)
14
+ // Decision fields: - **Date**: YYYY-MM-DD\n (empty string when absent — render purity: never clock-read in a formatter)
13
15
  // - **Status**: Accepted\n
14
16
  // - **Context**: ...\n
15
17
  // - **Decision**: ...\n
@@ -24,10 +26,22 @@
24
26
  // - **Status**: Active\n
25
27
  // - **Source**: self-learning:{obsId}\n
26
28
  // - **Amendments**: text1; text2\n (omitted when absent or empty)
27
- // TL;DR line: <!-- TL;DR: N {decisions|pitfalls}. Key: id1, id2 -->
28
- // File headers:
29
- // decisions.md: "<!-- TL;DR: 0 decisions. Key: -->\n# Architectural Decisions\n\nAppend-only. Status changes allowed; deletions prohibited.\n"
30
- // pitfalls.md: "<!-- TL;DR: 0 pitfalls. Key: -->\n# Known Pitfalls\n\nArea-specific gotchas, fragile areas, and past bugs.\n"
29
+ // v2 entries — rows with schema 2 — render from their fields (formatEntryBodyV2):
30
+ // \n## {anchorId}: {title}\n\n
31
+ // - **Status**: {Accepted|Active}\n (+ " · verified {last_verified}" when the row has one)
32
+ // - **Scope**: `{scope1}`, `{scope2}`\n
33
+ // - **{Decision|Rule}**: {rule}\n (Decision for decisions, Rule for pitfalls)
34
+ // - **Why**: {why}\n
35
+ // - **Source**: {provenance}\n
36
+ // Inactive table, after the active bodies and omitted when no entry is inactive:
37
+ // \n## Inactive\n\n| ID | Status | Note |\n|---|---|---|\n then "| {anchorId} | {status} | {note} |\n" per entry
38
+ // TL;DR line: <!-- TL;DR: N {decisions|pitfalls} --> (N = the file's active entries)
39
+ // File headers (the renderer swaps in the real TL;DR line):
40
+ // decisions.md: "<!-- TL;DR: 0 decisions -->\n# Architectural Decisions\n\n{GENERATED_NOTICE}\n"
41
+ // pitfalls.md: "<!-- TL;DR: 0 pitfalls -->\n# Known Pitfalls\n\n{GENERATED_NOTICE}\n"
42
+ // Index lines:
43
+ // v1: " {anchorId} {title cut to 60} [{status}]" (+ " — {area cut to 80}" when it has one)
44
+ // v2: " {anchorId} {title}" (+ " — {scope joined ', ' cut to 80}" when it has one)
31
45
  //
32
46
  // Field parsing: both formatters use segmentDetails() which splits on ';' and
33
47
  // anchors key detection to the START of each trimmed segment — so 'reissue:'
@@ -36,31 +50,40 @@
36
50
  // Recovery pass: for any key the anchored pass left unset, an unanchored
37
51
  // regex ('(?:^|[.;\\s])key:\\s*([^;]+)') is tried against the full details
38
52
  // string — handles legacy corpus rows written before the ';'-delimited grammar
39
- // was documented, where fields are separated by '. ' rather than ';' (applies
40
- // PF-044). The recovery pass never overrides an anchored match.
53
+ // was documented, where fields are separated by '. ' rather than ';'.
54
+ // The recovery pass never overrides an anchored match.
41
55
  // LineTerminators (\r, \n, \u2028, \u2029) in field values are collapsed to a
42
56
  // single space at all five collapse sites (segmentDetails ×2, amendmentToString
43
57
  // ×3) — guards the single-line field contract against the full JS LineTerminator
44
- // set, not just \n.
58
+ // set, not just \n. A v2 field is structured, never parsed: each run of control
59
+ // characters in it collapses to one space (the store's singleLine), so no field
60
+ // value can add a line, a heading or a table row.
45
61
  //
46
62
  // Index extraction: extractEntryFromBlock uses line-anchored regexes
47
63
  // (/^- \*\*Status\*\*:/m, /^- \*\*Area\*\*:/m) to guard against amendment
48
- // text that accidentally contains those patterns as substrings.
64
+ // text that accidentally contains those patterns as substrings. It reads v1
65
+ // blocks only; a v2 index line is built from the row's fields.
49
66
  //
50
- // Amendments shape: the row's `amendments` array accepts BOTH the
51
- // { date, note } objects declared by LearningObservation/LedgerRow in
52
- // src/core/observations.ts (rendered as `[date] note`) and pre-rendered
53
- // strings. formatAmendmentsLine normalises per entry — never a bare join,
54
- // which would emit `[object Object]` for the schema-declared shape.
67
+ // Amendments shape: a v1 row's `amendments` array accepts BOTH the
68
+ // { date, note } objects the v1 corpus holds (rendered as `[date] note`) and
69
+ // pre-rendered strings. formatAmendmentsLine normalises per entry — never a
70
+ // bare join, which would emit `[object Object]` for the object shape.
55
71
  //
56
72
  // Consumers of these strings:
57
- // - session-start-context (line 57): reads TL;DR comment via sed
73
+ // - session-start-context (Section 1): injects the TL;DR comment's text via sed
58
74
  // - devflow:apply-decisions: reads ## ADR-NNN: / ## PF-NNN: headings
59
- // - decisions-usage-scan: scans /(ADR|PF)-\d{3}/ anchors
60
- // - buildIndexContent (below): parses ## heading, - **Status**:, - **Area**: lines from rendered blocks
75
+ // - buildIndexContent (below): parses ## heading, - **Status**:, - **Area**: lines from rendered v1 blocks
61
76
 
62
77
  'use strict';
63
78
 
79
+ const {
80
+ ACTIVE_STATUSES,
81
+ activeStatusFor,
82
+ isV2,
83
+ singleLine,
84
+ inactiveNote,
85
+ } = require('./learning-store.cjs');
86
+
64
87
  /** JS LineTerminator set — /m `^` matches after each of these and `.` excludes them. */
65
88
  const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
66
89
 
@@ -89,12 +112,11 @@ const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
89
112
  * that legacy corpus rows written before the ';'-delimited grammar was
90
113
  * documented (which embed field keys mid-segment after '. ') are still
91
114
  * parsed correctly. The recovery pass never overrides a value the anchored
92
- * pass already set. applies PF-044 (divergence/migration: legacy rows exist
93
- * written under the old contract that embedded keys after '. ').
115
+ * pass already set.
94
116
  *
95
117
  * D002 (details-parsing): This is the SINGLE parser for structured details
96
118
  * strings — both formatDecisionBody and formatPitfallBody delegate here.
97
- * applies PF-042 (delimiter-regex truncation).
119
+ * A per-field delimiter regex would silently truncate any value containing ';'.
98
120
  *
99
121
  * @param {string} detailsStr - raw details string from an observation row
100
122
  * @param {readonly string[]} keys - recognised field names
@@ -138,7 +160,7 @@ function segmentDetails(detailsStr, keys) {
138
160
  // ';'. The unanchored regex requires the key to be preceded by a
139
161
  // word-boundary character (^, '.', ';', or whitespace) so that 'reissue:'
140
162
  // still does NOT match 'issue:', and it only fills keys the anchored pass
141
- // left unset — never overrides an anchored match. applies PF-044.
163
+ // left unset — never overrides an anchored match.
142
164
  for (const key of keys) {
143
165
  if (result[key] !== undefined) continue;
144
166
  const m = detailsStr.match(new RegExp('(?:^|[.;\\s])' + key + ':\\s*([^;]+)', 'i'));
@@ -151,11 +173,9 @@ function segmentDetails(detailsStr, keys) {
151
173
  /**
152
174
  * Normalise one amendment entry to its rendered string form.
153
175
  *
154
- * TWO SHAPES are accepted because two authorities define this field:
155
- * - `{ date, note }` — the shape declared by LearningObservation /
156
- * LedgerRow in src/core/observations.ts, and the ONLY shape its
157
- * isLearningObservation type guard accepts. Renders as `[date] note`
158
- * (bare `note` when date is absent/blank).
176
+ * TWO SHAPES are accepted:
177
+ * - `{ date, note }` — the shape every amendment in the v1 corpus has.
178
+ * Renders as `[date] note` (bare `note` when date is absent/blank).
159
179
  * - `string` — a pre-rendered `[date] note` line, the convenience form.
160
180
  *
161
181
  * A plain `join` over the object shape would emit `[object Object]`, so the
@@ -198,44 +218,29 @@ function formatAmendmentsLine(amendments) {
198
218
  return `- **Amendments**: ${parts.join('; ')}\n`;
199
219
  }
200
220
 
201
- /**
202
- * Guard against raw_body payloads that could forge a second entry heading or
203
- * claim a different anchor ID. Accepts only a string whose `^## (ADR|PF)-\d+:`
204
- * headings number exactly one AND match `## ${anchorId}:`.
205
- *
206
- * A rejected raw_body is DROPPED from the row — the entry then renders through
207
- * the sanitised formatDecisionBody/formatPitfallBody — the sanctioned fallback when raw_body is absent or rejected (ADR-022).
208
- *
209
- * Per PF-023: validate at the sink so all callers (assign-anchor, refresh-anchor,
210
- * any future op) inherit the guard without repeating it.
211
- *
212
- * @param {unknown} body
213
- * @param {string} anchorId - e.g. 'ADR-001' or 'PF-023'
214
- * @returns {boolean}
215
- */
216
- function isSafeRawBody(body, anchorId) {
217
- if (typeof body !== 'string') return false;
218
- const headings = body.match(/^## (?:ADR|PF)-\d+:/gm) || [];
219
- return headings.length === 1 && headings[0] === `## ${anchorId}:`;
220
- }
221
-
222
221
  /** Recognised field keys for decision entries. */
223
222
  const ADR_KEYS = /** @type {const} */ (['context', 'decision', 'rationale']);
224
223
 
225
224
  /** Recognised field keys for pitfall entries. */
226
225
  const PF_KEYS = /** @type {const} */ (['area', 'issue', 'impact', 'resolution']);
227
226
 
227
+ /** The line under each rendered file's title: who writes the file and what it lists. */
228
+ const GENERATED_NOTICE =
229
+ 'Generated from the local learning ledger by devflow; do not edit. ' +
230
+ 'Active entries follow; retired ones are listed under Inactive.';
231
+
228
232
  /**
229
- * Return the initial header content for a new decisions or pitfalls file.
230
- * Byte-identical to the initDecisionsContent function in json-helper.cjs.
233
+ * Return the header of a rendered decisions or pitfalls file: the zero-count
234
+ * TL;DR line, the title and the generated-file notice. It is the whole file of an
235
+ * empty corpus; the renderer swaps in the file's real TL;DR line.
231
236
  *
232
237
  * @param {'decision'|'pitfall'} kind
233
238
  * @returns {string}
234
239
  */
235
240
  function initDecisionsContent(kind) {
236
241
  return kind === 'decision'
237
- ? '<!-- TL;DR: 0 decisions. Key: -->\n# Architectural Decisions\n\nAppend-only. Status changes allowed; deletions prohibited.\n'
238
- : '<!-- TL;DR: 0 pitfalls. Key: -->\n# Known Pitfalls\n\nArea-specific gotchas, fragile areas, and past bugs.\n';
242
+ ? `${buildTldrLine('decisions', [])}\n# Architectural Decisions\n\n${GENERATED_NOTICE}\n`
243
+ : `${buildTldrLine('pitfalls', [])}\n# Known Pitfalls\n\n${GENERATED_NOTICE}\n`;
239
244
  }
240
245
 
241
246
  /**
@@ -249,7 +254,7 @@ function initDecisionsContent(kind) {
249
254
  function formatDecisionBody(row) {
250
255
  const detailsStr = row.details || '';
251
256
  const obsId = row.id || 'unknown';
252
- // Render purity (ADR-022): never clock-read inside a formatter. Absent date
257
+ // Render purity: never clock-read inside a formatter. Absent date
253
258
  // renders as an empty string so the output is deterministic and idempotent.
254
259
  const artDate = row.date || '';
255
260
  const anchorId = row.anchor_id || '';
@@ -297,88 +302,95 @@ function formatPitfallBody(row) {
297
302
  );
298
303
  }
299
304
 
305
+ // ---------------------------------------------------------------------------
306
+ // v2 entries and the Inactive table
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /** Per-type labels of a v2 body: the active status it shows and the name of its rule field. */
310
+ const V2_LABELS = Object.freeze({
311
+ decision: Object.freeze({ status: activeStatusFor('decision'), rule: 'Decision' }),
312
+ pitfall: Object.freeze({ status: activeStatusFor('pitfall'), rule: 'Rule' }),
313
+ });
314
+
315
+ /** A row field on one line: a string with each control-character run collapsed to a space, '' for anything else. */
316
+ function oneLine(value) {
317
+ return typeof value === 'string' ? singleLine(value) : '';
318
+ }
319
+
320
+ /** A v2 row's scope entries, each on one line; an entry that is not a non-empty string is skipped. */
321
+ function scopeEntries(row) {
322
+ return Array.isArray(row.scope)
323
+ ? row.scope.filter(entry => typeof entry === 'string' && entry !== '').map(singleLine)
324
+ : [];
325
+ }
326
+
300
327
  /**
301
- * Project a full observation row into the canonical committed-ledger shape.
302
- * Whitelists ONLY the fields that belong in decisions-ledger.jsonl:
303
- * { id, type, pattern, details, anchor_id, decisions_status, date?, raw_body?, amendments? }
328
+ * Format the body block of a v2 entry (schema 2) from its row fields. Returns the
329
+ * block starting with a leading newline, like the v1 formatters.
304
330
  *
305
- * All observation-lifecycle fields (evidence, confidence, quality_ok, count,
306
- * first_seen, last_seen, artifact_path, status, …) are intentionally excluded
307
- * from the committed ledger — they are log-only state.
331
+ * The Status line shows the active status of the entry's type — Accepted for a
332
+ * decision, Active for a pitfall; only active entries render — and then
333
+ * ` · verified {last_verified}` when the row has that date. Scope entries are code
334
+ * spans joined by ', ', and the rule is labelled Decision or Rule by type. A field
335
+ * that is not a string renders empty: a row is validated when it is written, but a
336
+ * hand edit can leave anything, and a formatter that runs under the lock must not
337
+ * throw.
308
338
  *
309
- * D001: The projected shape is a DISTINCT COMMITTED shape, not a full obs copy.
310
- * This function is the single source of truth for that projection so both the
311
- * add-path (assign-anchor) and the migration's preserve-verbatim path produce
312
- * byte-identical committed shapes. applies ADR-008.
339
+ * @param {object} row - a v2 ledger row
340
+ * @returns {string}
341
+ */
342
+ function formatEntryBodyV2(row) {
343
+ const labels = row.type === 'pitfall' ? V2_LABELS.pitfall : V2_LABELS.decision;
344
+ const verified = oneLine(row.last_verified);
345
+ const scope = scopeEntries(row).map(entry => `\`${entry}\``).join(', ');
346
+ return (
347
+ `\n## ${oneLine(row.anchor_id)}: ${oneLine(row.title)}\n\n` +
348
+ `- **Status**: ${labels.status}${verified ? ` · verified ${verified}` : ''}\n` +
349
+ `- **Scope**: ${scope}\n` +
350
+ `- **${labels.rule}**: ${oneLine(row.rule)}\n` +
351
+ `- **Why**: ${oneLine(row.why)}\n` +
352
+ `- **Source**: ${oneLine(row.provenance)}\n`
353
+ );
354
+ }
355
+
356
+ /** A table cell: one line, trimmed, with `|` escaped so a value adds no column. */
357
+ function tableCell(value) {
358
+ return oneLine(value).trim().replace(/\|/g, '\\|');
359
+ }
360
+
361
+ /**
362
+ * Format the Inactive table of a rendered file: one row per inactive entry, v1 or
363
+ * v2, in the order given (the renderer sorts them by number), or '' when there are
364
+ * none.
313
365
  *
314
- * Validation at the SINK (per PF-023 — validate at convergence so all callers inherit):
315
- * - expectType: if provided, obs.type must match or this function throws; prevents
316
- * re-projecting across entry types (PF-NNN into decisions.md or vice versa).
317
- * - pattern: JS LineTerminators collapsed to a single space — the heading is
318
- * single-line by construction; a newline in pattern would forge '- **Status**:'
319
- * lines or second '## ADR-NNN:' headings that line-anchored index regexes match first.
320
- * - raw_body: gated by isSafeRawBody — accepts only a body with exactly one heading
321
- * matching anchorId; a rejected body is dropped so the entry renders through the
322
- * sanitised formatDecisionBody/formatPitfallBody instead.
366
+ * The Note column says why the entry is inactive, as `list` does (inactiveNote):
367
+ * `encoded in {path}`, else `superseded by {anchor}`, else the status note, else
368
+ * `—`. Every cell is one line with `|` escaped as `\|`, so no value adds a line or
369
+ * a column. index.md never carries this table.
323
370
  *
324
- * @param {object} obs - Full observation row from decisions-log.jsonl
325
- * @param {{ anchorId: string, status: string, date?: string, expectType?: string }} opts
326
- * @returns {object} Canonical ledger row
371
+ * @param {object[]} rows - inactive ledger rows
372
+ * @returns {string}
327
373
  */
328
- function toLedgerRow(obs, { anchorId, status, date, expectType }) {
329
- // Type guard — per PF-023: validate at the sink so all callers (assign-anchor,
330
- // refresh-anchor, any future op) inherit the check without repeating it.
331
- if (expectType !== undefined && obs.type !== expectType) {
332
- throw new Error(
333
- `toLedgerRow: type mismatch for ${anchorId} — ledger has '${expectType}', log has '${obs.type}'`
334
- );
335
- }
336
- /** @type {Record<string, unknown>} */
337
- const row = {
338
- id: obs.id,
339
- type: obs.type,
340
- // Heading is single-line by construction — collapse any LLM-injected line terminators
341
- // so a newline in pattern cannot forge '- **Status**:' lines or second '## ADR-NNN:'
342
- // headings inside the rendered body (those would be matched first by the line-anchored
343
- // index regexes in extractEntryFromBlock). applies PF-023.
344
- pattern: typeof obs.pattern === 'string' ? obs.pattern.replace(LINE_TERMINATORS, ' ').trim() : obs.pattern,
345
- details: obs.details, // segmentDetails already collapses line terminators at read time
346
- anchor_id: anchorId,
347
- decisions_status: status,
348
- };
349
- // Optional fields — include only when present in the observation or explicitly provided
350
- if (date !== undefined) row.date = date;
351
- // log-sourced raw_body (ADR-022) — a log row that lost raw_body un-freezes the
352
- // entry to formatter-rendered output by design. Gate through isSafeRawBody (PF-023).
353
- if (obs.raw_body !== undefined && isSafeRawBody(obs.raw_body, anchorId)) {
354
- row.raw_body = obs.raw_body;
355
- }
356
- if (obs.amendments !== undefined) row.amendments = obs.amendments;
357
- return row;
374
+ function formatInactiveTable(rows) {
375
+ if (rows.length === 0) return '';
376
+ const lines = rows.map(row =>
377
+ `| ${tableCell(row.anchor_id)} | ${tableCell(row.decisions_status)} | ${tableCell(inactiveNote(row)) || '—'} |`
378
+ );
379
+ return `\n## Inactive\n\n| ID | Status | Note |\n|---|---|---|\n${lines.join('\n')}\n`;
358
380
  }
359
381
 
360
382
  /**
361
- * Build the TL;DR comment line for a rendered decisions or pitfalls file.
362
- * Format: `<!-- TL;DR: N {decisions|pitfalls}. Key: id1, id2 -->`
363
- *
364
- * Key is the last 5 anchor IDs from the provided active rows (sorted by
365
- * numeric anchor ascending — same order as the rendered file).
366
- * When rows is empty, Key is empty string (no trailing space before -->).
383
+ * Build the TL;DR comment line of a rendered decisions or pitfalls file:
384
+ * `<!-- TL;DR: N {decisions|pitfalls} -->`, N being the active entries the file
385
+ * renders. It names no entry: session-start-context injects its text into every
386
+ * session, and the index lists the entries.
367
387
  *
368
388
  * @param {'decisions'|'pitfalls'} kind - label used in the comment
369
- * @param {object[]} rows - active anchored rows (already filtered + sorted)
389
+ * @param {object[]} rows - the file's active rows
370
390
  * @returns {string} complete TL;DR comment line (no trailing newline)
371
391
  */
372
392
  function buildTldrLine(kind, rows) {
373
- const count = rows.length;
374
- const last5 = rows.slice(-5).map(r => r.anchor_id);
375
- const keyStr = last5.join(', ');
376
- // Byte-compat: an empty key list must render `Key: -->` (single space) so the
377
- // empty-corpus render is byte-identical to initDecisionsContent's header. A
378
- // trailing space before `-->` would diverge from the documented contract and
379
- // break the assertion that the render is the SOLE format authority.
380
- if (!keyStr) return `<!-- TL;DR: ${count} ${kind}. Key: -->`;
381
- return `<!-- TL;DR: ${count} ${kind}. Key: ${keyStr} -->`;
393
+ return `<!-- TL;DR: ${rows.length} ${kind} -->`;
382
394
  }
383
395
 
384
396
  // ---------------------------------------------------------------------------
@@ -386,12 +398,11 @@ function buildTldrLine(kind, rows) {
386
398
  // ---------------------------------------------------------------------------
387
399
 
388
400
  /**
389
- * Statuses recognised by the index formatter — everything else renders as
390
- * [unknown]. Only Active (pitfalls) and Accepted (decisions) appear in
391
- * rendered .md files; the renderer excludes Deprecated/Superseded/Retired
392
- * before writing.
401
+ * Statuses a v1 index line tags as they are — everything else renders as
402
+ * [unknown]. They are the store's active statuses: the index lists active
403
+ * entries only.
393
404
  */
394
- const INDEX_KNOWN_STATUSES = ['Active', 'Accepted'];
405
+ const INDEX_KNOWN_STATUSES = ACTIVE_STATUSES;
395
406
 
396
407
  /**
397
408
  * Truncate a string to maxLen characters, appending '…' if truncated.
@@ -419,14 +430,43 @@ function formatIndexEntryLine(entry) {
419
430
  return ` ${entry.id} ${title} ${tag}${areaSuffix}`;
420
431
  }
421
432
 
433
+ /**
434
+ * `text` cut to its first `maxChars` characters (code points) plus '…' when it is
435
+ * longer, so a cut never splits a surrogate pair.
436
+ *
437
+ * @param {string} text
438
+ * @param {number} maxChars
439
+ * @returns {string}
440
+ */
441
+ function truncateChars(text, maxChars) {
442
+ const chars = Array.from(text);
443
+ return chars.length <= maxChars ? text : chars.slice(0, maxChars).join('') + '…';
444
+ }
445
+
446
+ /**
447
+ * Format the index line of a v2 entry from its row fields: ` {anchor} {title}`
448
+ * and, when the entry has a scope, ` — ` and its entries joined by ', ', cut to
449
+ * 80 characters plus '…'. The title is whole, since a v2 title is at most 120
450
+ * characters, and there is no status tag: the index lists active entries only.
451
+ *
452
+ * @param {object} row - a v2 ledger row
453
+ * @returns {string}
454
+ */
455
+ function formatIndexEntryLineV2(row) {
456
+ const scope = scopeEntries(row).join(', ');
457
+ const scopeSuffix = scope ? ` — ${truncateChars(scope, 80)}` : '';
458
+ return ` ${oneLine(row.anchor_id)} ${oneLine(row.title)}${scopeSuffix}`;
459
+ }
460
+
422
461
  /**
423
462
  * Build the compact index content from in-memory active ledger rows.
424
463
  * Empty corpus (both arrays empty) → '(none)'.
425
464
  * No trailing newline (caller adds '\n' before writing).
426
465
  *
427
- * Strategy: for each row, obtain its rendered block (pre-rendered block when
428
- * provided, else truthy raw_body || format*Body(row)), then extract
429
- * heading/Status/Area with the same regexes.
466
+ * Strategy: a v2 row's line is built from its fields (formatIndexEntryLineV2).
467
+ * For a v1 row, obtain its rendered block (pre-rendered block when provided, else
468
+ * truthy raw_body || format*Body(row)), then extract heading/Status/Area with the
469
+ * same regexes (D-V1-BYTE-STABLE).
430
470
  * This preserves byte-compat for migrated rows that carry Area/Status only in raw_body.
431
471
  * Note: raw_body === "" is treated as absent (falsy); both predicates align with the
432
472
  * truthy check in renderDecisionsFile so index and body files never drift on this edge.
@@ -463,42 +503,50 @@ function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFil
463
503
  return { id, title: rawTitle, status, area };
464
504
  }
465
505
 
466
- /** @type {Array<{ id: string, title: string, status: string|null, area: string|null }>} */
467
- const adrEntries = [];
468
- for (let i = 0; i < activeDecisionRows.length; i++) {
469
- const row = activeDecisionRows[i];
470
- const block = decisionBlocks ? decisionBlocks[i] : (row.raw_body ? row.raw_body : formatDecisionBody(row));
471
- const entry = extractEntryFromBlock(block);
472
- if (entry) adrEntries.push(entry);
506
+ /**
507
+ * The index lines of one kind's active rows, in row order. A v1 row whose block
508
+ * has no entry heading has no line.
509
+ * @param {object[]} rows
510
+ * @param {string[]|undefined} rowBlocks - pre-rendered blocks, one per row
511
+ * @param {(row: object) => string} formatV1Body
512
+ * @returns {string[]}
513
+ */
514
+ function indexLines(rows, rowBlocks, formatV1Body) {
515
+ const lines = [];
516
+ for (let i = 0; i < rows.length; i++) {
517
+ const row = rows[i];
518
+ if (isV2(row)) {
519
+ lines.push(formatIndexEntryLineV2(row));
520
+ continue;
521
+ }
522
+ const block = rowBlocks ? rowBlocks[i] : (row.raw_body || formatV1Body(row));
523
+ const entry = extractEntryFromBlock(block);
524
+ if (entry) lines.push(formatIndexEntryLine(entry));
525
+ }
526
+ return lines;
473
527
  }
474
528
 
475
- /** @type {Array<{ id: string, title: string, status: string|null, area: string|null }>} */
476
- const pfEntries = [];
477
- for (let i = 0; i < activePitfallRows.length; i++) {
478
- const row = activePitfallRows[i];
479
- const block = pitfallBlocks ? pitfallBlocks[i] : (row.raw_body ? row.raw_body : formatPitfallBody(row));
480
- const entry = extractEntryFromBlock(block);
481
- if (entry) pfEntries.push(entry);
482
- }
529
+ const adrLines = indexLines(activeDecisionRows, decisionBlocks, formatDecisionBody);
530
+ const pfLines = indexLines(activePitfallRows, pitfallBlocks, formatPitfallBody);
483
531
 
484
- if (adrEntries.length === 0 && pfEntries.length === 0) return '(none)';
532
+ if (adrLines.length === 0 && pfLines.length === 0) return '(none)';
485
533
 
486
534
  const blocks = [];
487
535
 
488
- if (adrEntries.length > 0) {
489
- blocks.push([`Decisions (${adrEntries.length}):`, ...adrEntries.map(formatIndexEntryLine)].join('\n'));
536
+ if (adrLines.length > 0) {
537
+ blocks.push([`Decisions (${adrLines.length}):`, ...adrLines].join('\n'));
490
538
  }
491
539
 
492
- if (pfEntries.length > 0) {
493
- blocks.push([`Pitfalls (${pfEntries.length}):`, ...pfEntries.map(formatIndexEntryLine)].join('\n'));
540
+ if (pfLines.length > 0) {
541
+ blocks.push([`Pitfalls (${pfLines.length}):`, ...pfLines].join('\n'));
494
542
  }
495
543
 
496
544
  // Footer: explain how to read full bodies
497
545
  const footerLines = [];
498
- if (adrEntries.length > 0) {
546
+ if (adrLines.length > 0) {
499
547
  footerLines.push(`ADR-NNN entries live in ${decisionsFilePath}`);
500
548
  }
501
- if (pfEntries.length > 0) {
549
+ if (pfLines.length > 0) {
502
550
  footerLines.push(`PF-NNN entries live in ${pitfallsFilePath}`);
503
551
  }
504
552
  footerLines.push(
@@ -515,8 +563,9 @@ module.exports = {
515
563
  formatAmendmentsLine,
516
564
  formatDecisionBody,
517
565
  formatPitfallBody,
566
+ formatEntryBodyV2,
567
+ formatInactiveTable,
568
+ formatIndexEntryLineV2,
518
569
  buildTldrLine,
519
- toLedgerRow,
520
- isSafeRawBody,
521
570
  buildIndexContent,
522
571
  };