devflow-kit 3.0.1 → 3.2.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 (166) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/agents/git.md +2 -2
  4. package/dist/cli/agents-view/index.js +1 -1
  5. package/dist/cli/agents-view/render.js +71 -17
  6. package/dist/cli/agents-view/state.js +42 -16
  7. package/dist/cli/agents-view/terminal.js +5 -5
  8. package/dist/cli/commands/agents.js +142 -51
  9. package/dist/cli/commands/ambient.js +1 -1
  10. package/dist/cli/commands/attribution-prompts.js +8 -8
  11. package/dist/cli/commands/capture.js +1 -1
  12. package/dist/cli/commands/compliance-prompts.js +8 -8
  13. package/dist/cli/commands/compliance.js +8 -7
  14. package/dist/cli/commands/flags.js +33 -31
  15. package/dist/cli/commands/hud.js +1 -1
  16. package/dist/cli/commands/init-seed.js +9 -9
  17. package/dist/cli/commands/init.js +162 -85
  18. package/dist/cli/commands/install-report.js +10 -10
  19. package/dist/cli/commands/learning.js +302 -136
  20. package/dist/cli/commands/memory.js +36 -15
  21. package/dist/cli/commands/proxy.js +23 -23
  22. package/dist/cli/commands/rules.js +6 -5
  23. package/dist/cli/commands/tracker-prompts.js +6 -6
  24. package/dist/cli/commands/tracker.js +9 -9
  25. package/dist/cli/commands/uninstall.js +183 -59
  26. package/dist/cli/flags-view/render.js +5 -5
  27. package/dist/cli/flags-view/state.js +9 -9
  28. package/dist/cli/flags-view/terminal.js +4 -4
  29. package/dist/cli/tui/cells.js +1 -1
  30. package/dist/cli/tui/terminal.js +6 -6
  31. package/dist/commands/code-review.md +0 -2
  32. package/dist/commands/debug.md +14 -11
  33. package/dist/commands/dynamic-build.md +51 -47
  34. package/dist/commands/dynamic-plan.md +27 -7
  35. package/dist/commands/dynamic-profile.md +17 -3
  36. package/dist/commands/dynamic-tickets.md +18 -4
  37. package/dist/commands/explore.md +9 -3
  38. package/dist/commands/implement.md +20 -16
  39. package/dist/commands/plan.md +13 -9
  40. package/dist/commands/release.md +23 -3
  41. package/dist/commands/research.md +9 -3
  42. package/dist/commands/resolve.md +9 -12
  43. package/dist/commands/self-review.md +0 -2
  44. package/dist/core/agent-frontmatter.js +28 -3
  45. package/dist/core/agent-models.js +204 -42
  46. package/dist/core/agent-state.js +28 -6
  47. package/dist/core/ansi.js +2 -2
  48. package/dist/core/assets.js +1 -1
  49. package/dist/core/cache.js +7 -8
  50. package/dist/core/codex-auth-inspect.js +4 -4
  51. package/dist/core/compliance-compose.js +3 -3
  52. package/dist/core/compliance.js +3 -4
  53. package/dist/core/evidence-policy.js +14 -13
  54. package/dist/core/external-models.js +1 -1
  55. package/dist/core/feature-config.js +71 -13
  56. package/dist/core/feature-switch.js +3 -3
  57. package/dist/core/flags.js +49 -25
  58. package/dist/core/fs-atomic.js +6 -7
  59. package/dist/core/learning-queue-cleanup.js +16 -81
  60. package/dist/core/learning-store.js +61 -0
  61. package/dist/core/linked-path.js +46 -0
  62. package/dist/core/manifest.js +5 -5
  63. package/dist/core/mds-variants.js +13 -13
  64. package/dist/core/model-discovery.js +8 -8
  65. package/dist/core/observations.js +17 -101
  66. package/dist/core/orphan-sweep.js +4 -4
  67. package/dist/core/plugins.js +13 -8
  68. package/dist/core/project-paths.js +9 -13
  69. package/dist/core/proxy-log.js +8 -8
  70. package/dist/core/proxy-state.js +3 -3
  71. package/dist/core/queue-drain.js +31 -0
  72. package/dist/core/reference-sweep.js +6 -6
  73. package/dist/core/teammate-mode-cleanup.js +1 -1
  74. package/dist/core/tracker.js +14 -14
  75. package/dist/hud/colors.js +2 -2
  76. package/dist/hud/components/learning-counts.js +54 -22
  77. package/dist/hud/components/version-badge.js +1 -1
  78. package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
  79. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  80. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  81. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  82. package/dist/targets/claude-code/compliance-install.js +17 -15
  83. package/dist/targets/claude-code/hooks.js +2 -2
  84. package/dist/targets/claude-code/installer.js +59 -32
  85. package/dist/targets/claude-code/legacy.js +1 -1
  86. package/dist/targets/claude-code/post-install.js +135 -45
  87. package/dist/targets/claude-code/tracker-install.js +2 -2
  88. package/package.json +1 -1
  89. package/src/assets/agents/code.md +15 -21
  90. package/src/assets/agents/design.md +4 -2
  91. package/src/assets/agents/diagnose.md +3 -1
  92. package/src/assets/agents/evaluate.md +4 -0
  93. package/src/assets/agents/git.mds +2 -2
  94. package/src/assets/agents/knowledge.md +5 -3
  95. package/src/assets/agents/learning.md +281 -196
  96. package/src/assets/agents/research.md +3 -1
  97. package/src/assets/agents/review.md +5 -3
  98. package/src/assets/agents/scrutinize.md +5 -1
  99. package/src/assets/agents/simplify.md +4 -0
  100. package/src/assets/agents/skim.md +4 -2
  101. package/src/assets/agents/synthesize.md +6 -0
  102. package/src/assets/agents/test.md +18 -10
  103. package/src/assets/agents/triage.md +11 -9
  104. package/src/assets/agents/validate.md +14 -10
  105. package/src/assets/commands/_partials/_decisions.mds +8 -3
  106. package/src/assets/commands/_partials/_docs_root.mds +3 -3
  107. package/src/assets/commands/_partials/_engine.mds +16 -32
  108. package/src/assets/commands/_partials/_knowledge.mds +0 -2
  109. package/src/assets/commands/_partials/_preamble.mds +6 -2
  110. package/src/assets/commands/_partials/_settings.mds +2 -2
  111. package/src/assets/commands/_partials/_tracker.mds +1 -1
  112. package/src/assets/commands/code-review.mds +0 -2
  113. package/src/assets/commands/debug.mds +13 -8
  114. package/src/assets/commands/dynamic-build.mds +18 -12
  115. package/src/assets/commands/dynamic-plan.mds +10 -4
  116. package/src/assets/commands/dynamic-profile.mds +1 -1
  117. package/src/assets/commands/dynamic-tickets.mds +2 -2
  118. package/src/assets/commands/explore.mds +9 -1
  119. package/src/assets/commands/implement.mds +19 -13
  120. package/src/assets/commands/plan.mds +12 -8
  121. package/src/assets/commands/release.md +23 -3
  122. package/src/assets/commands/research.mds +9 -3
  123. package/src/assets/commands/resolve.mds +9 -10
  124. package/src/assets/mds/git/_pr.mds +3 -3
  125. package/src/assets/mds/tracker/_common.mds +1 -1
  126. package/src/assets/mds/tracker/_github.mds +3 -3
  127. package/src/assets/mds/tracker/_jira.mds +3 -3
  128. package/src/assets/mds/tracker/_linear.mds +3 -3
  129. package/src/assets/mds/tracker/_mcp.mds +6 -5
  130. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
  131. package/src/assets/scripts/hooks/background-memory-update +97 -33
  132. package/src/assets/scripts/hooks/capture-prompt +4 -3
  133. package/src/assets/scripts/hooks/capture-question +4 -3
  134. package/src/assets/scripts/hooks/capture-turn +5 -20
  135. package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
  136. package/src/assets/scripts/hooks/ensure-proxy +5 -6
  137. package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
  138. package/src/assets/scripts/hooks/git-marker +71 -0
  139. package/src/assets/scripts/hooks/is-hex-sha +1 -1
  140. package/src/assets/scripts/hooks/json-helper.cjs +345 -944
  141. package/src/assets/scripts/hooks/json-parse +25 -129
  142. package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
  143. package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
  144. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
  145. package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
  146. package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
  147. package/src/assets/scripts/hooks/memory-worker +10 -0
  148. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  149. package/src/assets/scripts/hooks/preamble +9 -1
  150. package/src/assets/scripts/hooks/queue-append +55 -23
  151. package/src/assets/scripts/hooks/resolve-project-root +3 -4
  152. package/src/assets/scripts/hooks/session-start-context +146 -45
  153. package/src/assets/scripts/hooks/session-start-memory +33 -11
  154. package/src/assets/scripts/lib/project-config.cjs +2 -2
  155. package/src/assets/scripts/pr-evidence.cjs +3 -3
  156. package/src/assets/scripts/redact-secrets.cjs +20 -20
  157. package/src/assets/scripts/release-trace.cjs +1 -1
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
  159. package/src/assets/scripts/resolve-settings.cjs +3 -3
  160. package/src/assets/scripts/verify-evidence.cjs +2 -2
  161. package/src/assets/skills/apply-decisions/SKILL.md +37 -17
  162. package/src/assets/skills/docs-framework/SKILL.md +2 -2
  163. package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
  164. package/src/assets/skills/test-driven-development/SKILL.md +6 -4
  165. package/dist/core/observation-io.js +0 -50
  166. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -6,30 +6,33 @@
