devflow-kit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/commands/resolve.md +29 -16
- package/package.json +1 -1
- package/src/assets/agents/learning.md +63 -13
- package/src/assets/agents/triage.md +19 -1
- package/src/assets/commands/resolve.mds +29 -16
- package/src/assets/scripts/hooks/background-memory-update +180 -37
- package/src/assets/scripts/hooks/is-hex-sha +14 -0
- package/src/assets/scripts/hooks/json-helper.cjs +223 -38
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +264 -43
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +1 -1
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +59 -28
- package/src/assets/scripts/hooks/pre-compact-memory +40 -22
- package/src/assets/scripts/hooks/session-start-memory +19 -20
|
@@ -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
|
-
|
|
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
|
|
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**: ${
|
|
75
|
-
`- **Decision**: ${
|
|
76
|
-
`- **Consequences**: ${
|
|
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
|
|
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**: ${
|
|
103
|
-
`- **Issue**: ${
|
|
104
|
-
`- **Impact**: ${
|
|
105
|
-
`- **Resolution**: ${
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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 (
|
|
214
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
245
|
-
const
|
|
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 (
|
|
253
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
*
|
|
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}
|
|
147
|
+
* @returns {string[]} per-row rendered blocks
|
|
145
148
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
304
|
+
const indexLine = indexContent + '\n';
|
|
305
|
+
writeAtomic(indexFilePath, indexLine);
|
|
267
306
|
|
|
268
307
|
process.stderr.write(
|
|
269
|
-
`[render-decisions] wrote decisions.md (${decisionsContent
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|