devflow-kit 2.2.0 → 2.4.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.
@@ -9,12 +9,13 @@
9
9
  //
10
10
  // BYTE-COMPAT CONTRACT (must not change without updating all consumers):
11
11
  // Decision heading: \n## {anchorId}: {title}\n
12
- // Decision fields: - **Date**: YYYY-MM-DD\n
12
+ // Decision fields: - **Date**: YYYY-MM-DD\n (empty string when absent — render purity, ADR-022: never clock-read in a formatter)
13
13
  // - **Status**: Accepted\n
14
14
  // - **Context**: ...\n
15
15
  // - **Decision**: ...\n
16
16
  // - **Consequences**: ...\n
17
17
  // - **Source**: self-learning:{obsId}\n
18
+ // - **Amendments**: text1; text2\n (omitted when absent or empty)
18
19
  // Pitfall heading: \n## {anchorId}: {title}\n
19
20
  // Pitfall fields: - **Area**: ...\n
20
21
  // - **Issue**: ...\n
@@ -22,11 +23,36 @@
22
23
  // - **Resolution**: ...\n
23
24
  // - **Status**: Active\n
24
25
  // - **Source**: self-learning:{obsId}\n
26
+ // - **Amendments**: text1; text2\n (omitted when absent or empty)
25
27
  // TL;DR line: <!-- TL;DR: N {decisions|pitfalls}. Key: id1, id2 -->
26
28
  // File headers:
27
29
  // decisions.md: "<!-- TL;DR: 0 decisions. Key: -->\n# Architectural Decisions\n\nAppend-only. Status changes allowed; deletions prohibited.\n"
28
30
  // pitfalls.md: "<!-- TL;DR: 0 pitfalls. Key: -->\n# Known Pitfalls\n\nArea-specific gotchas, fragile areas, and past bugs.\n"
29
31
  //
32
+ // Field parsing: both formatters use segmentDetails() which splits on ';' and
33
+ // anchors key detection to the START of each trimmed segment — so 'reissue:'
34
+ // does NOT match 'issue:', and embedded semicolons inside a field value are
35
+ // preserved (the segment is treated as a continuation of the prior field).
36
+ // Recovery pass: for any key the anchored pass left unset, an unanchored
37
+ // regex ('(?:^|[.;\\s])key:\\s*([^;]+)') is tried against the full details
38
+ // 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.
41
+ // LineTerminators (\r, \n, \u2028, \u2029) in field values are collapsed to a
42
+ // single space at all five collapse sites (segmentDetails ×2, amendmentToString
43
+ // ×3) — guards the single-line field contract against the full JS LineTerminator
44
+ // set, not just \n.
45
+ //
46
+ // Index extraction: extractEntryFromBlock uses line-anchored regexes
47
+ // (/^- \*\*Status\*\*:/m, /^- \*\*Area\*\*:/m) to guard against amendment
48
+ // text that accidentally contains those patterns as substrings.
49
+ //
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.
55
+ //
30
56
  // Consumers of these strings:
31
57
  // - session-start-context (line 57): reads TL;DR comment via sed
32
58
  // - devflow:apply-decisions: reads ## ADR-NNN: / ## PF-NNN: headings
@@ -35,6 +61,170 @@
35
61
 
36
62
  'use strict';
37
63
 