6
6
  // DESIGN: Idempotent, clock-free render from anchored ledger rows. No timestamps
7
7
  // in output — render is a pure function of the ledger rows. Two consumers:
8
8
  // 1. renderDecisionsFile(rows, kind) — exported pure function for testing
9
- // 2. CLI: `render <worktree>` and `--check <worktree>` subcommands
9
+ // 2. CLI: `render <worktree>` and `--check <worktree>` subcommands, run from the
10
+ // project root: <worktree> names the current directory
10
11
  //
11
12
  // Filtering rules (must match AC-F3):
12
13
  // - anchor_id must be set (unanchored observing rows are excluded)
13
14
  // - type must match kind: 'decision' rows → decisions.md; 'pitfall' rows → pitfalls.md
14
- // - decisions_status: undefined|'Accepted'|'Active' → included
15
- // 'Deprecated'|'Superseded'|'Retired' → excluded
15
+ // - an active row (isActive from learning-store.cjs, the one status list) renders
16
+ // its body; an inactive one is listed in the file's Inactive table instead
16
17
  //
17
- // Row shape: see LearningObservation in src/core/observations.ts.
18
+ // Row shape: a v2 ledger row (schema 2) or a v1 one; see learning-store.cjs.
18
19
  // Ledger file: .devflow/learning/decisions-ledger.jsonl (anchored rows only).