64
+ /** JS LineTerminator set — /m `^` matches after each of these and `.` excludes them. */
65
+ const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
66
+
67
+ /**
68
+ * Segment-parse a details string into key→value pairs using anchored key
69
+ * detection. Splits on ';' and checks whether each trimmed segment begins
70
+ * with one of the recognised keys (e.g. 'area:'). If a segment does NOT
71
+ * begin with a recognised key it is treated as a continuation of the
72
+ * previous field — this preserves embedded semicolons inside a field value.
73
+ *
74
+ * Key detection is anchored to the START of the trimmed segment so that
75
+ * 'reissue:' does NOT match 'issue:', 'precontext:' does NOT match
76
+ * 'context:', etc. All matching is case-insensitive.
77
+ *
78
+ * JS LineTerminators (\r, \n, , ) inside values are collapsed to a
79
+ * single space so the formatted output lines remain single-line (guards the
80
+ * full LineTerminator set, not only \n).
81
+ *
82
+ * DUPLICATE KEY POLICY: if the same key appears more than once in the
83
+ * details string the LAST occurrence wins — each new segment-start match
84
+ * overwrites the prior value. This is last-match-wins, not priority-ordered
85
+ * first-match-wins.
86
+ *
87
+ * RECOVERY PASS: after the anchored segment pass, any key still unset is
88
+ * searched for with an unanchored regex ('(?:^|[.;\\s])key:\\s*([^;]+)') so
89
+ * that legacy corpus rows written before the ';'-delimited grammar was
90
+ * documented (which embed field keys mid-segment after '. ') are still
91
+ * 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 '. ').
94
+ *
95
+ * D002 (details-parsing): This is the SINGLE parser for structured details
96
+ * strings — both formatDecisionBody and formatPitfallBody delegate here.
97
+ * applies PF-042 (delimiter-regex truncation).
98
+ *
99
+ * @param {string} detailsStr - raw details string from an observation row
100
+ * @param {readonly string[]} keys - recognised field names
101
+ * @returns {Record<string, string>} map of field name → extracted value
102
+ */
103
+ function segmentDetails(detailsStr, keys) {
104
+ /** @type {Record<string, string>} */
105
+ const result = {};
106
+ if (!detailsStr) return result;
107
+
108
+ const segments = detailsStr.split(';');
109
+ let currentKey = null;
110
+
111
+ for (const seg of segments) {
112
+ const trimmed = seg.trim();
113
+ // Hoist toLowerCase — avoids one allocation per key per segment (PERF-3).
114
+ const lowered = trimmed.toLowerCase();
115
+ let matched = false;
116
+
117
+ for (const key of keys) {
118
+ const prefix = key + ':';
119
+ // Anchored: does the trimmed segment START with '<key>:'?
120
+ // Lower-casing both sides gives case-insensitive matching without regex.
121
+ if (lowered.startsWith(prefix)) {
122
+ currentKey = key;
123
+ result[key] = trimmed.slice(prefix.length).trim().replace(LINE_TERMINATORS, ' ');
124
+ matched = true;
125
+ break;
126
+ }
127
+ }
128
+
129
+ if (!matched && currentKey !== null) {
130
+ // Continuation of the previous field's value (embedded semicolons)
131
+ result[currentKey] = result[currentKey] + '; ' + trimmed.replace(LINE_TERMINATORS, ' ');
132
+ }
133
+ }
134
+
135
+ // Recovery pass: a key the anchored pass never matched may still appear
136
+ // mid-segment in legacy corpus rows (written before the ';'-delimited
137
+ // grammar was documented) where fields are separated by '. ' rather than
138
+ // ';'. The unanchored regex requires the key to be preceded by a
139
+ // word-boundary character (^, '.', ';', or whitespace) so that 'reissue:'
140
+ // still does NOT match 'issue:', and it only fills keys the anchored pass
141
+ // left unset — never overrides an anchored match. applies PF-044.
142
+ for (const key of keys) {
143
+ if (result[key] !== undefined) continue;
144
+ const m = detailsStr.match(new RegExp('(?:^|[.;\\s])' + key + ':\\s*([^;]+)', 'i'));
145
+ if (m) result[key] = m[1].trim().replace(LINE_TERMINATORS, ' ');
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ /**
152
+ * Normalise one amendment entry to its rendered string form.
153
+ *
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).
159
+ * - `string` — a pre-rendered `[date] note` line, the convenience form.
160
+ *
161
+ * A plain `join` over the object shape would emit `[object Object]`, so the
162
+ * normalisation is load-bearing rather than defensive. Unrecognised or
163
+ * note-less entries collapse to '' and are dropped by the caller — a
164
+ * formatter running under the .decisions.lock must never throw.
165
+ *
166
+ * Newlines are collapsed to spaces to preserve the single-line field contract.
167
+ *
168
+ * @param {unknown} entry
169
+ * @returns {string} rendered amendment, or '' when unrenderable
170
+ */
171
+ function amendmentToString(entry) {
172
+ if (typeof entry === 'string') return entry.replace(LINE_TERMINATORS, ' ').trim();
173
+ if (entry && typeof entry === 'object') {
174
+ const note = typeof entry.note === 'string' ? entry.note.replace(LINE_TERMINATORS, ' ').trim() : '';
175
+ if (!note) return '';
176
+ const date = typeof entry.date === 'string' ? entry.date.replace(LINE_TERMINATORS, ' ').trim() : '';
177
+ return date ? `[${date}] ${note}` : note;
178
+ }
179
+ return '';
180
+ }
181
+
182
+ /**
183
+ * Format the Amendments line for a decision or pitfall body.
184
+ * Returns an empty string when the amendments array is absent, empty, or
185
+ * contains nothing renderable, so callers can concatenate unconditionally
186
+ * without leaving a blank line.
187
+ *
188
+ * Format: `- **Amendments**: text1; text2\n`
189
+ * A single amendment has no trailing semicolon.
190
+ *
191
+ * @param {Array<string | { date?: string, note?: string }> | undefined | null} amendments
192
+ * @returns {string} formatted line with trailing newline, or '' if empty
193
+ */
194
+ function formatAmendmentsLine(amendments) {
195
+ if (!Array.isArray(amendments) || amendments.length === 0) return '';
196
+ const parts = amendments.map(amendmentToString).filter(Boolean);
197
+ if (parts.length === 0) return '';
198
+ return `- **Amendments**: ${parts.join('; ')}\n`;
199
+ }
200
+
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
+ /** Recognised field keys for decision entries. */
223
+ const ADR_KEYS = /** @type {const} */ (['context', 'decision', 'rationale']);
224
+
225
+ /** Recognised field keys for pitfall entries. */
226
+ const PF_KEYS = /** @type {const} */ (['area', 'issue', 'impact', 'resolution']);
227
+
38
228
  /**
39
229
  * Return the initial header content for a new decisions or pitfalls file.
40
230
  * Byte-identical to the initDecisionsContent function in json-helper.cjs.
@@ -59,22 +249,23 @@ function initDecisionsContent(kind) {
59
249
  function formatDecisionBody(row) {
60
250
  const detailsStr = row.details || '';
61
251
  const obsId = row.id || 'unknown';
62
- const artDate = row.date || new Date().toISOString().slice(0, 10);
252
+ // Render purity (ADR-022): never clock-read inside a formatter. Absent date
253
+ // renders as an empty string so the output is deterministic and idempotent.
254
+ const artDate = row.date || '';
63
255
  const anchorId = row.anchor_id || '';
64
256
  const pattern = row.pattern || '';
65
257
 
66
- const contextM = detailsStr.match(/context:\s*([^;]+)/i);
67
- const decisionM = detailsStr.match(/decision:\s*([^;]+)/i);
68
- const rationaleM = detailsStr.match(/rationale:\s*([^;]+)/i);
258
+ const fields = segmentDetails(detailsStr, ADR_KEYS);
69
259
 
70
260
  return (
71
261
  `\n## ${anchorId}: ${pattern}\n\n` +
72
262
  `- **Date**: ${artDate}\n` +
73
263
  `- **Status**: Accepted\n` +
74
- `- **Context**: ${(contextM || [])[1] || detailsStr}\n` +
75
- `- **Decision**: ${(decisionM || [])[1] || pattern}\n` +
76
- `- **Consequences**: ${(rationaleM || [])[1] || ''}\n` +
77
- `- **Source**: self-learning:${obsId}\n`
264
+ `- **Context**: ${fields.context || detailsStr}\n` +
265
+ `- **Decision**: ${fields.decision || pattern}\n` +
266
+ `- **Consequences**: ${fields.rationale || ''}\n` +
267
+ `- **Source**: self-learning:${obsId}\n` +
268
+ formatAmendmentsLine(row.amendments)
78
269
  );
79
270
  }
80
271
 
@@ -92,19 +283,17 @@ function formatPitfallBody(row) {
92
283
  const anchorId = row.anchor_id || '';
93
284
  const pattern = row.pattern || '';
94
285
 
95
- const areaM = detailsStr.match(/area:\s*([^;]+)/i);
96
- const issueM = detailsStr.match(/issue:\s*([^;]+)/i);
97
- const impactM = detailsStr.match(/impact:\s*([^;]+)/i);
98
- const resM = detailsStr.match(/resolution:\s*([^;]+)/i);
286
+ const fields = segmentDetails(detailsStr, PF_KEYS);
99
287
 
100
288
  return (
101
289
  `\n## ${anchorId}: ${pattern}\n\n` +
102
- `- **Area**: ${(areaM || [])[1] || detailsStr}\n` +
103
- `- **Issue**: ${(issueM || [])[1] || detailsStr}\n` +
104
- `- **Impact**: ${(impactM || [])[1] || ''}\n` +
105
- `- **Resolution**: ${(resM || [])[1] || ''}\n` +
290
+ `- **Area**: ${fields.area || detailsStr}\n` +
291
+ `- **Issue**: ${fields.issue || detailsStr}\n` +
292
+ `- **Impact**: ${fields.impact || ''}\n` +
293
+ `- **Resolution**: ${fields.resolution || ''}\n` +
106
294
  `- **Status**: Active\n` +
107
- `- **Source**: self-learning:${obsId}\n`
295
+ `- **Source**: self-learning:${obsId}\n` +
296
+ formatAmendmentsLine(row.amendments)
108
297
  );
109
298
  }
110
299
 
@@ -122,23 +311,48 @@ function formatPitfallBody(row) {
122
311
  * add-path (assign-anchor) and the migration's preserve-verbatim path produce
123
312
  * byte-identical committed shapes. applies ADR-008.
124
313
  *
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.
323
+ *
125
324
  * @param {object} obs - Full observation row from decisions-log.jsonl
126
- * @param {{ anchorId: string, status: string, date?: string }} opts
325
+ * @param {{ anchorId: string, status: string, date?: string, expectType?: string }} opts
127
326
  * @returns {object} Canonical ledger row
128
327
  */
129
- function toLedgerRow(obs, { anchorId, status, date }) {
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
+ }
130
336
  /** @type {Record<string, unknown>} */
131
337
  const row = {
132
338
  id: obs.id,
133
339
  type: obs.type,
134
- pattern: obs.pattern,
135
- details: obs.details,
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
136
346
  anchor_id: anchorId,
137
347
  decisions_status: status,
138
348
  };
139
349
  // Optional fields — include only when present in the observation or explicitly provided
140
350
  if (date !== undefined) row.date = date;
141
- if (obs.raw_body !== undefined) row.raw_body = obs.raw_body;
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
+ }
142
356
  if (obs.amendments !== undefined) row.amendments = obs.amendments;
143
357
  return row;
144
358
  }