19
20
  // If absent, treat as empty corpus.
20
21
  //
21
- // Byte-compat: formatDecisionBody / formatPitfallBody / buildTldrLine /
22
- // initDecisionsContent — all from decisions-format.cjs (single source of truth).
22
+ // Byte-compat: formatDecisionBody / formatPitfallBody / formatEntryBodyV2 /
23
+ // formatInactiveTable / buildTldrLine / initDecisionsContent — all from
24
+ // decisions-format.cjs (single source of truth).
23
25
 
24
26
  'use strict';
25
27
 
26
28
  const fs = require('fs');
27
- const path = require('path');
28
29
 
29
30
  const {
30
31
  initDecisionsContent,
31
32
  formatDecisionBody,
32
33
  formatPitfallBody,
34
+ formatEntryBodyV2,
35
+ formatInactiveTable,
33
36
  buildTldrLine,
34
37
  buildIndexContent,
35
38
  } = require('./decisions-format.cjs');
@@ -38,51 +41,32 @@ const {
38
41
  getDecisionsFilePath,
39
42
  getPitfallsFilePath,
40
43
  getDecisionsIndexPath,
41
- getDecisionsLockDir,
44
+ getDecisionsLedgerPath,
42
45
  } = require('./project-paths.cjs');
43
- const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
44
46
  const { safePath } = require('./safe-path.cjs');
45
-
46
- // ---------------------------------------------------------------------------
47
- // Constants
48
- // ---------------------------------------------------------------------------
49
-
50
- /** Statuses that indicate an anchored entry should be HIDDEN from the render. */
51
- const INACTIVE_STATUSES = new Set(['Deprecated', 'Superseded', 'Retired']);
52
-
53
- /** Ledger filename relative to .devflow/learning/ */
54
- const LEDGER_FILENAME = 'decisions-ledger.jsonl';
47
+ const {
48
+ isActive,
49
+ isV2,
50
+ readJsonl,
51
+ hasLearningDir,
52
+ withDecisionsLock,
53
+ writeFileAtomic,
54
+ } = require('./learning-store.cjs');
55
55
 
56
56
  // ---------------------------------------------------------------------------
57
57
  // Ledger parsing
58
58
  // ---------------------------------------------------------------------------
59
59
 
60
60
  /**
61
- * Parse a JSONL ledger file into an array of row objects.
62
- * Skips empty or malformed lines. Returns [] if file is absent.
61
+ * Read a JSONL ledger file leniently: its rows, skipping any line that is not one
62
+ * JSON object, or [] when the file is absent. Read-only: a skipped line is never
63
+ * quarantined (the store's readJsonl reports it for a caller that counts them).
63
64
  *
64
65
  * @param {string} ledgerPath
65
66
  * @returns {object[]}
66
67
  */
67
68
  function parseLedger(ledgerPath) {
68
- let raw;
69
- try {
70
- raw = fs.readFileSync(ledgerPath, 'utf8');
71
- } catch (err) {
72
- if (err.code === 'ENOENT') return [];
73
- throw err;
74
- }
75
- const rows = [];
76
- for (const line of raw.split('\n')) {
77
- const trimmed = line.trim();
78
- if (!trimmed) continue;
79
- try {
80
- rows.push(JSON.parse(trimmed));
81
- } catch {
82
- // Skip malformed lines
83
- }
84
- }
85
- return rows;
69
+ return readJsonl(ledgerPath).rows;
86
70
  }
87
71
 
88
72
  // ---------------------------------------------------------------------------