@@ -210,18 +424,25 @@ function formatIndexEntryLine(entry) {
210
424
  * Empty corpus (both arrays empty) → '(none)'.
211
425
  * No trailing newline (caller adds '\n' before writing).
212
426
  *
213
- * Strategy: for each row, obtain its rendered block (truthy raw_body || format*Body(row)),
214
- * then extract heading/Status/Area with the same regexes.
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.
215
430
  * This preserves byte-compat for migrated rows that carry Area/Status only in raw_body.
216
431
  * Note: raw_body === "" is treated as absent (falsy); both predicates align with the
217
432
  * truthy check in renderDecisionsFile so index and body files never drift on this edge.
218
433
  *
219
434
  * @param {object[]} activeDecisionRows - Active decision rows (type='decision', sorted by anchor)
220
435
  * @param {object[]} activePitfallRows - Active pitfall rows (type='pitfall', sorted by anchor)
221
- * @param {{ decisionsFilePath: string, pitfallsFilePath: string }} opts - absolute file paths for footer
436
+ * @param {{ decisionsFilePath: string, pitfallsFilePath: string, decisionBlocks?: string[], pitfallBlocks?: string[] }} opts
437
+ * decisionsFilePath / pitfallsFilePath — absolute file paths for footer.
438
+ * decisionBlocks / pitfallBlocks — optional pre-rendered per-row blocks (one entry per
439
+ * active row, same order as the row arrays). When provided, each block is used directly
440
+ * instead of re-rendering the row, so callers that already built blocks for the body
441
+ * files avoid a second full render pass (PERF-2). The fallback expression
442
+ * (raw_body || format*Body(row)) is used when the arrays are absent.
222
443
  * @returns {string} compact index string, or '(none)'
223
444
  */
224
- function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFilePath, pitfallsFilePath }) {
445
+ function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFilePath, pitfallsFilePath, decisionBlocks, pitfallBlocks }) {
225
446
  /**
226
447
  * Extract an index entry from a rendered block string.
227
448
  * @param {string} block
@@ -232,25 +453,30 @@ function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFil
232
453
  if (!headingMatch) return null;
233
454
  const id = headingMatch[1];
234
455
  const rawTitle = headingMatch[2].trim();
235
- const statusMatch = block.match(/- \*\*Status\*\*: (.+)/);
456
+ // Line-anchored regexes prevent amendment text that contains "- **Status**:"
457
+ // or "- **Area**:" as a substring from hijacking the extracted values.
458
+ // The /m (multiline) flag makes ^ match at the start of any line in the block.
459
+ const statusMatch = block.match(/^- \*\*Status\*\*: (.+)/m);
236
460
  const status = statusMatch ? statusMatch[1].trim() : null;
237
- const areaMatch = block.match(/- \*\*Area\*\*: (.+)/);
461
+ const areaMatch = block.match(/^- \*\*Area\*\*: (.+)/m);
238
462
  const area = areaMatch ? areaMatch[1].trim() : null;
239
463
  return { id, title: rawTitle, status, area };
240
464
  }
241
465
 
242
466
  /** @type {Array<{ id: string, title: string, status: string|null, area: string|null }>} */
243
467
  const adrEntries = [];
244
- for (const row of activeDecisionRows) {
245
- const block = row.raw_body ? row.raw_body : formatDecisionBody(row);
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));
246
471
  const entry = extractEntryFromBlock(block);