@@ -90,19 +74,7 @@ function parseLedger(ledgerPath) {
90
74
  // ---------------------------------------------------------------------------
91
75
 
92
76
  /**
93
- * Determine whether a row is "active" for render purposes.
94
- * Active = decisions_status is undefined OR is one of 'Accepted'/'Active'.
95
- *
96
- * @param {object} row
97
- * @returns {boolean}
98
- */
99
- function isActive(row) {
100
- if (!row.decisions_status) return true;
101
- return !INACTIVE_STATUSES.has(row.decisions_status);
102
- }
103
-
104
- /**
105
- * Extract the numeric suffix from an anchor_id like "ADR-016" or "PF-007".
77
+ * Extract the numeric suffix from an anchor_id like "ADR-NNN" or "PF-NNN".
106
78
  * Returns Infinity for unparseable values so they sort to the end.
107
79
  *
108
80
  * @param {string} anchorId
@@ -114,6 +86,26 @@ function anchorNumeric(anchorId) {
114
86
  return m ? parseInt(m[0], 10) : Infinity;
115
87
  }
116
88
 
89
+ /** The ledger row type a rendered file of `kind` holds. */
90
+ function rowTypeOf(kind) {
91
+ return kind === 'decisions' ? 'decision' : 'pitfall';
92
+ }
93
+
94
+ /**
95
+ * The anchored rows of `kind` whose activity is `active`, sorted by numeric anchor.
96
+ *
97
+ * @param {object[]} rows - all rows from the ledger (unfiltered)
98
+ * @param {'decisions'|'pitfalls'} kind
99
+ * @param {boolean} active
100
+ * @returns {object[]}
101
+ */
102
+ function selectRows(rows, kind, active) {
103
+ const type = rowTypeOf(kind);
104
+ return rows
105
+ .filter(r => r.type === type && r.anchor_id && isActive(r) === active)
106
+ .sort((a, b) => anchorNumeric(a.anchor_id) - anchorNumeric(b.anchor_id));
107
+ }
108
+
117
109
  /**
118
110
  * Select active rows of a given kind from the ledger, sorted by numeric anchor.
119
111
  * Exported so callers (renderAndWriteAll, migrations) can build the index without
@@ -124,10 +116,19 @@ function anchorNumeric(anchorId) {
124
116
  * @returns {object[]} filtered + sorted active rows
125
117
  */
126
118
  function selectActiveRows(rows, kind) {
127
- const type = kind === 'decisions' ? 'decision' : 'pitfall';
128
- return rows
129
- .filter(r => r.type === type && r.anchor_id && isActive(r))
130
- .sort((a, b) => anchorNumeric(a.anchor_id) - anchorNumeric(b.anchor_id));
119
+ return selectRows(rows, kind, true);
120
+ }
121
+
122
+ /**
123
+ * Select the inactive rows of a given kind, sorted by numeric anchor: the rows a
124
+ * file lists in its Inactive table instead of rendering their bodies.
125
+ *
126
+ * @param {object[]} rows - all rows from the ledger (unfiltered)
127
+ * @param {'decisions'|'pitfalls'} kind
128
+ * @returns {object[]} filtered + sorted inactive rows
129
+ */
130
+ function selectInactiveRows(rows, kind) {
131
+ return selectRows(rows, kind, false);
131
132
  }
132
133
 
133
134
  /**
@@ -135,8 +136,16 @@ function selectActiveRows(rows, kind) {
135
136
  * Each block starts with a leading newline (matching the format contract).
136
137
  *
137
138
  * Per-row content:
138
- * - If row.raw_body is truthy → emit verbatim (migrated entries)
139
- * - Otherwise → formatDecisionBody / formatPitfallBody from details
139
+ * - a v2 row (schema 2) → formatEntryBodyV2 from its fields
140
+ * - a v1 row with a truthy raw_body → raw_body verbatim (migrated entries)
141
+ * - any other v1 row → formatDecisionBody / formatPitfallBody from details
142
+ *
143
+ * D-V1-BYTE-STABLE: a v1 ledger row — any row without schema 2 — renders byte for
144
+ * byte as it did before v2: its raw_body verbatim when truthy, else
145
+ * formatDecisionBody or formatPitfallBody, and its index line keeps the v1 shape;
146
+ * only a schema-2 row takes the v2 body and index line. Reason: the ledger stays
147
+ * v1 until each entry is rewritten, and a render that moved v1 bytes would show
148
+ * every untouched entry as changed and alter text nobody revised.
140
149
  *
141
150
  * Extracted so renderAndWriteAll can compute blocks once and reuse them
142
151
  * for both the body files and buildIndexContent, avoiding a second full
@@ -148,6 +157,7 @@ function selectActiveRows(rows, kind) {
148
157
  */
149
158
  function buildBodyBlocks(activeRows, kind) {
150
159
  return activeRows.map(row => {
160
+ if (isV2(row)) return formatEntryBodyV2(row);
151
161
  if (row.raw_body) {
152
162
  // Migrated entry: emit verbatim. raw_body must start with \n## so
153
163
  // it fits seamlessly after the header preamble.
@@ -160,44 +170,27 @@ function buildBodyBlocks(activeRows, kind) {
160
170
  }
161
171
 
162
172
  /**
163
- * Assemble the full file content from pre-computed blocks and active rows.
173
+ * Assemble the full file content: the header with the file's TL;DR line, the
174
+ * pre-computed active blocks, then the Inactive table.
164
175
  * Internal helper — avoids re-computing blocks when the caller already has them.
165
176
  *
166
177
  * @param {object[]} activeRows - already-filtered + sorted active rows
167
178
  * @param {string[]} blocks - pre-rendered per-row blocks (from buildBodyBlocks)
168
179
  * @param {'decisions'|'pitfalls'} kind
180
+ * @param {object[]} inactiveRows - already-filtered + sorted inactive rows
169
181
  * @returns {string} complete file content
170
182
  */
171
- function buildFileFromBlocks(activeRows, blocks, kind) {
172
- // Build TL;DR line (uses active + sorted rows so last-5 are stable)
183
+ function buildFileFromBlocks(activeRows, blocks, kind, inactiveRows) {
184
+ // The TL;DR line counts the active rows.
173
185
  const tldr = buildTldrLine(kind, activeRows);
174
186
 
175
- // Build header: replace placeholder TL;DR in the init content with the real one.
176
- // initDecisionsContent returns "<!-- TL;DR: 0 {kind}. Key: -->\n..." so we
177
- // replace the TL;DR line at position 0.
178
- const initKind = kind === 'decisions' ? 'decision' : 'pitfall';
179
- const headerWithPlaceholder = initDecisionsContent(initKind);
187
+ // Build header: replace the zero-count TL;DR line that opens the init content
188
+ // ("<!-- TL;DR: 0 {kind} -->\n...") with the real one.
189
+ const headerWithPlaceholder = initDecisionsContent(rowTypeOf(kind));
180
190
  // Replace only the first line (the TL;DR comment)
181
191
  const header = headerWithPlaceholder.replace(/^<!-- TL;DR:[^\n]*-->/, tldr);
182
192
 
183
- return header + blocks.join('');
184
- }
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);
193
+ return header + blocks.join('') + formatInactiveTable(inactiveRows);
201
194
  }
202
195
 
203
196
  /**
@@ -207,13 +200,14 @@ function renderBodyFromActive(activeRows, kind) {
207
200
  * Filtering:
208
201
  * - row.type must match kind ('decision' → decisions.md, 'pitfall' → pitfalls.md)
209
202
  * - row.anchor_id must be set
210
- * - row must be active (decisions_status not in INACTIVE_STATUSES)
203
+ * - an active row (isActive) renders its body; an inactive one is listed in the
204
+ * Inactive table
211
205
  *
212
206
  * Output structure:
213
207
  * TL;DR line (line 1)
214
208
  * File header body (title + preamble)
215
209
  * Per-row blocks (sorted by numeric anchor ASC)
216
- * (no trailing newline beyond what the blocks naturally include)
210
+ * Inactive table (sorted by numeric anchor ASC; omitted when empty)
217
211
  *
218
212
  * Idempotent and clock-free: no timestamps in output.
219
213
  *
@@ -222,54 +216,27 @@ function renderBodyFromActive(activeRows, kind) {
222
216
  * @returns {string} complete file content
223
217
  */
224
218
  function renderDecisionsFile(rows, kind) {
225
- return renderBodyFromActive(selectActiveRows(rows, kind), kind);
219
+ const activeRows = selectActiveRows(rows, kind);
220
+ return buildFileFromBlocks(activeRows, buildBodyBlocks(activeRows, kind), kind, selectInactiveRows(rows, kind));
226
221
  }
227
222
 
228
223
  // ---------------------------------------------------------------------------
229
- // Atomic write helper
224
+ // Render and write
230
225
  // ---------------------------------------------------------------------------
231
226
 
232
227
  /**
233
- * Write content atomically via a .tmp sibling + rename.
234
- * Uses O_EXCL to prevent TOCTOU symlink attacks, retries once on EEXIST.
235
- *
236
- * @param {string} filePath
237
- * @param {string} content
238
- */
239
- function writeAtomic(filePath, content) {
240
- const tmp = filePath + '.tmp';
241
- try {
242
- fs.writeFileSync(tmp, content, { flag: 'wx' });
243
- } catch (err) {
244
- if (err.code !== 'EEXIST') throw err;
245
- try { fs.unlinkSync(tmp); } catch { /* race */ }
246
- fs.writeFileSync(tmp, content, { flag: 'wx' });
247
- }
248
- fs.renameSync(tmp, filePath);
249
- }
250
-
251
- // ---------------------------------------------------------------------------
252
- // Lock-free render+write helper (for callers that already hold .decisions.lock)
253
- // ---------------------------------------------------------------------------
254
-
255
- /**
256
- * Render both decisions.md and pitfalls.md from the given ledger rows and write
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.
260
- *
261
- * Creates the decisionsDir if it does not exist.
228
+ * Render decisions.md, pitfalls.md and index.md from the ledger rows, in memory,
229
+ * in the order they are written: the body files first, the index last. `render`
230
+ * and the learning store's writers write exactly these contents, and `--check`
231
+ * compares exactly these.
262
232
  *
263
233
  * @param {string} worktreePath - Absolute path to the worktree root.
264
234
  * @param {object[]} rows - All rows from the ledger (unfiltered).
235
+ * @returns {Array<{ path: string, content: string }>}
265
236
  */
266
- function renderAndWriteAll(worktreePath, rows) {
267
- const decisionsDir = path.join(worktreePath, '.devflow', 'learning');
268
- fs.mkdirSync(decisionsDir, { recursive: true });
269
-
237
+ function renderLearningFiles(worktreePath, rows) {
270
238
  const decisionsFilePath = getDecisionsFilePath(worktreePath);
271
239
  const pitfallsFilePath = getPitfallsFilePath(worktreePath);
272
- const indexFilePath = getDecisionsIndexPath(worktreePath);
273
240
 
274
241
  // Hoist active-row selection: computed once per kind, reused for both the
275
242
  // body render and the index build — avoids two redundant selectActiveRows passes.
@@ -281,31 +248,53 @@ function renderAndWriteAll(worktreePath, rows) {
281
248
  const decisionBlocks = buildBodyBlocks(activeDecisionRows, 'decisions');
282
249
  const pitfallBlocks = buildBodyBlocks(activePitfallRows, 'pitfalls');
283
250
 
284
- const decisionsContent = buildFileFromBlocks(activeDecisionRows, decisionBlocks, 'decisions');
285
- const pitfallsContent = buildFileFromBlocks(activePitfallRows, pitfallBlocks, 'pitfalls');
251
+ return [
252
+ {
253
+ path: decisionsFilePath,
254
+ content: buildFileFromBlocks(activeDecisionRows, decisionBlocks, 'decisions', selectInactiveRows(rows, 'decisions')),
255
+ },
256
+ {
257
+ path: pitfallsFilePath,
258
+ content: buildFileFromBlocks(activePitfallRows, pitfallBlocks, 'pitfalls', selectInactiveRows(rows, 'pitfalls')),
259
+ },
260
+ {
261
+ // Compact index: a write-time artifact consumed via plain Read.
262
+ path: getDecisionsIndexPath(worktreePath),
263
+ content: buildIndexContent(activeDecisionRows, activePitfallRows, {
264
+ decisionsFilePath,
265
+ pitfallsFilePath,
266
+ decisionBlocks,
267
+ pitfallBlocks,
268
+ }) + '\n',
269
+ },
270
+ ];
271
+ }
272
+
273
+ /**
274
+ * Render decisions.md, pitfalls.md and index.md from the given ledger rows and
275
+ * write them atomically. It takes no lock: the caller holds .decisions.lock
276
+ * (D-ONE-LEARNING-LOCK). It writes only into an existing `.devflow/learning/`:
277
+ * without one it throws before writing anything (D-NO-STRAY-TREE).
278
+ *
279
+ * @param {string} worktreePath - Absolute path to the worktree root.
280
+ * @param {object[]} rows - All rows from the ledger (unfiltered).
281
+ * @throws when `<worktreePath>/.devflow/learning/` does not exist, or a write fails
282
+ */
283
+ function renderAndWriteAll(worktreePath, rows) {
284
+ if (!hasLearningDir(worktreePath)) {
285
+ throw new Error(`renderAndWriteAll: no .devflow/learning/ under ${worktreePath}`);
286
+ }
287
+ const [decisions, pitfalls, index] = renderLearningFiles(worktreePath, rows);
286
288
 
287
289
  // Write body files first; index last. On a crash between body writes and the
288
290
  // index write: on the FIRST render the index is absent (reader falls back to
289
291
  // (none)); on a RE-render the index is stale — one generation behind the new
290
292
  // body files — never corrupt. Both cases are benign and self-heal on the next
291
293
  // successful render.
292
- writeAtomic(decisionsFilePath, decisionsContent);
293
- writeAtomic(pitfallsFilePath, pitfallsContent);
294
-
295
- // Build and write compact index (write-time artifact; consumed via plain Read)
296
- // Reuses the pre-computed active rows and pre-rendered blocks — no additional
297
- // selectActiveRows or format pass.
298
- const indexContent = buildIndexContent(activeDecisionRows, activePitfallRows, {
299
- decisionsFilePath,
300
- pitfallsFilePath,
301
- decisionBlocks,
302
- pitfallBlocks,
303
- });
304
- const indexLine = indexContent + '\n';
305
- writeAtomic(indexFilePath, indexLine);
294
+ for (const file of [decisions, pitfalls, index]) writeFileAtomic(file.path, file.content);
306
295
 
307
296
  process.stderr.write(
308
- `[render-decisions] wrote decisions.md (${Buffer.byteLength(decisionsContent)}B) + pitfalls.md (${Buffer.byteLength(pitfallsContent)}B) + index.md (${Buffer.byteLength(indexLine)}B)\n`
297
+ `[render-decisions] wrote decisions.md (${Buffer.byteLength(decisions.content)}B) + pitfalls.md (${Buffer.byteLength(pitfalls.content)}B) + index.md (${Buffer.byteLength(index.content)}B)\n`
309
298
  );
310
299
  }
311
300
 
@@ -313,106 +302,142 @@ function renderAndWriteAll(worktreePath, rows) {
313
302
  // CLI entry point
314
303
  // ---------------------------------------------------------------------------
315
304
 
316
- if (require.main === module) {
317
- const argv = process.argv.slice(2);
318
-
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';
323
-
324
- // Parse: `render <worktree>` or `--check <worktree>`
325
- let mode; // 'render' | 'check'
326
- let worktreePath;
327
-
328
- if (argv[0] === 'render' && argv[1]) {
329
- mode = 'render';
330
- worktreePath = path.resolve(argv[1]);
331
- } else if (argv[0] === '--check' && argv[1]) {
332
- mode = 'check';
333
- worktreePath = path.resolve(argv[1]);
334
- } else {
335
- process.stderr.write(USAGE);
336
- process.exit(1);
305
+ /** The name the CLI's messages carry; the lock reports it as the op. */
306
+ const CLI_NAME = 'render-decisions';
307
+
308
+ const USAGE =
309
+ 'Usage (run from the project root; <worktree> names the current directory, e.g. "."):\n' +
310
+ ' render-decisions.cjs render <worktree> Write decisions.md, pitfalls.md and index.md\n' +
311
+ ' render-decisions.cjs --check <worktree> Compare without writing; exit 1 on drift\n';
312
+
313
+ /**
314
+ * The project root the CLI works in: the current directory, with symlinks
315
+ * resolved. The CLI runs from the project root, as the json-helper ops do, and
316
+ * `<worktree>` must name that same directory once resolved (safePath refuses a NUL
317
+ * byte). The argument is only compared: every path the CLI reads or writes is
318
+ * built from the current directory, never from argv.
319
+ *
320
+ * @param {string} arg - the `<worktree>` argument
321
+ * @returns {{ ok: true, value: string } | { ok: false, error: { kind: 'invalid-root', message: string } }}
322
+ */
323
+ function resolveCliRoot(arg) {
324
+ const invalid = message => ({ ok: false, error: { kind: 'invalid-root', message: `${CLI_NAME}: ${message}` } });
325
+ let cwd;
326
+ let named;
327
+ try {
328
+ cwd = fs.realpathSync(process.cwd());
329
+ named = fs.realpathSync(safePath(arg));
330
+ } catch (err) {
331
+ return invalid(`invalid worktree path: ${err.message}`);
332
+ }
333
+ if (named !== cwd) {
334
+ return invalid(`${named} is not the current directory ${cwd} — run from the project root`);
337
335
  }
336
+ return { ok: true, value: cwd };
337
+ }
338
+
339
+ /** Report on stderr how many ledger lines were not one JSON object, when any were. */
340
+ function reportMalformed(count, ledgerPath) {
341
+ if (count === 0) return;
342
+ process.stderr.write(
343
+ `[render-decisions] MALFORMED: ${count} ledger line${count === 1 ? '' : 's'} skipped (${ledgerPath})\n`
344
+ );
345
+ }
338
346
 
339
- // Validate path at trust boundary before any file operations.
340
- // safePath rejects null bytes, which path.resolve preserves silently.
347
+ /** The ledger's rows, reporting its malformed lines on stderr. Read-only. */
348
+ function readLedgerRows(root) {
349
+ const ledgerPath = getDecisionsLedgerPath(root);
350
+ const { rows, rejected } = readJsonl(ledgerPath);
351
+ reportMalformed(rejected.length, ledgerPath);
352
+ return rows;
353
+ }
354
+
355
+ /** The file's content, or null when it does not exist. */
356
+ function readIfPresent(file) {
341
357
  try {
342
- worktreePath = safePath(worktreePath);
358
+ return fs.readFileSync(file, 'utf8');
343
359
  } catch (err) {
344
- process.stderr.write(`render-decisions: invalid worktree path: ${err.message}\n`);
345
- process.exit(1);
360
+ if (err && err.code === 'ENOENT') return null;
361
+ throw err;
346
362
  }
363
+ }
347
364
 
348
- const decisionsDir = path.join(worktreePath, '.devflow', 'learning');
349
- const ledgerPath = path.join(decisionsDir, LEDGER_FILENAME);
350
- const decisionsFilePath = getDecisionsFilePath(worktreePath);
351
- const pitfallsFilePath = getPitfallsFilePath(worktreePath);
352
- const indexFilePath = getDecisionsIndexPath(worktreePath);
353
- const lockDir = getDecisionsLockDir(worktreePath);
354
-
355
- // Ensure decisionsDir exists (needed before lock acquisition and file reads)
356
- fs.mkdirSync(decisionsDir, { recursive: true });
357
-
358
- // Read ledger (empty corpus if absent)
359
- const rows = parseLedger(ledgerPath);
360
-
361
- if (mode === 'check') {
362
- // Render all three files in memory and compare against on-disk content.
363
- // Exit non-zero on drift.
364
- // Hoist active-row selection (mirrors renderAndWriteAll): computed once per kind,
365
- // reused for both body render and index build.
366
- const activeDecisionRows = selectActiveRows(rows, 'decisions');
367
- const activePitfallRows = selectActiveRows(rows, 'pitfalls');
368
- const decisionsContent = renderBodyFromActive(activeDecisionRows, 'decisions');
369
- const pitfallsContent = renderBodyFromActive(activePitfallRows, 'pitfalls');
370
- const indexContent = buildIndexContent(activeDecisionRows, activePitfallRows, {
371
- decisionsFilePath,
372
- pitfallsFilePath,
373
- }) + '\n';
374
-
375
- let drift = false;
376
- let existingDecisions = '';
377
- let existingPitfalls = '';
378
- let existingIndex = '';
379
- try { existingDecisions = fs.readFileSync(decisionsFilePath, 'utf8'); } catch { drift = true; }
380
- try { existingPitfalls = fs.readFileSync(pitfallsFilePath, 'utf8'); } catch { drift = true; }
381
- // index.md missing is a drift condition (it should always be present after a render)
382
- try { existingIndex = fs.readFileSync(indexFilePath, 'utf8'); } catch { drift = true; }
383
-
384
- if (!drift) {
385
- if (existingDecisions !== decisionsContent) {
386
- process.stderr.write(`[render-decisions] DRIFT: ${decisionsFilePath}\n`);
387
- drift = true;
388
- }
389
- if (existingPitfalls !== pitfallsContent) {
390
- process.stderr.write(`[render-decisions] DRIFT: ${pitfallsFilePath}\n`);
391
- drift = true;
392
- }
393
- if (existingIndex !== indexContent) {
394
- process.stderr.write(`[render-decisions] DRIFT: ${indexFilePath}\n`);
395
- drift = true;
396
- }
397
- }
365
+ /**
366
+ * `render`: read the ledger under .decisions.lock and write the three files from
367
+ * what it read (D-ONE-LEARNING-LOCK), so a ledger write that lands while it waits
368
+ * is rendered rather than overwritten. Refuses without the learning directory
369
+ * (D-NO-STRAY-TREE), and when it or `.devflow` is a symbolic link (D-NO-LINKED-TREE).
370
+ *
371
+ * @param {string} root
372
+ * @returns {number} exit code
373
+ */
374
+ function renderCommand(root) {
375
+ const result = withDecisionsLock(CLI_NAME, root, () => {
376
+ renderAndWriteAll(root, readLedgerRows(root));
377
+ return { ok: true, value: null };
378
+ });
379
+ if (result.ok) return 0;
380
+ process.stderr.write(`${result.error.message}\n`);
381
+ return 1;
382
+ }
398
383
 
399
- process.exit(drift ? 1 : 0);
384
+ /**
385
+ * `--check`: render in memory and compare with the files on disk, naming each
386
+ * file that differs or is missing; exit 1 on any drift. It writes nothing and
387
+ * takes no lock — taking it would create the lock directory — and refuses
388
+ * without the learning directory (D-NO-STRAY-TREE).
389
+ *
390
+ * @param {string} root
391
+ * @returns {number} exit code
392
+ */
393
+ function checkCommand(root) {
394
+ if (!hasLearningDir(root)) {
395
+ process.stderr.write(`${CLI_NAME}: no .devflow/learning/ under ${root} — run from the project root\n`);
396
+ return 1;
397
+ }
398
+ let drift = false;
399
+ for (const file of renderLearningFiles(root, readLedgerRows(root))) {
400
+ const onDisk = readIfPresent(file.path);
401
+ if (onDisk !== file.content) {
402
+ process.stderr.write(`[render-decisions] DRIFT: ${file.path}${onDisk === null ? ' (missing)' : ''}\n`);
403
+ drift = true;
404
+ }
400
405
  }
406
+ return drift ? 1 : 0;
407
+ }
401
408
 
402
- // mode === 'render': write atomically under lock
403
- if (!acquireMkdirLock(lockDir, 30000, 60000)) {
404
- process.stderr.write(`render-decisions: timeout acquiring lock at ${lockDir}\n`);
405
- process.exit(1);
409
+ /**
410
+ * Run the CLI on its arguments and return the exit code. Neither mode creates the
411
+ * learning directory (D-NO-STRAY-TREE).
412
+ *
413
+ * @param {string[]} argv - the arguments after the script path
414
+ * @returns {number}
415
+ */
416
+ function runCli(argv) {
417
+ const mode = argv[0] === 'render' || argv[0] === '--check' ? argv[0] : null;
418
+ if (mode === null || argv.length !== 2 || !argv[1]) {
419
+ process.stderr.write(USAGE);
420
+ return 1;
406
421
  }
422
+ const root = resolveCliRoot(argv[1]);
423
+ if (!root.ok) {
424
+ process.stderr.write(`${root.error.message}\n`);
425
+ return 1;
426
+ }
427
+ return mode === 'render' ? renderCommand(root.value) : checkCommand(root.value);
428
+ }
407
429
 
430
+ if (require.main === module) {
431
+ // The one exit, outside every lock: withDecisionsLock has released its lock
432
+ // before runCli returns or throws.
433
+ let exitCode;
408
434
  try {
409
- // Use the lock-free helper — we already hold the lock.
410
- renderAndWriteAll(worktreePath, rows);
411
- } finally {
412
- releaseLock(lockDir);
435
+ exitCode = runCli(process.argv.slice(2));
436
+ } catch (err) {
437
+ process.stderr.write(`${CLI_NAME}: ${err && err.message ? err.message : String(err)}\n`);
438
+ exitCode = 1;
413
439
  }
414
-
415
- process.exit(0);
440
+ process.exit(exitCode);
416
441
  }
417
442
 
418
443
  // ---------------------------------------------------------------------------
@@ -421,8 +446,10 @@ if (require.main === module) {
421
446
 
422
447
  module.exports = {
423
448
  renderDecisionsFile,
449
+ renderLearningFiles,
424
450
  renderAndWriteAll,
425
451
  selectActiveRows,
452
+ selectInactiveRows,
426
453
  parseLedger,
427
454
  isActive,
428
455
  anchorNumeric,
@@ -64,6 +64,16 @@ source "$SCRIPT_DIR/get-mtime" || { echo "memory-worker: failed to source get-mt
64
64
  # BEFORE spawning to prevent a second concurrent Stop hook from double-spawning
65
65
  # within the same 120s window.
66
66
  TRIGGER_FILE="$MEMORY_DIR/.working-memory-last-trigger"
67
+
68
+ # D-HOOKS-NO-SYMLINK (git-marker): the stamp below is a `touch`, which follows
69
+ # a symbolic link, so a linked stamp or memory folder is never stamped, and with no
70
+ # stamp to throttle it no worker is spawned either.
71
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$TRIGGER_FILE"; then
72
+ log "SKIP: a symbolic link sits on the path to $TRIGGER_FILE; nothing written, worker not spawned"
73
+ dbg "EXIT: symbolic link on the throttle path"
74
+ exit 0
75
+ fi
76
+
67
77
  NOW=$(date +%s)
68
78
  LAST_TRIGGER=0
69
79
  if [ -f "$TRIGGER_FILE" ]; then