247
472
  if (entry) adrEntries.push(entry);
248
473
  }
249
474
 
250
475
  /** @type {Array<{ id: string, title: string, status: string|null, area: string|null }>} */
251
476
  const pfEntries = [];
252
- for (const row of activePitfallRows) {
253
- const block = row.raw_body ? row.raw_body : formatPitfallBody(row);
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));
254
480
  const entry = extractEntryFromBlock(block);
255
481
  if (entry) pfEntries.push(entry);
256
482
  }
@@ -260,19 +486,11 @@ function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFil
260
486
  const blocks = [];
261
487
 
262
488
  if (adrEntries.length > 0) {
263
- const lines = [`Decisions (${adrEntries.length}):`];
264
- for (const entry of adrEntries) {
265
- lines.push(formatIndexEntryLine(entry));
266
- }
267
- blocks.push(lines.join('\n'));
489
+ blocks.push([`Decisions (${adrEntries.length}):`, ...adrEntries.map(formatIndexEntryLine)].join('\n'));
268
490
  }
269
491
 
270
492
  if (pfEntries.length > 0) {
271
- const lines = [`Pitfalls (${pfEntries.length}):`];
272
- for (const entry of pfEntries) {
273
- lines.push(formatIndexEntryLine(entry));
274
- }
275
- blocks.push(lines.join('\n'));
493
+ blocks.push([`Pitfalls (${pfEntries.length}):`, ...pfEntries.map(formatIndexEntryLine)].join('\n'));
276
494
  }
277
495
 
278
496
  // Footer: explain how to read full bodies
@@ -293,9 +511,12 @@ function buildIndexContent(activeDecisionRows, activePitfallRows, { decisionsFil
293
511
 
294
512
  module.exports = {
295
513
  initDecisionsContent,
514
+ segmentDetails,
515
+ formatAmendmentsLine,
296
516
  formatDecisionBody,
297
517
  formatPitfallBody,
298
518
  buildTldrLine,
299
519
  toLedgerRow,
520
+ isSafeRawBody,
300
521
  buildIndexContent,
301
522
  };
@@ -48,7 +48,7 @@ function _idleSleep50() {
48
48
  * (default 60 s) is forcibly removed and the caller retries. This protects against
49
49
  * crashed holders but creates a narrow TOCTOU window: if a holder is actively
50
50
  * working and takes longer than 60 s, its lock can be stolen — leading to concurrent
51
- * ledger writes. Current callers (assign-anchor, retire-anchor, render CLI) perform
51
+ * ledger writes. Current callers (assign-anchor, retire-anchor, refresh-anchor, render CLI) perform
52
52
  * only synchronous file I/O + JSON parse and complete well under 60 s in practice,
53
53
  * so this window is not reachable under normal operation. For long-running callers
54
54
  * call refreshLock(lockDir) periodically to reset the mtime and push the deadline
@@ -131,21 +131,23 @@ function selectActiveRows(rows, kind) {
131
131
  }
132
132
 
133
133
  /**
134
- * Build the full file content from already-filtered + sorted active rows.
135
- * Internal helper — callers that have already run selectActiveRows can pass
136
- * the result here directly to avoid re-filtering the ledger.
134
+ * Build per-row body blocks from already-filtered + sorted active rows.
135
+ * Each block starts with a leading newline (matching the format contract).
137
136
  *
138
137
  * Per-row content:
139
138
  * - If row.raw_body is truthy → emit verbatim (migrated entries)
140
139
  * - Otherwise → formatDecisionBody / formatPitfallBody from details
141
140
  *
141
+ * Extracted so renderAndWriteAll can compute blocks once and reuse them
142
+ * for both the body files and buildIndexContent, avoiding a second full
143
+ * render pass (PERF-2).
144
+ *
142
145
  * @param {object[]} activeRows - already-filtered + sorted active rows
143
146
  * @param {'decisions'|'pitfalls'} kind
144
- * @returns {string} complete file content
147
+ * @returns {string[]} per-row rendered blocks
145
148
  */
146
- function renderBodyFromActive(activeRows, kind) {
147
- // Build per-row blocks
148
- const blocks = activeRows.map(row => {
149
+ function buildBodyBlocks(activeRows, kind) {
150
+ return activeRows.map(row => {
149
151
  if (row.raw_body) {
150
152
  // Migrated entry: emit verbatim. raw_body must start with \n## so
151
153
  // it fits seamlessly after the header preamble.
@@ -155,7 +157,18 @@ function renderBodyFromActive(activeRows, kind) {
155
157
  ? formatDecisionBody(row)
156
158
  : formatPitfallBody(row);
157
159
  });
160
+ }
158
161
 
162
+ /**
163
+ * Assemble the full file content from pre-computed blocks and active rows.
164
+ * Internal helper — avoids re-computing blocks when the caller already has them.
165
+ *
166
+ * @param {object[]} activeRows - already-filtered + sorted active rows
167
+ * @param {string[]} blocks - pre-rendered per-row blocks (from buildBodyBlocks)
168
+ * @param {'decisions'|'pitfalls'} kind
169
+ * @returns {string} complete file content
170
+ */
171
+ function buildFileFromBlocks(activeRows, blocks, kind) {
159
172
  // Build TL;DR line (uses active + sorted rows so last-5 are stable)
160
173
  const tldr = buildTldrLine(kind, activeRows);
161
174
 
@@ -170,6 +183,23 @@ function renderBodyFromActive(activeRows, kind) {
170
183
  return header + blocks.join('');
171
184
  }
172
185
 
186
+ /**
187
+ * Build the full file content from already-filtered + sorted active rows.
188
+ * Internal helper — callers that have already run selectActiveRows can pass
189
+ * the result here directly to avoid re-filtering the ledger.
190
+ *
191
+ * Per-row content:
192
+ * - If row.raw_body is truthy → emit verbatim (migrated entries)
193
+ * - Otherwise → formatDecisionBody / formatPitfallBody from details
194
+ *
195
+ * @param {object[]} activeRows - already-filtered + sorted active rows
196
+ * @param {'decisions'|'pitfalls'} kind
197
+ * @returns {string} complete file content
198
+ */
199
+ function renderBodyFromActive(activeRows, kind) {
200
+ return buildFileFromBlocks(activeRows, buildBodyBlocks(activeRows, kind), kind);
201
+ }
202
+
173
203
  /**
174
204
  * Pure render function. Produces the full content of a decisions.md or
175
205
  * pitfalls.md file from the given ledger rows.
@@ -224,9 +254,9 @@ function writeAtomic(filePath, content) {
224
254
 
225
255
  /**
226
256
  * Render both decisions.md and pitfalls.md from the given ledger rows and write
227
- * them atomically. Does NOT acquire any lock — callers (assign-anchor, retire-anchor)
228
- * must already hold .decisions.lock. The standalone `render` CLI takes the lock
229
- * before calling this function.
257
+ * them atomically. Does NOT acquire any lock — callers (assign-anchor, retire-anchor,
258
+ * refresh-anchor) must already hold .decisions.lock. The standalone `render` CLI takes
259
+ * the lock before calling this function.
230
260
  *
231
261
  * Creates the decisionsDir if it does not exist.
232
262
  *
@@ -246,8 +276,13 @@ function renderAndWriteAll(worktreePath, rows) {
246
276
  const activeDecisionRows = selectActiveRows(rows, 'decisions');
247
277
  const activePitfallRows = selectActiveRows(rows, 'pitfalls');
248
278
 
249
- const decisionsContent = renderBodyFromActive(activeDecisionRows, 'decisions');
250
- const pitfallsContent = renderBodyFromActive(activePitfallRows, 'pitfalls');
279
+ // Build per-row blocks once — reused for body files and index so
280
+ // buildIndexContent does not re-render every entry a second time (PERF-2).
281
+ const decisionBlocks = buildBodyBlocks(activeDecisionRows, 'decisions');
282
+ const pitfallBlocks = buildBodyBlocks(activePitfallRows, 'pitfalls');
283
+
284
+ const decisionsContent = buildFileFromBlocks(activeDecisionRows, decisionBlocks, 'decisions');
285
+ const pitfallsContent = buildFileFromBlocks(activePitfallRows, pitfallBlocks, 'pitfalls');
251
286
 
252
287
  // Write body files first; index last. On a crash between body writes and the
253
288
  // index write: on the FIRST render the index is absent (reader falls back to
@@ -258,15 +293,19 @@ function renderAndWriteAll(worktreePath, rows) {
258
293
  writeAtomic(pitfallsFilePath, pitfallsContent);
259
294
 
260
295
  // Build and write compact index (write-time artifact; consumed via plain Read)
261
- // Reuses the pre-computed active rows — no additional selectActiveRows pass.
296
+ // Reuses the pre-computed active rows and pre-rendered blocks — no additional
297
+ // selectActiveRows or format pass.
262
298
  const indexContent = buildIndexContent(activeDecisionRows, activePitfallRows, {
263
299
  decisionsFilePath,
264
300
  pitfallsFilePath,
301
+ decisionBlocks,
302
+ pitfallBlocks,
265
303
  });
266
- writeAtomic(indexFilePath, indexContent + '\n');
304
+ const indexLine = indexContent + '\n';
305
+ writeAtomic(indexFilePath, indexLine);
267
306
 
268
307
  process.stderr.write(
269
- `[render-decisions] wrote decisions.md (${decisionsContent.length}B) + pitfalls.md (${pitfallsContent.length}B) + index.md (${indexContent.length}B)\n`
308
+ `[render-decisions] wrote decisions.md (${Buffer.byteLength(decisionsContent)}B) + pitfalls.md (${Buffer.byteLength(pitfallsContent)}B) + index.md (${Buffer.byteLength(indexLine)}B)\n`
270
309
  );
271
310
  }
272
311
 
@@ -277,14 +316,10 @@ function renderAndWriteAll(worktreePath, rows) {
277
316
  if (require.main === module) {
278
317
  const argv = process.argv.slice(2);
279
318
 
280
- if (argv.length === 0) {
281
- process.stderr.write(
282
- 'Usage:\n' +
283
- ' render-decisions.cjs render <worktree> Write both .md files\n' +
284
- ' render-decisions.cjs --check <worktree> Diff without writing; exit 1 on drift\n'
285
- );
286
- process.exit(1);
287
- }
319
+ const USAGE =
320
+ 'Usage:\n' +
321
+ ' render-decisions.cjs render <worktree> Write both .md files\n' +
322
+ ' render-decisions.cjs --check <worktree> Diff without writing; exit 1 on drift\n';
288
323
 
289
324
  // Parse: `render <worktree>` or `--check <worktree>`
290
325
  let mode; // 'render' | 'check'
@@ -297,11 +332,7 @@ if (require.main === module) {
297
332
  mode = 'check';
298
333
  worktreePath = path.resolve(argv[1]);
299
334
  } else {
300
- process.stderr.write(
301
- 'Usage:\n' +
302
- ' render-decisions.cjs render <worktree> Write both .md files\n' +
303
- ' render-decisions.cjs --check <worktree> Diff without writing; exit 1 on drift\n'
304
- );
335
+ process.stderr.write(USAGE);
305
336
  process.exit(1);
306
337
  }
307
338