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
@@ -0,0 +1,3207 @@
1
+ // src/assets/scripts/hooks/lib/learning-store.cjs
2
+ //
3
+ // The v2 learning store: the one place the learning plumbing reads, validates,
4
+ // projects and writes the decisions log, the ledger and their side files.
5
+ //
6
+ // DESIGN: plumbing only. No function here prints or calls process.exit. Work that
7
+ // can fail on its input or on the lock returns a Result — { ok: true, value } or
8
+ // { ok: false, error: { kind, message } } — and its caller prints it: json-helper.cjs
9
+ // for the ops, or the `devflow learning` CLI through src/core/learning-store.ts. A throw
10
+ // means a broken invariant or an I/O failure, never an expected outcome; a lock
11
+ // held by withDecisionsLock is released on every path, the throw included.
12
+ //
13
+ // Loading: node built-ins and three sibling libs only. This module never requires
14
+ // decisions-format.cjs or render-decisions.cjs at load time: both require it, for
15
+ // the status list and the one-line and inactive-note text they share with `list`.
16
+ //
17
+ // Files under <root>/.devflow/learning/:
18
+ // decisions-log.jsonl observation rows — the content authority
19
+ // decisions-ledger.jsonl anchored rows — projections of log rows
20
+ // decisions-log.archive.jsonl rotated-out observation rows (D-ROTATE-UNREFERENCED)
21
+ // decisions-history.jsonl prior content versions (D-CONTENT-HISTORY)
22
+ // *.rejected.jsonl quarantined malformed lines (D-QUARANTINE-MALFORMED)
23
+ // *.pre-v2.jsonl one-time copies of the v1 files (D-V1-BACKUP-ONCE)
24
+ // .decisions.lock/ the one learning lock (D-ONE-LEARNING-LOCK)
25
+ // .pending-turns.jsonl the queue the capture hooks append to
26
+ // .pending-turns.processing the claimed batch, and .pending-turns.owner its
27
+ // owner's token (D-OWNED-CLAIM)
28
+ //
29
+ // TS COUNTERPARTS: src/core/observations.ts mirrors the status lists (D201), and
30
+ // src/core/learning-store.ts transcribes the functions the CLI calls from their
31
+ // JSDoc here (D-LEARNING-STORE-SEAM) — change both together.
32
+
33
+ 'use strict';
34
+
35
+ const crypto = require('crypto');
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const { execFileSync } = require('child_process');
39
+
40
+ const {
41
+ getLearningDir,
42
+ getLearningPendingTurnsPath,
43
+ getLearningPendingTurnsProcessingPath,
44
+ getLearningClaimOwnerPath,
45
+ getDecisionsLedgerPath,
46
+ getDecisionsLogPath,
47
+ getDecisionsArchivePath,
48
+ getDecisionsHistoryPath,
49
+ getDecisionsLockDir,
50
+ } = require('./project-paths.cjs');
51
+ const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
52
+ const { safePath } = require('./safe-path.cjs');
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Constants
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** Schema version stamped on every v2 log and ledger row. */
59
+ const SCHEMA_VERSION = 2;
60
+
61
+ /**
62
+ * Field limits. Text limits count characters (code points); `scopeMin`/`scopeMax`
63
+ * and `evidenceMax` count entries. `note`, `quoteMin`, `quoteMax` and `path` bound
64
+ * the status-change inputs.
65
+ */
66
+ const FIELD_LIMITS = Object.freeze({
67
+ title: 120,
68
+ rule: 400,
69
+ why: 300,
70
+ provenance: 120,
71
+ scopeMin: 1,
72
+ scopeMax: 5,
73
+ scopeEntry: 200,
74
+ evidenceMax: 5,
75
+ evidenceItem: 300,
76
+ note: 120,
77
+ quoteMin: 12,
78
+ quoteMax: 200,
79
+ path: 300,
80
+ });
81
+
82
+ /** Statuses of an entry that renders: decisions are Accepted, pitfalls Active. */
83
+ const ACTIVE_STATUSES = Object.freeze(['Accepted', 'Active']);
84
+
85
+ /** Statuses of an entry the ledger keeps but every rendered file and count leaves out. */
86
+ const INACTIVE_STATUSES = Object.freeze(['Encoded', 'Superseded', 'Retired', 'Deprecated']);
87
+
88
+ /** Every status a ledger entry may carry. src/core/observations.ts mirrors it (D201). */
89
+ const ENTRY_STATUSES = Object.freeze([...ACTIVE_STATUSES, ...INACTIVE_STATUSES]);
90
+
91
+ /** An observation's content keys, in the order they are stored (after `id`). */
92
+ const CONTENT_KEYS = Object.freeze(['type', 'title', 'rule', 'why', 'scope', 'provenance', 'evidence']);
93
+
94
+ /** Keys plumbing sets on a log row; an input carrying one is refused (D-PUT-NOT-MERGE). */
95
+ const PLUMBING_OWNED_KEYS = Object.freeze(['schema', 'observations', 'first_seen', 'last_seen', 'status', 'anchor_id']);
96
+
97
+ /** Keys only the ledger holds, carried across every re-projection (D-LOG-CONTENT-AUTHORITY). */
98
+ const LEDGER_OWNED_KEYS = Object.freeze([
99
+ 'date', 'last_verified', 'last_attempt', 'status_note', 'superseded_by', 'encoded_at', 'retired_on',
100
+ ]);
101
+
102
+ /** Maintenance hand-out parameters (D-DUE-ORDER). */
103
+ const DUE = Object.freeze({ verifyAgeDays: 30, leaseHours: 24, maxEntries: 5, byteBudget: 61440 });
104
+
105
+ /** Prior content versions kept per observation id (D-CONTENT-HISTORY). */
106
+ const HISTORY_DEPTH = 3;
107
+
108
+ /** How long a writer waits for .decisions.lock before reporting busy (ms). */
109
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
110
+
111
+ /** Age after which a held .decisions.lock is treated as abandoned and broken (ms). */
112
+ const LOCK_STALE_MS = 60000;
113
+
114
+ /** An anchor id: ADR-NNN or PF-NNN, three or more digits. */
115
+ const ANCHOR_ID_RE = /^(ADR|PF)-\d{3,}$/;
116
+
117
+ /** An observation id. */
118
+ const OBS_ID_RE = /^obs_[a-z0-9_]{3,60}$/;
119
+
120
+ /** Bound on each git call (ms) and on its output (bytes). */
121
+ const GIT_TIMEOUT_MS = 5000;
122
+ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
123
+
124
+ /** A full commit id, SHA-1 or SHA-256. */
125
+ const COMMIT_ID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
126
+
127
+ const HOUR_MS = 60 * 60 * 1000;
128
+ const DAY_MS = 24 * HOUR_MS;
129
+
130
+ /** The content keys a ledger row projects from its log row (evidence stays in the log). */
131
+ const PROJECTED_CONTENT_KEYS = Object.freeze(['type', 'title', 'rule', 'why', 'scope', 'provenance']);
132
+
133
+ /** Keys a create or an update must carry (D-PUT-NOT-MERGE). */
134
+ const REQUIRED_KEYS = Object.freeze(['id', 'type', 'title', 'rule', 'why', 'scope', 'provenance']);
135
+
136
+ const VALIDATION_MODES = Object.freeze(['create', 'update', 'reinforce']);
137
+
138
+ /**
139
+ * C0 and C1 control characters, DEL, the JS line terminators U+2028/U+2029 and the
140
+ * bidirectional formatting controls — none may appear in an input string, and
141
+ * listings collapse them to a space.
142
+ */
143
+ const CONTROL_CHARS_CLASS = '[\\u0000-\\u001f\\u007f-\\u009f\\u200e\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]';
144
+ const CONTROL_CHAR_RE = new RegExp(CONTROL_CHARS_CLASS);
145
+ const CONTROL_RUN_RE = new RegExp(`${CONTROL_CHARS_CLASS}+`, 'g');
146
+
147
+ /**
148
+ * An anchor id written as a whole word in free text: ADR-NNN or PF-NNN, three or
149
+ * more digits. Title, rule and why may not name one the ledger holds, and the
150
+ * cited-number scan collects the ones tracked files cite.
151
+ */
152
+ const ANCHOR_WORD_RE = /\b(?:ADR|PF)-\d{3,}\b/g;
153
+
154
+ /** An issue or PR reference: `#` and digits after the start or a non-word character other than `&`. */
155
+ const ISSUE_REF_RE = /(?:^|[^\w&])#\d+/;
156
+
157
+ /**
158
+ * Source and text file extensions whose `name.ext:line` or `name.ext#Lline` form is
159
+ * a file-and-line reference. A host:port carries none of them, so it passes.
160
+ */
161
+ const LINE_REF_EXTENSIONS = Object.freeze([
162
+ 'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'cjs', 'mjs', 'py', 'go', 'rs', 'java', 'kt', 'rb', 'php',
163
+ 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'swift', 'sh', 'bash', 'zsh', 'md', 'mds', 'mdx', 'json', 'jsonl',
164
+ 'ya?ml', 'toml', 'txt', 'html', 'css', 'scss', 'sql', 'xml',
165
+ ]);
166
+ const FILE_LINE_REF_RE = new RegExp(`[\\w-]\\.(?:${LINE_REF_EXTENSIONS.join('|')})(?::\\d+|#L\\d+)`, 'i');
167
+
168
+ /** A scope area tag. */
169
+ const AREA_TAG_RE = /^area:[a-z0-9][a-z0-9-]{0,39}$/;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Small helpers
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** @param {unknown} value @returns {boolean} */
176
+ function isPlainObject(value) {
177
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
178
+ }
179
+
180
+ /** @param {unknown} value @returns {boolean} */
181
+ function isNonEmptyString(value) {
182
+ return typeof value === 'string' && value.length > 0;
183
+ }
184
+
185
+ /** Length in characters (code points), not UTF-16 code units. */
186
+ function codePointLength(text) {
187
+ let n = 0;
188
+ for (const _ of text) n += 1;
189
+ return n;
190
+ }
191
+
192
+ /** A deep copy of a JSON value, so a returned row never shares structure with its inputs. */
193
+ function copyJson(value) {
194
+ return value === undefined ? undefined : structuredClone(value);
195
+ }
196
+
197
+ /** True when both values serialize to the same JSON. */
198
+ function sameJson(a, b) {
199
+ return JSON.stringify(a) === JSON.stringify(b);
200
+ }
201
+
202
+ /**
203
+ * `text` with each run of control characters collapsed to one space: listings and
204
+ * the rendered files show every field on one line.
205
+ *
206
+ * @param {string} text
207
+ * @returns {string}
208
+ */
209
+ function singleLine(text) {
210
+ return text.replace(CONTROL_RUN_RE, ' ');
211
+ }
212
+
213
+ /** `text` cut to at most `max` characters, the last one `…` when it was cut. */
214
+ function cutTo(text, max) {
215
+ const chars = Array.from(text);
216
+ return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…';
217
+ }
218
+
219
+ /** The anchor prefix rows of `type` take, or null for any other type. */
220
+ function anchorPrefixFor(type) {
221
+ if (type === 'decision') return 'ADR';
222
+ if (type === 'pitfall') return 'PF';
223
+ return null;
224
+ }
225
+
226
+ /** Sort key of an anchor id: decisions before pitfalls, then by number; anything else last. */
227
+ function anchorOrder(anchorId) {
228
+ const m = typeof anchorId === 'string' ? /^(ADR|PF)-(\d+)$/.exec(anchorId) : null;
229
+ if (!m) return [2, Infinity];
230
+ return [m[1] === 'ADR' ? 0 : 1, parseInt(m[2], 10)];
231
+ }
232
+
233
+ /** Comparator for rows by anchor_id (see anchorOrder). */
234
+ function compareByAnchor(a, b) {
235
+ const [rankA, numA] = anchorOrder(a.anchor_id);
236
+ const [rankB, numB] = anchorOrder(b.anchor_id);
237
+ if (rankA !== rankB) return rankA - rankB;
238
+ if (numA !== numB) return numA < numB ? -1 : 1;
239
+ return String(a.anchor_id).localeCompare(String(b.anchor_id));
240
+ }
241
+
242
+ /** Comparator for strings by UTF-16 code unit, the order `<` gives. */
243
+ function compareText(a, b) {
244
+ if (a < b) return -1;
245
+ return a > b ? 1 : 0;
246
+ }
247
+
248
+ /** A sorted copy of `rows`, by anchor. */
249
+ function sortedByAnchor(rows) {
250
+ return [...rows].sort(compareByAnchor);
251
+ }
252
+
253
+ /** Ledger rows that carry an anchor and an active status. */
254
+ function activeAnchoredRows(ledger) {
255
+ return ledger.filter(row => isNonEmptyString(row.anchor_id) && isActive(row));
256
+ }
257
+
258
+ /** `file` with its `.jsonl` extension replaced by `suffix` (appended when it has none). */
259
+ function withJsonlSuffix(file, suffix) {
260
+ return /\.jsonl$/.test(file) ? file.replace(/\.jsonl$/, suffix) : file + suffix;
261
+ }
262
+
263
+ /**
264
+ * Run `git <args>` in `root` and return its stdout. The argv is a literal array,
265
+ * never a shell string, and every call turns `core.fsmonitor` off (D-NO-FSMONITOR,
266
+ * documented at listGitTrackedFiles): git runs the command a repository's config
267
+ * names there whenever it reads the index. Each call is bounded by GIT_TIMEOUT_MS
268
+ * and GIT_MAX_BUFFER, and stderr is discarded.
269
+ *
270
+ * @param {string} root - the directory to run in
271
+ * @param {string[]} args - the git subcommand and its arguments
272
+ * @returns {string}
273
+ * @throws when git is missing or `root` is not a working tree, the call times out or
274
+ * overflows its buffer, or git exits non-zero (the error's `status` is its exit code)
275
+ */
276
+ function git(root, args) {
277
+ return execFileSync('git', ['-c', 'core.fsmonitor=false', ...args], {
278
+ cwd: root,
279
+ timeout: GIT_TIMEOUT_MS,
280
+ maxBuffer: GIT_MAX_BUFFER,
281
+ stdio: ['ignore', 'pipe', 'ignore'],
282
+ encoding: 'utf8',
283
+ });
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Status helpers
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * True when a ledger row renders: its decisions_status is absent, or is anything
292
+ * outside INACTIVE_STATUSES (an unknown status counts as active).
293
+ *
294
+ * @param {{ decisions_status?: unknown }} row
295
+ * @returns {boolean}
296
+ */
297
+ function isActive(row) {
298
+ const status = row.decisions_status;
299
+ return !status || !INACTIVE_STATUSES.includes(status);
300
+ }
301
+
302
+ /**
303
+ * The active status of an entry of `type`: Accepted for a decision, Active for a
304
+ * pitfall. Any other type is a caller error.
305
+ *
306
+ * @param {string} type
307
+ * @returns {'Accepted'|'Active'}
308
+ */
309
+ function activeStatusFor(type) {
310
+ if (type === 'decision') return 'Accepted';
311
+ if (type === 'pitfall') return 'Active';
312
+ throw new Error(`activeStatusFor: type must be 'decision' or 'pitfall', got '${type}'`);
313
+ }
314
+
315
+ /**
316
+ * True for a v2 row (schema 2).
317
+ *
318
+ * @param {unknown} row
319
+ * @returns {boolean}
320
+ */
321
+ function isV2(row) {
322
+ return isPlainObject(row) && row.schema === SCHEMA_VERSION;
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // JSONL I/O
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /** One JSONL line as a row, or undefined when it is not exactly one JSON object. */
330
+ function parseRow(text) {
331
+ try {
332
+ const value = JSON.parse(text);
333
+ return isPlainObject(value) ? value : undefined;
334
+ } catch {
335
+ return undefined;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The first `size` bytes of the open file `fd` as UTF-8 text, fewer when the file
341
+ * ends sooner: the read never takes more than `size` bytes.
342
+ *
343
+ * @param {number} fd
344
+ * @param {number} size - the byte count fstat reported for `fd`
345
+ * @returns {string}
346
+ */
347
+ function readOpenedText(fd, size) {
348
+ const buf = Buffer.alloc(size);
349
+ let total = 0;
350
+ while (total < buf.length) {
351
+ const read = fs.readSync(fd, buf, total, buf.length - total, total);
352
+ if (read === 0) break;
353
+ total += read;
354
+ }
355
+ return buf.toString('utf8', 0, total);
356
+ }
357
+
358
+ /**
359
+ * The text of `file`, or null when nothing is there, when the file is anything
360
+ * but a regular file — a symbolic link, a directory, a FIFO or a device — or when
361
+ * it is larger than `maxBytes` (D-NO-LINKED-READ, at readJsonl). lstat decides
362
+ * before anything is opened, seeing a link without following it. The open never
363
+ * follows a link (O_NOFOLLOW) and never waits on a FIFO (O_NONBLOCK), and fstat
364
+ * confirms that what it opened is a regular file within the bound: a link that
365
+ * took the file's place after the lstat fails the read rather than being read
366
+ * through, and anything else that did reads as absent. The read never takes more
367
+ * bytes than fstat reported.
368
+ *
369
+ * @param {string} file - an absolute path
370
+ * @param {{ maxBytes?: number }} [opts] - maxBytes: the largest file read (default no cap)
371
+ * @returns {string|null}
372
+ * @throws on any other read error
373
+ */
374
+ function readTextUnlinked(file, { maxBytes = Infinity } = {}) {
375
+ let fd;
376
+ try {
377
+ const stat = fs.lstatSync(file);
378
+ if (!stat.isFile() || stat.size > maxBytes) return null;
379
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
380
+ } catch (err) {
381
+ if (err && err.code === 'ENOENT') return null;
382
+ throw err;
383
+ }
384
+ try {
385
+ const stat = fs.fstatSync(fd);
386
+ return stat.isFile() && stat.size <= maxBytes ? readOpenedText(fd, stat.size) : null;
387
+ } finally {
388
+ fs.closeSync(fd);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Read a JSONL file strictly: every non-blank line is either one JSON object (a
394
+ * row) or rejected, with its 1-based line number and its text.
395
+ *
396
+ * D-QUARANTINE-MALFORMED: a line that is not one JSON object is never read as a
397
+ * row and never silently dropped. readJsonl returns it among `rejected`; a writer
398
+ * appends it to the file's `.rejected.jsonl` sibling before rewriting the file
399
+ * (readJsonlForWrite), and a read-only path only reports it and writes nothing.
400
+ * Reason: a reader that skips malformed lines lets the next whole-file rewrite
401
+ * delete them without a trace, and a reader that quarantined would make list,
402
+ * show and the HUD write files.
403
+ *
404
+ * D-NO-LINKED-READ: a learning file that is itself a symbolic link reads as
405
+ * missing, and nothing is read through it; the pre-v2 backup skips one the same
406
+ * way (ensurePreV2Backup), and release-claim reads the claim's owner file the same
407
+ * way, and only up to CLAIM_OWNER_MAX_BYTES (readClaimOwner). readJsonl and
408
+ * readClaimOwner read a regular file alone (readTextUnlinked): a directory, a FIFO
409
+ * or a device where the file belongs reads as missing too, and neither waits on
410
+ * one. Reason: a repository can commit any learning file as a link to a file
411
+ * elsewhere on the machine, and a read that followed it would put that file's
412
+ * lines into list and show and, through a rewrite, the quarantine or a render,
413
+ * into the project's learning folder; a read that followed one to a FIFO or to
414
+ * /dev/zero would wait forever or fill memory, holding the learning lock when a
415
+ * writer or release-claim reads. The writers never write through a link either:
416
+ * a rename replaces one, and an append refuses one.
417
+ *
418
+ * @param {string} file
419
+ * @returns {{ rows: object[], rejected: Array<{ line: number, text: string }>, missing: boolean }}
420
+ * `missing` is true when the file does not exist, or is a symbolic link or
421
+ * anything else but a regular file. Any other read error is thrown.
422
+ */
423
+ function readJsonl(file) {
424
+ const raw = readTextUnlinked(safePath(file));
425
+ if (raw === null) return { rows: [], rejected: [], missing: true };
426
+ const rows = [];
427
+ const rejected = [];
428
+ const lines = raw.split('\n');
429
+ for (let i = 0; i < lines.length; i++) {
430
+ const text = lines[i];
431
+ if (text.trim() === '') continue;
432
+ const row = parseRow(text);
433
+ if (row === undefined) rejected.push({ line: i + 1, text });
434
+ else rows.push(row);
435
+ }
436
+ return { rows, rejected, missing: false };
437
+ }
438
+
439
+ /**
440
+ * The quarantine file for `file`: `decisions-log.jsonl` → `decisions-log.rejected.jsonl`.
441
+ *
442
+ * @param {string} file
443
+ * @returns {string}
444
+ */
445
+ function rejectedPathFor(file) {
446
+ return withJsonlSuffix(file, '.rejected.jsonl');
447
+ }
448
+
449
+ /**
450
+ * Append to `file`, creating it when absent. O_NOFOLLOW refuses a symlink planted
451
+ * at `file` (ELOOP) instead of writing through it.
452
+ */
453
+ function appendNoFollow(file, content) {
454
+ const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
455
+ const fd = fs.openSync(file, flags, 0o666);
456
+ try {
457
+ fs.writeFileSync(fd, content);
458
+ } finally {
459
+ fs.closeSync(fd);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Append each rejected line of `file` to its quarantine file as one record
465
+ * `{ rejected_at, source, line, text }` (D-QUARANTINE-MALFORMED). Append-only;
466
+ * writes nothing when nothing was rejected.
467
+ *
468
+ * @param {string} file - the file the lines were read from
469
+ * @param {Array<{ line: number, text: string }>} rejected
470
+ * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
471
+ * @returns {number} the number of records appended
472
+ */
473
+ function quarantineRejected(file, rejected, { now = Date.now() } = {}) {
474
+ if (rejected.length === 0) return 0;
475
+ const rejectedAt = new Date(now).toISOString();
476
+ const source = path.basename(file);
477
+ const content = rejected
478
+ .map(r => JSON.stringify({ rejected_at: rejectedAt, source, line: r.line, text: r.text }))
479
+ .join('\n') + '\n';
480
+ appendNoFollow(rejectedPathFor(file), content);
481
+ return rejected.length;
482
+ }
483
+
484
+ /**
485
+ * Read `file` for a rewrite: quarantine its malformed lines first, then return
486
+ * its rows (D-QUARANTINE-MALFORMED). Call it only on a path that rewrites the
487
+ * file — a read-only path uses readJsonl.
488
+ *
489
+ * @param {string} file
490
+ * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
491
+ * @returns {object[]}
492
+ */
493
+ function readJsonlForWrite(file, { now = Date.now() } = {}) {
494
+ const { rows, rejected } = readJsonl(file);
495
+ quarantineRejected(file, rejected, { now });
496
+ return rows;
497
+ }
498
+
499
+ /**
500
+ * Write `tmp` with O_EXCL (wx flag) so the kernel rejects the open if a file or
501
+ * symlink already exists at that path, preventing TOCTOU symlink-follow attacks.
502
+ * On EEXIST (stale or attacker-placed .tmp) it unlinks and retries once.
503
+ *
504
+ * @param {string} tmp - Path to the temporary file.
505
+ * @param {string} content - Content to write.
506
+ */
507
+ function writeExclusive(tmp, content) {
508
+ try {
509
+ fs.writeFileSync(tmp, content, { flag: 'wx' });
510
+ } catch (err) {
511
+ if (err.code !== 'EEXIST') throw err;
512
+ // Stale or attacker-placed .tmp — remove it and retry once.
513
+ try { fs.unlinkSync(tmp); } catch { /* race — already removed */ }
514
+ fs.writeFileSync(tmp, content, { flag: 'wx' });
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Atomically write a text file via a PID-scoped .tmp sibling and rename, so
520
+ * concurrent writers from different processes never collide on one .tmp path.
521
+ *
522
+ * @param {string} file
523
+ * @param {string} content
524
+ */
525
+ function writeFileAtomic(file, content) {
526
+ const tmp = file + '.tmp.' + process.pid;
527
+ writeExclusive(tmp, content);
528
+ fs.renameSync(tmp, file);
529
+ }
530
+
531
+ /**
532
+ * Atomically write rows as JSONL: one row per line with a trailing newline, or an
533
+ * empty file for no rows.
534
+ *
535
+ * @param {string} file
536
+ * @param {object[]} rows
537
+ */
538
+ function writeJsonlAtomic(file, rows) {
539
+ const content = rows.length > 0 ? rows.map(r => JSON.stringify(r)).join('\n') + '\n' : '';
540
+ writeFileAtomic(file, content);
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Locking
545
+ // ---------------------------------------------------------------------------
546
+
547
+ /**
548
+ * True when `<root>/.devflow/learning` exists and is a directory.
549
+ *
550
+ * @param {string} root - project root
551
+ * @returns {boolean}
552
+ */
553
+ function hasLearningDir(root) {
554
+ try {
555
+ return fs.statSync(getLearningDir(root)).isDirectory();
556
+ } catch (err) {
557
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
558
+ throw err;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * The error Result of an op run where `<root>/.devflow/learning/` is absent
564
+ * (D-NO-STRAY-TREE).
565
+ *
566
+ * @param {string} opName - operation name, for the message
567
+ * @param {string} root - project root
568
+ * @returns {{ ok: false, error: { kind: 'no-learning-dir', message: string } }}
569
+ */
570
+ function noLearningDir(opName, root) {
571
+ return {
572
+ ok: false,
573
+ error: { kind: 'no-learning-dir', message: `${opName}: no .devflow/learning/ under ${root} — run from the project root` },
574
+ };
575
+ }
576
+
577
+ /** True when `file` is itself a symbolic link; false for anything else, or nothing, there. */
578
+ function isSymbolicLink(file) {
579
+ try {
580
+ return fs.lstatSync(file).isSymbolicLink();
581
+ } catch (err) {
582
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
583
+ throw err;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * The first of `<root>/.devflow` and `<root>/.devflow/learning` that is itself a
589
+ * symbolic link, or null when neither is (D-NO-LINKED-TREE).
590
+ *
591
+ * @param {string} root - project root
592
+ * @returns {string|null}
593
+ */
594
+ function linkedLearningFolder(root) {
595
+ const learningDir = getLearningDir(root);
596
+ for (const dir of [path.dirname(learningDir), learningDir]) {
597
+ if (isSymbolicLink(dir)) return dir;
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * The error Result of an op run where `.devflow` or `.devflow/learning` under its
604
+ * root is a symbolic link (D-NO-LINKED-TREE).
605
+ *
606
+ * @param {string} opName - operation name, for the message
607
+ * @param {string} dir - the folder that is a link
608
+ * @returns {{ ok: false, error: { kind: 'not-a-directory', message: string } }}
609
+ */
610
+ function linkedFolder(opName, dir) {
611
+ return {
612
+ ok: false,
613
+ error: { kind: 'not-a-directory', message: `${opName}: ${dir} is a symbolic link, not a directory; nothing was changed` },
614
+ };
615
+ }
616
+
617
+ /** True for a Result: `{ ok: true, … }` or `{ ok: false, error: { … } }`. */
618
+ function isResult(value) {
619
+ return isPlainObject(value) && (value.ok === true || (value.ok === false && isPlainObject(value.error)));
620
+ }
621
+
622
+ /**
623
+ * Run `fn` under `.decisions.lock` and return the Result it returns, unchanged.
624
+ * The lock is released on every path, a throw from `fn` included; the throw then
625
+ * reaches the caller.
626
+ *
627
+ * D-ONE-LEARNING-LOCK: learning writers take one lock, `.decisions.lock`, through
628
+ * this wrapper — every write to the log, the ledger, their side files and the
629
+ * rendered files happens under it. Reason: two writers under two locks can each
630
+ * read one file, change it and rename their copy over it, and the second rename
631
+ * silently discards the first one's change.
632
+ *
633
+ * D-NO-STRAY-TREE: a learning writer refuses with `no-learning-dir` when
634
+ * `.devflow/learning/` is absent under its root, and the store never creates that
635
+ * directory or its parent — the lock directory is the only thing made inside it.
636
+ * Reason: a writer run from the wrong directory would otherwise create a learning
637
+ * tree there and write a ledger that no session ever reads.
638
+ *
639
+ * D-NO-LINKED-TREE: a learning writer refuses with `not-a-directory`, changing
640
+ * nothing, when `.devflow` or `.devflow/learning` under its root is a symbolic
641
+ * link. Reason: a repository can commit either one as a link to a folder elsewhere
642
+ * on the machine, and a writer that followed it would take its lock there and
643
+ * rewrite, quarantine, archive, render or delete files in whatever folder the link
644
+ * names. Refused here, before the lock is taken, so every writer refuses in one
645
+ * place; the claim heartbeat makes the same check, and read-only paths still read.
646
+ *
647
+ * @param {string} opName - operation name, for messages
648
+ * @param {string} root - project root
649
+ * @param {() => { ok: boolean }} fn - the locked body; it must return a Result
650
+ * @param {{ timeoutMs?: number, staleMs?: number }} [opts]
651
+ * @returns {{ ok: true, value?: unknown } | { ok: false, error: { kind: string, message: string } }}
652
+ * fn's Result, or an error of kind `not-a-directory`, `no-learning-dir` or `busy`.
653
+ */
654
+ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
655
+ const linked = linkedLearningFolder(root);
656
+ if (linked !== null) return linkedFolder(opName, linked);
657
+ if (!hasLearningDir(root)) return noLearningDir(opName, root);
658
+ const lockDir = getDecisionsLockDir(root);
659
+ let acquired;
660
+ try {
661
+ acquired = acquireMkdirLock(lockDir, timeoutMs, staleMs);
662
+ } catch (err) {
663
+ // The learning directory went away between the check and the mkdir.
664
+ if (err && err.code === 'ENOENT') return noLearningDir(opName, root);
665
+ throw err;
666
+ }
667
+ if (!acquired) {
668
+ return { ok: false, error: { kind: 'busy', message: `${opName}: timeout acquiring lock at ${lockDir}` } };
669
+ }
670
+ try {
671
+ const result = fn();
672
+ if (!isResult(result)) {
673
+ throw new TypeError(`${opName}: the locked body must return a Result ({ ok: true, value } or { ok: false, error })`);
674
+ }
675
+ return result;
676
+ } finally {
677
+ releaseLock(lockDir);
678
+ }
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // State and the ledger registry
683
+ // ---------------------------------------------------------------------------
684
+
685
+ /**
686
+ * Read the ledger and the log, read-only: malformed lines are reported, never
687
+ * quarantined (D-QUARANTINE-MALFORMED). An absent file, or one that is a symbolic
688
+ * link or not a regular file (D-NO-LINKED-READ), reads as empty.
689
+ *
690
+ * @param {string} root - project root
691
+ * @returns {{ ledgerRows: object[], logRows: object[], rejected: { ledger: Array<{ line: number, text: string }>, log: Array<{ line: number, text: string }> } }}
692
+ */
693
+ function readLearningState(root) {
694
+ const ledger = readJsonl(getDecisionsLedgerPath(root));
695
+ const log = readJsonl(getDecisionsLogPath(root));
696
+ return {
697
+ ledgerRows: ledger.rows,
698
+ logRows: log.rows,
699
+ rejected: { ledger: ledger.rejected, log: log.rejected },
700
+ };
701
+ }
702
+
703
+ /**
704
+ * Index ledger rows by anchor and by observation id.
705
+ *
706
+ * D-LEDGER-REGISTRY: the ledger alone records what is promoted. An observation is
707
+ * anchored when any ledger row carries its id, and an anchor is taken when any
708
+ * ledger row carries it, whatever the log row says; lookups go through this
709
+ * registry, never through an anchor_id copied onto a log row. Reason: a guard that
710
+ * read the log row's anchor_id had no writer for most anchored rows, so one
711
+ * observation was promoted twice under two numbers.
712
+ *
713
+ * @param {object[]} ledgerRows
714
+ * @returns {{ byAnchor: Map<string, object>, byObsId: Map<string, object[]> }}
715
+ * byAnchor keeps the first row of a repeated anchor; byObsId lists every row
716
+ * carrying the id, in ledger order.
717
+ */
718
+ function ledgerRegistry(ledgerRows) {
719
+ const byAnchor = new Map();
720
+ const byObsId = new Map();
721
+ for (const row of ledgerRows) {
722
+ if (isNonEmptyString(row.anchor_id) && !byAnchor.has(row.anchor_id)) byAnchor.set(row.anchor_id, row);
723
+ if (isNonEmptyString(row.id)) {
724
+ const carriers = byObsId.get(row.id);
725
+ if (carriers) carriers.push(row);
726
+ else byObsId.set(row.id, [row]);
727
+ }
728
+ }
729
+ return { byAnchor, byObsId };
730
+ }
731
+
732
+ /**
733
+ * A predicate for the log rows some ledger row carries, whatever that row's status
734
+ * (D-LEDGER-REGISTRY). A log row with no id is carried by none.
735
+ *
736
+ * @param {object[]} ledgerRows
737
+ * @returns {(logRow: object) => boolean}
738
+ */
739
+ function carriedBy(ledgerRows) {
740
+ const { byObsId } = ledgerRegistry(ledgerRows);
741
+ return logRow => isNonEmptyString(logRow.id) && byObsId.has(logRow.id);
742
+ }
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // Observation validation
746
+ // ---------------------------------------------------------------------------
747
+
748
+ /** Why a key outside the accepted set is refused. */
749
+ function keyRefusal(key, mode) {
750
+ if (PLUMBING_OWNED_KEYS.includes(key)) return 'is set by plumbing, never by the caller';
751
+ if (LEDGER_OWNED_KEYS.includes(key)) return 'is held by the ledger, never by an observation';
752
+ if (mode === 'reinforce' && CONTENT_KEYS.includes(key)) return 'is not taken by a reinforce, which carries the id alone';
753
+ return 'is not a known key';
754
+ }
755
+
756
+ /**
757
+ * The problem that keeps a value from being one line of text, or null: not a
758
+ * string, blank, or holding a control character. Such a value is judged no further.
759
+ */
760
+ function lineTextProblem(value) {
761
+ if (typeof value !== 'string') return 'must be a string';
762
+ if (value.trim() === '') return 'must not be blank';
763
+ if (CONTROL_CHAR_RE.test(value)) return 'must be one line with no control characters';
764
+ return null;
765
+ }
766
+
767
+ /** The problem with a text value's length in characters, or null. */
768
+ function lengthProblem(value, limit) {
769
+ const length = codePointLength(value);
770
+ return length > limit ? `is ${length} characters, over the limit of ${limit}` : null;
771
+ }
772
+
773
+ /** The first problem with a text value, or null: lineTextProblem's, then its length. */
774
+ function textProblem(value, limit) {
775
+ return lineTextProblem(value) || lengthProblem(value, limit);
776
+ }
777
+
778
+ /**
779
+ * Every problem with title, rule or why prose that rots, one per kind found: an
780
+ * anchor the ledger holds, an issue reference, a file-and-line reference.
781
+ *
782
+ * @param {string} value
783
+ * @param {Set<string>} ledgerIds - every anchor in the ledger
784
+ * @returns {string[]} in that order
785
+ */
786
+ function proseProblems(value, ledgerIds) {
787
+ const problems = [];
788
+ const named = (value.match(ANCHOR_WORD_RE) || []).find(anchor => ledgerIds.has(anchor));
789
+ if (named) problems.push(`names ledger entry ${named}; state the rule in words`);
790
+ if (ISSUE_REF_RE.test(value)) problems.push('carries an issue reference; state what it established instead');
791
+ if (FILE_LINE_REF_RE.test(value)) problems.push('carries a file-and-line reference; name the function or quote the line instead');
792
+ return problems;
793
+ }
794
+
795
+ /**
796
+ * Every problem with a title, rule or why: a value that is not one line of text
797
+ * reports that alone; otherwise its length and each of its proseProblems.
798
+ *
799
+ * @param {unknown} value
800
+ * @param {number} limit
801
+ * @param {Set<string>} ledgerIds
802
+ * @returns {string[]}
803
+ */
804
+ function proseFieldProblems(value, limit, ledgerIds) {
805
+ const notText = lineTextProblem(value);
806
+ if (notText) return [notText];
807
+ const tooLong = lengthProblem(value, limit);
808
+ return [...(tooLong ? [tooLong] : []), ...proseProblems(value, ledgerIds)];
809
+ }
810
+
811
+ /** The problem with a glob's shape, or null. */
812
+ function globShapeProblem(glob) {
813
+ if (glob.startsWith('/') || glob.startsWith(':')) return 'must be relative to the repository root, with no pathspec magic';
814
+ if (/[\s`|]/.test(glob)) return 'must not contain whitespace, a backtick or |';
815
+ if (glob.split('/').includes('..')) return 'must not contain a .. segment';
816
+ return null;
817
+ }
818
+
819
+ /** The problem with one scope entry, or null. git is asked only about a well-shaped glob. */
820
+ function scopeEntryProblem(entry, scopeMatches) {
821
+ const text = textProblem(entry, FIELD_LIMITS.scopeEntry);
822
+ if (text) return text;
823
+ if (entry.startsWith('area:')) {
824
+ return AREA_TAG_RE.test(entry)
825
+ ? null
826
+ : 'is not an area tag: area: then a lowercase letter or digit and up to 39 lowercase letters, digits or hyphens';
827
+ }
828
+ return globShapeProblem(entry) || (scopeMatches(entry) ? null : 'matches no tracked file');
829
+ }
830
+
831
+ /** Errors for the scope list. */
832
+ function scopeErrors(scope, scopeMatches) {
833
+ if (!Array.isArray(scope)) return [{ field: 'scope', message: 'must be an array of area tags and globs' }];
834
+ const { scopeMin, scopeMax } = FIELD_LIMITS;
835
+ if (scope.length < scopeMin || scope.length > scopeMax) {
836
+ return [{ field: 'scope', message: `holds ${scope.length} entries; it takes ${scopeMin} to ${scopeMax}` }];
837
+ }
838
+ const errors = [];
839
+ scope.forEach((entry, i) => {
840
+ const problem = scopeEntryProblem(entry, scopeMatches);
841
+ if (problem) errors.push({ field: `scope[${i}]`, message: problem });
842
+ });
843
+ return errors;
844
+ }
845
+
846
+ /** Errors for the optional evidence list. */
847
+ function evidenceErrors(evidence) {
848
+ if (evidence === undefined) return [];
849
+ if (!Array.isArray(evidence)) return [{ field: 'evidence', message: 'must be an array of quotes' }];
850
+ if (evidence.length > FIELD_LIMITS.evidenceMax) {
851
+ return [{ field: 'evidence', message: `holds ${evidence.length} items, over the limit of ${FIELD_LIMITS.evidenceMax}` }];
852
+ }
853
+ const errors = [];
854
+ evidence.forEach((item, i) => {
855
+ const problem = textProblem(item, FIELD_LIMITS.evidenceItem);
856
+ if (problem) errors.push({ field: `evidence[${i}]`, message: problem });
857
+ });
858
+ return errors;
859
+ }
860
+
861
+ /**
862
+ * Validate one put-observation input and report every problem at once.
863
+ *
864
+ * D-PUT-NOT-MERGE: a create or an update carries the whole content — the id and
865
+ * every CONTENT_KEYS key it needs — and the stored row is exactly that content
866
+ * plus the counters plumbing keeps; an update replaces the content and never
867
+ * merges with the prior row. A key plumbing owns (PLUMBING_OWNED_KEYS), a key the
868
+ * ledger owns (LEDGER_OWNED_KEYS) or any other unknown key is refused, and a
869
+ * reinforce carries the id alone. Reason: a merge keeps whatever the new content
870
+ * no longer says, so a stale clause outlives every rewrite, and an input that sets
871
+ * a counter, a status or an anchor would let the writer forge plumbing state.
872
+ *
873
+ * Field rules: text is one line with no control characters, not blank, and within
874
+ * FIELD_LIMITS. Title, rule and why may not name an anchor the ledger holds, carry
875
+ * an issue reference (`#` and digits after a non-word character other than `&`) or
876
+ * carry a file-and-line reference; provenance and evidence record where a lesson
877
+ * came from and may cite all three. A scope entry is an area tag or a glob that is
878
+ * relative, has no `..` segment, whitespace, backtick or `|`, and matches at least
879
+ * one tracked file. A title, rule or why that is one line of text reports its
880
+ * length and every kind of reference it holds together, so one retry can fix them
881
+ * all; any other field, and text that is not one line, reports its first problem.
882
+ *
883
+ * @param {unknown} input - the parsed stdin object
884
+ * @param {{
885
+ * mode: 'create'|'update'|'reinforce',
886
+ * existing?: object|null,
887
+ * ledgerIds?: Iterable<string>,
888
+ * scopeMatches?: (glob: string) => boolean,
889
+ * }} opts
890
+ * existing — the log row with the input's id, or null; ledgerIds — every anchor
891
+ * in the ledger; scopeMatches — required for create and update.
892
+ * @returns {{ ok: true, value: object } | { ok: false, errors: Array<{ field: string, message: string }> }}
893
+ * value is the content in canonical key order (id, then CONTENT_KEYS), or `{ id }`
894
+ * for a reinforce. Errors come key refusals first, then by field in that order;
895
+ * a title, rule or why gives its length first, then a named anchor, an issue
896
+ * reference and a file-and-line reference.
897
+ */
898
+ function validateObservationInput(input, { mode, existing = null, ledgerIds = [], scopeMatches } = {}) {
899
+ if (!VALIDATION_MODES.includes(mode)) {
900
+ throw new TypeError(`validateObservationInput: mode must be one of ${VALIDATION_MODES.join(', ')}, got '${mode}'`);
901
+ }
902
+ if (mode !== 'reinforce' && typeof scopeMatches !== 'function') {
903
+ throw new TypeError('validateObservationInput: a create or an update needs scopeMatches');
904
+ }
905
+ if (!isPlainObject(input)) return { ok: false, errors: [{ field: '(input)', message: 'must be one JSON object' }] };
906
+
907
+ const errors = [];
908
+ const accepted = mode === 'reinforce' ? ['id'] : ['id', ...CONTENT_KEYS];
909
+ for (const key of Object.keys(input)) {
910
+ if (!accepted.includes(key)) errors.push({ field: key, message: keyRefusal(key, mode) });
911
+ }
912
+
913
+ const present = key => input[key] !== undefined;
914
+ const required = mode === 'reinforce' ? ['id'] : REQUIRED_KEYS;
915
+ const fieldError = (field, problem) => { if (problem) errors.push({ field, message: problem }); };
916
+ const missing = key => (required.includes(key) && !present(key) ? 'is required' : null);
917
+
918
+ const idValid = typeof input.id === 'string' && OBS_ID_RE.test(input.id);
919
+ fieldError('id', missing('id') || (idValid ? null : 'must be obs_ and then 3 to 60 lowercase letters, digits or underscores'));
920
+ if (idValid && mode === 'create' && existing) fieldError('id', 'is already in the log; update it instead');
921
+ if (idValid && mode !== 'create' && !existing) fieldError('id', 'is not in the log');
922
+
923
+ if (mode !== 'reinforce') {
924
+ const typeValid = input.type === 'decision' || input.type === 'pitfall';
925
+ fieldError('type', missing('type') || (typeValid ? null : "must be 'decision' or 'pitfall'"));
926
+ if (typeValid && mode === 'update' && existing && existing.type !== input.type) {
927
+ fieldError('type', `cannot change from '${existing.type}' to '${input.type}'`);
928
+ }
929
+
930
+ const ledgerIdSet = new Set(ledgerIds);
931
+ for (const field of ['title', 'rule', 'why']) {
932
+ const problems = present(field)
933
+ ? proseFieldProblems(input[field], FIELD_LIMITS[field], ledgerIdSet)
934
+ : [missing(field)];
935
+ for (const problem of problems) fieldError(field, problem);
936
+ }
937
+ const scopeMissing = missing('scope');
938
+ if (scopeMissing) fieldError('scope', scopeMissing);
939
+ else errors.push(...scopeErrors(input.scope, scopeMatches));
940
+ fieldError('provenance', missing('provenance') || (present('provenance')
941
+ ? textProblem(input.provenance, FIELD_LIMITS.provenance)
942
+ : null));
943
+ errors.push(...evidenceErrors(input.evidence));
944
+ }
945
+
946
+ if (errors.length > 0) return { ok: false, errors };
947
+ const value = { id: input.id };
948
+ if (mode !== 'reinforce') {
949
+ for (const key of CONTENT_KEYS) {
950
+ if (present(key)) value[key] = copyJson(input[key]);
951
+ }
952
+ }
953
+ return { ok: true, value };
954
+ }
955
+
956
+ /**
957
+ * A memoized `(glob) => boolean` answering whether `glob` matches at least one
958
+ * file git tracks under `root` (`git ls-files -- ':(glob)<glob>'`). Each glob is
959
+ * asked once per matcher. Any git failure — not a repository, a timeout — answers
960
+ * false, so a scope that cannot be checked is refused, never accepted.
961
+ *
962
+ * @param {string} root - project root
963
+ * @returns {(glob: string) => boolean}
964
+ */
965
+ function gitScopeMatcher(root) {
966
+ const answers = new Map();
967
+ const tracks = glob => {
968
+ if (!isNonEmptyString(glob)) return false;
969
+ let out;
970
+ try {
971
+ out = git(root, ['ls-files', '-z', '--', ':(glob)' + glob]);
972
+ } catch {
973
+ return false;
974
+ }
975
+ return out.split('\0').some(name => name !== '');
976
+ };
977
+ return glob => {
978
+ if (!answers.has(glob)) answers.set(glob, tracks(glob));
979
+ return answers.get(glob);
980
+ };
981
+ }
982
+
983
+ // ---------------------------------------------------------------------------
984
+ // Projection and counters
985
+ // ---------------------------------------------------------------------------
986
+
987
+ /**
988
+ * Project a v2 log row into its ledger row.
989
+ *
990
+ * D-LOG-CONTENT-AUTHORITY: the observation log is the one home of an entry's
991
+ * content — its type, title, rule, why, scope and provenance. A ledger row is a
992
+ * projection of its log row, built by this function alone, at promotion and at
993
+ * every re-projection; nothing else writes entry content into the ledger. The
994
+ * ledger owns what is about the entry rather than in it: the anchor number,
995
+ * decisions_status and the LEDGER_OWNED_KEYS (the promotion date, last_verified,
996
+ * last_attempt, the status note, superseded_by, encoded_at and retired_on), which
997
+ * carry over from the prior ledger row unless the caller sets them. Reason: a
998
+ * ledger row copied once at promotion silently lost every later sharpening of its
999
+ * entry, and content kept in two places leaves two authorities that disagree.
1000
+ *
1001
+ * Key order: schema, id, type, anchor_id, decisions_status, title, rule, why,
1002
+ * scope, provenance, then the LEDGER_OWNED_KEYS present. Evidence and the
1003
+ * counters stay in the log; a prior v1 row's pattern, details, amendments and any
1004
+ * other key are dropped.
1005
+ *
1006
+ * @param {object} logRow - a v2 log row
1007
+ * @param {object|null|undefined} priorLedgerRow - the entry's current ledger row, or none at promotion
1008
+ * @param {{ anchorId?: string, status?: string, date?: string, expectType?: string }} [opts]
1009
+ * anchorId and status default to the prior row's; date, when given, replaces it.
1010
+ * @returns {object} a new ledger row
1011
+ * @throws when the log row is not v2, its type differs from expectType, the anchor
1012
+ * is malformed or belongs to the other type, or the status is not an entry status
1013
+ */
1014
+ function toLedgerRowV2(logRow, priorLedgerRow, { anchorId, status, date, expectType } = {}) {
1015
+ if (!isV2(logRow)) throw new Error(`toLedgerRowV2: log row '${logRow && logRow.id}' is not a v2 row`);
1016
+ const prior = priorLedgerRow || {};
1017
+ const anchor = anchorId !== undefined ? anchorId : prior.anchor_id;
1018
+ if (expectType !== undefined && logRow.type !== expectType) {
1019
+ throw new Error(`toLedgerRowV2: type mismatch for ${anchor} — ledger has '${expectType}', log has '${logRow.type}'`);
1020
+ }
1021
+ if (typeof anchor !== 'string' || !ANCHOR_ID_RE.test(anchor)) {
1022
+ throw new Error(`toLedgerRowV2: '${anchor}' is not an anchor id`);
1023
+ }
1024
+ if (!anchor.startsWith(`${anchorPrefixFor(logRow.type)}-`)) {
1025
+ throw new Error(`toLedgerRowV2: anchor ${anchor} does not belong to a ${logRow.type} row`);
1026
+ }
1027
+ const entryStatus = status !== undefined ? status : prior.decisions_status;
1028
+ if (!ENTRY_STATUSES.includes(entryStatus)) {
1029
+ throw new Error(`toLedgerRowV2: '${entryStatus}' is not an entry status`);
1030
+ }
1031
+
1032
+ const row = {
1033
+ schema: SCHEMA_VERSION,
1034
+ id: logRow.id,
1035
+ type: logRow.type,
1036
+ anchor_id: anchor,
1037
+ decisions_status: entryStatus,
1038
+ };
1039
+ for (const key of PROJECTED_CONTENT_KEYS) {
1040
+ if (key !== 'type' && logRow[key] !== undefined) row[key] = copyJson(logRow[key]);
1041
+ }
1042
+ for (const key of LEDGER_OWNED_KEYS) {
1043
+ const value = key === 'date' && date !== undefined ? date : prior[key];
1044
+ if (value !== undefined) row[key] = copyJson(value);
1045
+ }
1046
+ return row;
1047
+ }
1048
+
1049
+ /**
1050
+ * `row` with the ledger-owned fields in `updates` set; a field set to undefined is
1051
+ * removed. The other keys keep their order, and the ledger-owned keys follow them
1052
+ * in LEDGER_OWNED_KEYS order, the order toLedgerRowV2 writes, so a row two
1053
+ * writers have changed serializes the same as the projection would and a
1054
+ * whole-row comparison still means "nothing changed".
1055
+ *
1056
+ * @param {object} row - a ledger row
1057
+ * @param {Record<string, unknown>} updates - ledger-owned fields only
1058
+ * @returns {object} a new row
1059
+ * @throws {TypeError} when `updates` names a key the ledger does not own
1060
+ */
1061
+ function withLedgerFields(row, updates) {
1062
+ for (const key of Object.keys(updates)) {
1063
+ if (!LEDGER_OWNED_KEYS.includes(key)) throw new TypeError(`withLedgerFields: '${key}' is not a ledger-owned field`);
1064
+ }
1065
+ const next = {};
1066
+ for (const [key, value] of Object.entries(row)) {
1067
+ if (!LEDGER_OWNED_KEYS.includes(key)) next[key] = copyJson(value);
1068
+ }
1069
+ for (const key of LEDGER_OWNED_KEYS) {
1070
+ const value = Object.prototype.hasOwnProperty.call(updates, key) ? updates[key] : row[key];
1071
+ if (value !== undefined) next[key] = copyJson(value);
1072
+ }
1073
+ return next;
1074
+ }
1075
+
1076
+ /** `value` when it is a positive integer, else undefined. */
1077
+ function positiveInteger(value) {
1078
+ return Number.isInteger(value) && value >= 1 ? value : undefined;
1079
+ }
1080
+
1081
+ /** `value` when it is a string that parses as a date, else undefined. */
1082
+ function timestamp(value) {
1083
+ return isNonEmptyString(value) && !Number.isNaN(Date.parse(value)) ? value : undefined;
1084
+ }
1085
+
1086
+ /**
1087
+ * The counters a v2 log row carries, derived from any row: `observations` falls
1088
+ * back to `count`, then 1; `first_seen` falls back to `created`, then `last_seen`,
1089
+ * then now; `last_seen` falls back to the resolved `first_seen`. A value that is
1090
+ * not a positive integer or a parseable date counts as absent.
1091
+ *
1092
+ * @param {object} row
1093
+ * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
1094
+ * @returns {{ observations: number, first_seen: string, last_seen: string }}
1095
+ */
1096
+ function toV2Counters(row, { now = Date.now() } = {}) {
1097
+ const firstSeen = timestamp(row.first_seen) || timestamp(row.created) || timestamp(row.last_seen)
1098
+ || new Date(now).toISOString();
1099
+ return {
1100
+ observations: positiveInteger(row.observations) || positiveInteger(row.count) || 1,
1101
+ first_seen: firstSeen,
1102
+ last_seen: timestamp(row.last_seen) || firstSeen,
1103
+ };
1104
+ }
1105
+
1106
+ // ---------------------------------------------------------------------------
1107
+ // History and the pre-v2 backup
1108
+ // ---------------------------------------------------------------------------
1109
+
1110
+ /**
1111
+ * Record an entry's prior content in decisions-history.jsonl and trim that entry
1112
+ * to its last HISTORY_DEPTH versions. A writer under the lock calls it before it
1113
+ * replaces the content; malformed history lines are quarantined first.
1114
+ *
1115
+ * D-CONTENT-HISTORY: before a write replaces an entry's content, the writer
1116
+ * appends the prior log row and ledger rows to decisions-history.jsonl, which
1117
+ * keeps the last HISTORY_DEPTH versions per observation id; a write that leaves
1118
+ * the content as it was appends nothing. Reason: rewrites replace content in place
1119
+ * rather than appending to it, so without a history the first bad rewrite would
1120
+ * lose the wording it replaced.
1121
+ *
1122
+ * @param {string} root - project root
1123
+ * @param {{ id: string, ledger?: object[], log?: object|null }} entry - the prior versions
1124
+ * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
1125
+ * @returns {number} the versions the entry now has (at most HISTORY_DEPTH)
1126
+ */
1127
+ function appendHistory(root, { id, ledger = [], log = null }, { now = Date.now() } = {}) {
1128
+ if (!isNonEmptyString(id)) throw new TypeError('appendHistory: id must be a non-empty string');
1129
+ if (!Array.isArray(ledger)) throw new TypeError('appendHistory: ledger must be an array of rows');
1130
+ const file = getDecisionsHistoryPath(root);
1131
+ const prior = readJsonlForWrite(file, { now });
1132
+ const versions = prior.filter(r => r.id === id).length;
1133
+ let toDrop = Math.max(0, versions + 1 - HISTORY_DEPTH);
1134
+ const kept = prior.filter(r => {
1135
+ if (r.id !== id || toDrop === 0) return true;
1136
+ toDrop -= 1;
1137
+ return false;
1138
+ });
1139
+ const record = { id, at: new Date(now).toISOString(), ledger: copyJson(ledger), log: copyJson(log) };
1140
+ writeJsonlAtomic(file, [...kept, record]);
1141
+ return Math.min(versions + 1, HISTORY_DEPTH);
1142
+ }
1143
+
1144
+ /**
1145
+ * The stored prior versions of observation `id`, oldest first. Read-only.
1146
+ *
1147
+ * @param {string} root - project root
1148
+ * @param {string} id
1149
+ * @returns {object[]} records `{ id, at, ledger, log }`
1150
+ */
1151
+ function historyVersions(root, id) {
1152
+ return readJsonl(getDecisionsHistoryPath(root)).rows.filter(r => r.id === id);
1153
+ }
1154
+
1155
+ /**
1156
+ * Copy the v1 files aside before the first v2 write.
1157
+ *
1158
+ * D-V1-BACKUP-ONCE: the first write to a tree that still holds a v1 row copies the
1159
+ * log, the ledger and the archive, as they are on disk, to `*.pre-v2.jsonl` with
1160
+ * an exclusive create, before anything rewrites them; an existing copy is never
1161
+ * overwritten. Reason: v2 writes convert and rewrite v1 rows, and these copies are
1162
+ * the only record of the corpus as it was before the conversion. A file that is a
1163
+ * symbolic link is not copied: the store reads none (D-NO-LINKED-READ, at readJsonl).
1164
+ *
1165
+ * @param {string} root - project root
1166
+ * @param {{ logRows?: object[], ledgerRows?: object[] }} rows - the rows just read
1167
+ * @returns {string[]} the backup paths written by this call (none when every row
1168
+ * is v2, a file is absent or a symbolic link, or its copy already exists)
1169
+ */
1170
+ function ensurePreV2Backup(root, { logRows = [], ledgerRows = [] } = {}) {
1171
+ if ([...logRows, ...ledgerRows].every(row => isV2(row))) return [];
1172
+ const written = [];
1173
+ for (const file of [getDecisionsLogPath(root), getDecisionsLedgerPath(root), getDecisionsArchivePath(root)]) {
1174
+ if (isSymbolicLink(file)) continue;
1175
+ const copy = withJsonlSuffix(file, '.pre-v2.jsonl');
1176
+ try {
1177
+ fs.copyFileSync(file, copy, fs.constants.COPYFILE_EXCL);
1178
+ written.push(copy);
1179
+ } catch (err) {
1180
+ if (err && (err.code === 'EEXIST' || err.code === 'ENOENT')) continue;
1181
+ throw err;
1182
+ }
1183
+ }
1184
+ return written;
1185
+ }
1186
+
1187
+ // ---------------------------------------------------------------------------
1188
+ // Rotation
1189
+ // ---------------------------------------------------------------------------
1190
+
1191
+ /** Days of inactivity after which an observation no ledger row carries leaves the log (D-ROTATE-UNREFERENCED). */
1192
+ const ROTATE_AGE_DAYS = 30;
1193
+
1194
+ /** What an older install's usage telemetry left in the learning directory: a file and a lock directory. */
1195
+ const USAGE_LEFTOVERS = Object.freeze(['.decisions-usage.json', '.decisions-usage.lock']);
1196
+
1197
+ /**
1198
+ * Epoch ms of a row's last activity — its `last_seen`, else `first_seen`, else
1199
+ * `created` — or null when that value is absent or does not parse.
1200
+ *
1201
+ * @param {object} row
1202
+ * @returns {number|null}
1203
+ */
1204
+ function lastActivityMs(row) {
1205
+ const value = row.last_seen || row.first_seen || row.created;
1206
+ const at = typeof value === 'string' ? Date.parse(value) : NaN;
1207
+ return Number.isFinite(at) ? at : null;
1208
+ }
1209
+
1210
+ /**
1211
+ * Move the observations no ledger row carries out of the log once they have been
1212
+ * inactive for ROTATE_AGE_DAYS, and delete what the retired usage telemetry left.
1213
+ *
1214
+ * D-ROTATE-UNREFERENCED: rotation archives every log row that no ledger row
1215
+ * carries once its last activity — last_seen, else first_seen, else created — is
1216
+ * at least ROTATE_AGE_DAYS old, whatever its status; a row any ledger row carries
1217
+ * stays, however old and whatever that ledger row's status. An archived row is
1218
+ * appended to the archive unless a byte-identical copy (in its JSON form) is
1219
+ * already there, and the log is rewritten without it. Reason: only the ledger
1220
+ * records what is promoted (D-LEDGER-REGISTRY), so neither a log status nor an
1221
+ * anchor_id copied onto a log row can say what to keep, and a dedup by id dropped
1222
+ * the newer version of a row whose older version was already archived.
1223
+ *
1224
+ * Under the learning lock it first deletes the usage leftovers. When no row is
1225
+ * due it writes nothing else. When rows are due it backs up a v1 tree
1226
+ * (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
1227
+ * (D-QUARANTINE-MALFORMED) before it appends to the archive and rewrites the log;
1228
+ * an interrupted run is retried safely, because the identical copy it appended is
1229
+ * skipped.
1230
+ *
1231
+ * @param {string} root - project root
1232
+ * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1233
+ * @returns {{ ok: true, value: { rotated: number, appended: number } } | { ok: false, error: { kind: string, message: string } }}
1234
+ * rotated: rows removed from the log; appended: rows added to the archive.
1235
+ * Errors are withDecisionsLock's not-a-directory, no-learning-dir and busy.
1236
+ */
1237
+ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1238
+ return withDecisionsLock('rotate-observations', root, () => {
1239
+ for (const name of USAGE_LEFTOVERS) {
1240
+ fs.rmSync(path.join(getLearningDir(root), name), { recursive: true, force: true });
1241
+ }
1242
+
1243
+ const logPath = getDecisionsLogPath(root);
1244
+ const log = readJsonl(logPath);
1245
+ const ledgerRows = readJsonl(getDecisionsLedgerPath(root)).rows;
1246
+ const isCarried = carriedBy(ledgerRows);
1247
+ const cutoff = now - ROTATE_AGE_DAYS * DAY_MS;
1248
+ const isDue = row => {
1249
+ if (isCarried(row)) return false;
1250
+ const at = lastActivityMs(row);
1251
+ return at !== null && at <= cutoff;
1252
+ };
1253
+ const due = log.rows.filter(isDue);
1254
+ if (due.length === 0) return { ok: true, value: { rotated: 0, appended: 0 } };
1255
+
1256
+ ensurePreV2Backup(root, { logRows: log.rows, ledgerRows });
1257
+ quarantineRejected(logPath, log.rejected, { now });
1258
+
1259
+ const archivePath = getDecisionsArchivePath(root);
1260
+ const archived = new Set(readJsonl(archivePath).rows.map(row => JSON.stringify(row)));
1261
+ const appended = [];
1262
+ for (const row of due) {
1263
+ const line = JSON.stringify(row);
1264
+ if (archived.has(line)) continue;
1265
+ archived.add(line);
1266
+ appended.push(line);
1267
+ }
1268
+ if (appended.length > 0) appendNoFollow(archivePath, appended.join('\n') + '\n');
1269
+ writeJsonlAtomic(logPath, log.rows.filter(row => !isDue(row)));
1270
+ return { ok: true, value: { rotated: due.length, appended: appended.length } };
1271
+ }, { timeoutMs });
1272
+ }
1273
+
1274
+ // ---------------------------------------------------------------------------
1275
+ // Clearing
1276
+ // ---------------------------------------------------------------------------
1277
+
1278
+ /**
1279
+ * Drop the observations no entry uses — `devflow learning --clear`.
1280
+ *
1281
+ * D-CLEAR-UNREFERENCED: clearing removes from the log exactly the rows no ledger
1282
+ * row carries (D-LEDGER-REGISTRY), whatever their age or status, and keeps every
1283
+ * row an entry carries, active or not; it refuses while the ledger holds a
1284
+ * malformed line, and it never writes the ledger, the archive or the rendered
1285
+ * files. Reason: truncating the whole log orphaned every entry — each lost the log
1286
+ * row that is its content authority, and refresh then refused them all — and a
1287
+ * malformed ledger line may be the one carrying a row clearing would drop.
1288
+ *
1289
+ * It runs under the learning lock (D-ONE-LEARNING-LOCK). When a row is dropped it
1290
+ * backs up a v1 tree (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
1291
+ * (D-QUARANTINE-MALFORMED) before it rewrites the log; with nothing to drop it
1292
+ * writes nothing.
1293
+ *
1294
+ * @param {string} root - project root
1295
+ * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1296
+ * @returns {{ ok: true, value: { cleared: number, kept: number } } | { ok: false, error: { kind: string, message: string } }}
1297
+ * cleared: rows removed from the log; kept: rows left in it. Error kinds:
1298
+ * ledger-malformed, and withDecisionsLock's not-a-directory, no-learning-dir and busy.
1299
+ */
1300
+ function clearUnreferenced(root, { now = Date.now(), timeoutMs } = {}) {
1301
+ return withDecisionsLock('clear', root, () => {
1302
+ const ledger = readJsonl(getDecisionsLedgerPath(root));
1303
+ if (ledger.rejected.length > 0) {
1304
+ const lines = ledger.rejected.length === 1 ? '1 malformed line' : `${ledger.rejected.length} malformed lines`;
1305
+ return {
1306
+ ok: false,
1307
+ error: {
1308
+ kind: 'ledger-malformed',
1309
+ message: `clear: the ledger has ${lines}, which may carry an observation this would drop; nothing was cleared`,
1310
+ },
1311
+ };
1312
+ }
1313
+ const logPath = getDecisionsLogPath(root);
1314
+ const log = readJsonl(logPath);
1315
+ const kept = log.rows.filter(carriedBy(ledger.rows));
1316
+ const cleared = log.rows.length - kept.length;
1317
+ if (cleared === 0) return { ok: true, value: { cleared: 0, kept: kept.length } };
1318
+
1319
+ ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
1320
+ quarantineRejected(logPath, log.rejected, { now });
1321
+ writeJsonlAtomic(logPath, kept);
1322
+ return { ok: true, value: { cleared, kept: kept.length } };
1323
+ }, { timeoutMs });
1324
+ }
1325
+
1326
+ // ---------------------------------------------------------------------------
1327
+ // Resetting
1328
+ // ---------------------------------------------------------------------------
1329
+
1330
+ /** rmdir(2) errors meaning the path is not an empty directory: gone, holding something, or not a directory. */
1331
+ const NOT_AN_EMPTY_DIR = Object.freeze(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'ENOTDIR']);
1332
+
1333
+ /** Remove `dir` when it is an empty directory; anything else at that path stays as it is. */
1334
+ function removeEmptyDir(dir) {
1335
+ try {
1336
+ fs.rmdirSync(dir);
1337
+ } catch (err) {
1338
+ if (!err || !NOT_AN_EMPTY_DIR.includes(err.code)) throw err;
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * Remove every learning file — `devflow learning --reset`: the log, the ledger
1344
+ * and their side files, the rendered files, the tuning config, and the queue
1345
+ * with its claim and owner file — and then the learning directory itself.
1346
+ *
1347
+ * D-RESET-UNDER-LOCK: reset empties the learning directory under the learning
1348
+ * lock, sparing only the lock directory, and removes the emptied directory once
1349
+ * the lock is released, and only if nothing has arrived in it. Reason: the lock
1350
+ * is released by its path, so removing it with the directory would let this
1351
+ * run's release delete the lock of a writer that recreated the tree in between;
1352
+ * and what arrives once the lock is free — a captured turn, or the next writer's
1353
+ * lock — belongs to the next run.
1354
+ *
1355
+ * Like every learning writer it refuses without `.devflow/learning/` and creates
1356
+ * nothing (D-NO-STRAY-TREE), refuses a learning directory or a `.devflow` that is a
1357
+ * symbolic link and removes nothing (D-NO-LINKED-TREE), since emptying it would
1358
+ * empty whatever directory the link leads to, and waits at most `timeoutMs` for the
1359
+ * lock, breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic
1360
+ * link in the directory is removed, never what it points to.
1361
+ *
1362
+ * @param {string} root - project root
1363
+ * @param {{ timeoutMs?: number }} [opts]
1364
+ * @returns {{ ok: true, value: { removed: number } } | { ok: false, error: { kind: string, message: string } }}
1365
+ * removed: the entries removed from the learning directory. Errors are
1366
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
1367
+ */
1368
+ function resetLearning(root, { timeoutMs } = {}) {
1369
+ const learningDir = getLearningDir(root);
1370
+ const lockName = path.basename(getDecisionsLockDir(root));
1371
+ const reset = withDecisionsLock('reset', root, () => {
1372
+ const entries = fs.readdirSync(learningDir).filter(name => name !== lockName);
1373
+ for (const name of entries) fs.rmSync(path.join(learningDir, name), { recursive: true, force: true });
1374
+ return { ok: true, value: { removed: entries.length } };
1375
+ }, { timeoutMs });
1376
+ if (reset.ok) removeEmptyDir(learningDir);
1377
+ return reset;
1378
+ }
1379
+
1380
+ // ---------------------------------------------------------------------------
1381
+ // The queue claim
1382
+ // ---------------------------------------------------------------------------
1383
+
1384
+ /**
1385
+ * Seconds without a heartbeat after which a claim is stale and the next claim
1386
+ * takes it over (D-OWNED-CLAIM). session-start-context's PROCESSING_STALE_SECS
1387
+ * holds the same value; a lockstep test pins the two together.
1388
+ */
1389
+ const CLAIM_STALE_SECS = 900;
1390
+
1391
+ /** How long a claim waits for the queue's own lock (ms): the wait of queue-append's overflow truncation. */
1392
+ const QUEUE_LOCK_TIMEOUT_MS = 2000;
1393
+
1394
+ /** Age after which the queue's own lock counts as abandoned (ms): learning-lock's threshold. */
1395
+ const QUEUE_LOCK_STALE_MS = 30000;
1396
+
1397
+ /** A claim token: 16 lowercase hex characters. */
1398
+ const CLAIM_TOKEN_RE = /^[0-9a-f]{16}$/;
1399
+
1400
+ /**
1401
+ * The largest owner file release-claim reads, in bytes. A token and its newline
1402
+ * take 17; a larger owner file reads as no owner file at all (D-NO-LINKED-READ,
1403
+ * at readJsonl).
1404
+ */
1405
+ const CLAIM_OWNER_MAX_BYTES = 4096;
1406
+
1407
+ /** link(2) errors of a filesystem without hard links; the claim renames instead. */
1408
+ const NO_HARD_LINK_CODES = Object.freeze(['EPERM', 'ENOTSUP']);
1409
+
1410
+ /**
1411
+ * A fresh claim token: 8 random bytes as 16 hex characters.
1412
+ *
1413
+ * @returns {string}
1414
+ */
1415
+ function newClaimToken() {
1416
+ return crypto.randomBytes(8).toString('hex');
1417
+ }
1418
+
1419
+ /** Throw a TypeError unless `token` is a claim token: a malformed one is a caller error. */
1420
+ function assertClaimToken(opName, token) {
1421
+ if (typeof token !== 'string' || !CLAIM_TOKEN_RE.test(token)) {
1422
+ throw new TypeError(`${opName}: a claim token is 16 lowercase hex characters`);
1423
+ }
1424
+ }
1425
+
1426
+ /**
1427
+ * The Stats of `file` when it is a regular file, null when nothing is there, and
1428
+ * false for anything else — a directory or a symlink — which the claim ops
1429
+ * refuse rather than follow or replace.
1430
+ *
1431
+ * @param {string} file
1432
+ * @returns {fs.Stats|null|false}
1433
+ */
1434
+ function regularFileStat(file) {
1435
+ let stat;
1436
+ try {
1437
+ stat = fs.lstatSync(file);
1438
+ } catch (err) {
1439
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
1440
+ throw err;
1441
+ }
1442
+ return stat.isFile() ? stat : false;
1443
+ }
1444
+
1445
+ /** The error Result for a claim path holding something other than a regular file. */
1446
+ function notRegularFile(opName, file) {
1447
+ return { ok: false, error: { kind: 'not-a-file', message: `${opName}: ${file} is not a regular file; remove it by hand` } };
1448
+ }
1449
+
1450
+ /** Delete `file`; one that is already gone is fine. */
1451
+ function removeIfPresent(file) {
1452
+ try {
1453
+ fs.unlinkSync(file);
1454
+ } catch (err) {
1455
+ if (!err || err.code !== 'ENOENT') throw err;
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * The token the owner file records, or null when it is absent or holds no token.
1461
+ * An owner file that is a symbolic link, anything else but a regular file, or
1462
+ * larger than CLAIM_OWNER_MAX_BYTES reads as absent (D-NO-LINKED-READ, at readJsonl).
1463
+ */
1464
+ function readClaimOwner(root) {
1465
+ const text = readTextUnlinked(getLearningClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
1466
+ if (text === null) return null;
1467
+ const token = text.trim();
1468
+ return CLAIM_TOKEN_RE.test(token) ? token : null;
1469
+ }
1470
+
1471
+ /**
1472
+ * Move the queue to the claim path under the queue's own lock, the lock
1473
+ * queue-append's overflow truncation takes, so a truncation can never rewrite the
1474
+ * queue from rows already claimed. link(2) refuses an existing claim; on a
1475
+ * filesystem without hard links a rename stands in, which is safe because the
1476
+ * caller found the claim path empty under the learning lock. A row a capture hook
1477
+ * appends meanwhile lands in the claimed batch or in the queue formed after it,
1478
+ * never in neither.
1479
+ *
1480
+ * @param {string} queuePath
1481
+ * @param {string} claimPath
1482
+ * @returns {'moved'|'busy'|'none'} busy when the queue lock is held or a claim
1483
+ * appeared; none when the queue vanished first
1484
+ */
1485
+ function moveQueueToClaim(queuePath, claimPath) {
1486
+ const queueLock = `${queuePath}.lock`;
1487
+ if (!acquireMkdirLock(queueLock, QUEUE_LOCK_TIMEOUT_MS, QUEUE_LOCK_STALE_MS)) return 'busy';
1488
+ try {
1489
+ try {
1490
+ fs.linkSync(queuePath, claimPath);
1491
+ } catch (err) {
1492
+ if (err && err.code === 'EEXIST') return 'busy';
1493
+ if (err && err.code === 'ENOENT') return 'none';
1494
+ if (!err || !NO_HARD_LINK_CODES.includes(err.code)) throw err;
1495
+ try {
1496
+ fs.renameSync(queuePath, claimPath);
1497
+ } catch (renameErr) {
1498
+ if (renameErr && renameErr.code === 'ENOENT') return 'none';
1499
+ throw renameErr;
1500
+ }
1501
+ return 'moved';
1502
+ }
1503
+ try {
1504
+ removeIfPresent(queuePath);
1505
+ } catch (err) {
1506
+ // Undo the link, so the rows stay queued once rather than claimed and queued.
1507
+ // The unlink error is the one to report; a failed undo leaves the claim to
1508
+ // go stale and be taken over.
1509
+ try { fs.unlinkSync(claimPath); } catch { /* reported through err */ }
1510
+ throw err;
1511
+ }
1512
+ return 'moved';
1513
+ } finally {
1514
+ releaseLock(queueLock);
1515
+ }
1516
+ }
1517
+
1518
+ /**
1519
+ * Claim the learning queue for one Learning run.
1520
+ *
1521
+ * D-OWNED-CLAIM: the learning queue is claimed and released only through the
1522
+ * claim-queue and release-claim ops, under the learning lock. A claim moves the
1523
+ * queue to .pending-turns.processing by link(2), under the queue's own lock (a
1524
+ * rename stands in only where the filesystem has no hard links), sets the claim's
1525
+ * mtime to now and records a fresh random token in .pending-turns.owner. A claim
1526
+ * younger than CLAIM_STALE_SECS is busy to every other claimant; an older one is
1527
+ * taken over with a new token, and the waiting queue is left for the next claim.
1528
+ * Release deletes the claim only for the token that owns it, and every json-helper
1529
+ * learning op refreshes an existing claim's mtime before it runs, without ever
1530
+ * creating one. Reason: a check-then-mv claim let two runs claim at once and
1531
+ * clobber a batch, mv kept the queue's old mtime so a fresh claim could look stale
1532
+ * at once, and an unconditional final unlink deleted another run's claim.
1533
+ *
1534
+ * Without .devflow/learning/ it answers none and creates nothing.
1535
+ *
1536
+ * @param {string} root - project root
1537
+ * @param {{ now?: number, token?: string, timeoutMs?: number }} [opts]
1538
+ * now: epoch ms (default Date.now()); token: the token to record (default a fresh one)
1539
+ * @returns {{ ok: true, value: { state: 'claimed', token: string, takeover: boolean } | { state: 'busy' } | { state: 'none' } }
1540
+ * | { ok: false, error: { kind: string, message: string } }}
1541
+ * @throws {TypeError} when `token` is not a claim token
1542
+ */
1543
+ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs } = {}) {
1544
+ assertClaimToken('claimQueue', token);
1545
+ if (!hasLearningDir(root)) return { ok: true, value: { state: 'none' } };
1546
+ return withDecisionsLock('claim-queue', root, () => {
1547
+ const claimPath = getLearningPendingTurnsProcessingPath(root);
1548
+ const queuePath = getLearningPendingTurnsPath(root);
1549
+ const at = new Date(now);
1550
+
1551
+ const claim = regularFileStat(claimPath);
1552
+ if (claim === false) return notRegularFile('claim-queue', claimPath);
1553
+ if (claim !== null) {
1554
+ if (now - claim.mtimeMs < CLAIM_STALE_SECS * 1000) return { ok: true, value: { state: 'busy' } };
1555
+ writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
1556
+ fs.utimesSync(claimPath, at, at);
1557
+ return { ok: true, value: { state: 'claimed', token, takeover: true } };
1558
+ }
1559
+
1560
+ const queue = regularFileStat(queuePath);
1561
+ if (queue === false) return notRegularFile('claim-queue', queuePath);
1562
+ if (queue === null || queue.size === 0) return { ok: true, value: { state: 'none' } };
1563
+
1564
+ const moved = moveQueueToClaim(queuePath, claimPath);
1565
+ if (moved !== 'moved') return { ok: true, value: { state: moved } };
1566
+ fs.utimesSync(claimPath, at, at);
1567
+ writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
1568
+ return { ok: true, value: { state: 'claimed', token, takeover: false } };
1569
+ }, { timeoutMs });
1570
+ }
1571
+
1572
+ /**
1573
+ * Release the claim `token` owns (D-OWNED-CLAIM): delete the claim and the owner
1574
+ * file when the token owns it (released); refuse when another token does
1575
+ * (not-owner); report a claim that is already gone (gone), deleting the owner
1576
+ * file only when it names this token. An owner file that is a symbolic link,
1577
+ * anything else but a regular file, or larger than CLAIM_OWNER_MAX_BYTES names no
1578
+ * token, as though it were absent (D-NO-LINKED-READ, at readJsonl).
1579
+ *
1580
+ * @param {string} root - project root
1581
+ * @param {string} token
1582
+ * @param {{ timeoutMs?: number }} [opts]
1583
+ * @returns {{ ok: true, value: { state: 'released'|'not-owner'|'gone' } } | { ok: false, error: { kind: string, message: string } }}
1584
+ * errors include withDecisionsLock's not-a-directory, no-learning-dir and busy
1585
+ * @throws {TypeError} when `token` is not a claim token
1586
+ */
1587
+ function releaseClaim(root, token, { timeoutMs } = {}) {
1588
+ assertClaimToken('releaseClaim', token);
1589
+ return withDecisionsLock('release-claim', root, () => {
1590
+ const claimPath = getLearningPendingTurnsProcessingPath(root);
1591
+ const ownerPath = getLearningClaimOwnerPath(root);
1592
+ const owned = readClaimOwner(root) === token;
1593
+
1594
+ const claim = regularFileStat(claimPath);
1595
+ if (claim === false) return notRegularFile('release-claim', claimPath);
1596
+ if (claim === null) {
1597
+ if (owned) removeIfPresent(ownerPath);
1598
+ return { ok: true, value: { state: 'gone' } };
1599
+ }
1600
+ if (!owned) return { ok: true, value: { state: 'not-owner' } };
1601
+ removeIfPresent(claimPath);
1602
+ removeIfPresent(ownerPath);
1603
+ return { ok: true, value: { state: 'released' } };
1604
+ }, { timeoutMs });
1605
+ }
1606
+
1607
+ /**
1608
+ * The claim heartbeat (D-OWNED-CLAIM): set an existing claim's mtime to now. It
1609
+ * never creates a claim and never follows a symlink at the claim path, and it
1610
+ * takes no lock — json-helper sends it before each learning op runs. A claim in a
1611
+ * learning tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
1612
+ * `lutimes` follows a linked folder above the claim, and the op that follows
1613
+ * refuses that tree anyway.
1614
+ *
1615
+ * @param {string} root - project root
1616
+ * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
1617
+ * @returns {{ ok: true, value: { touched: boolean } } | { ok: false, error: { kind: 'heartbeat-failed', message: string } }}
1618
+ */
1619
+ function touchClaim(root, { now = Date.now() } = {}) {
1620
+ if (linkedLearningFolder(root) !== null) return { ok: true, value: { touched: false } };
1621
+ const claimPath = getLearningPendingTurnsProcessingPath(root);
1622
+ const at = new Date(now);
1623
+ try {
1624
+ fs.lutimesSync(claimPath, at, at);
1625
+ } catch (err) {
1626
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return { ok: true, value: { touched: false } };
1627
+ return {
1628
+ ok: false,
1629
+ error: { kind: 'heartbeat-failed', message: `heartbeat: could not refresh ${claimPath}: ${err && err.message}` },
1630
+ };
1631
+ }
1632
+ return { ok: true, value: { touched: true } };
1633
+ }
1634
+
1635
+ // ---------------------------------------------------------------------------
1636
+ // Integrity, listing, due selection and show — all read-only
1637
+ // ---------------------------------------------------------------------------
1638
+
1639
+ /** True when a v2 row lists a glob scope entry that matches no tracked file. */
1640
+ function hasUnmatchedScope(row, scopeMatches) {
1641
+ if (!isV2(row) || !Array.isArray(row.scope)) return false;
1642
+ return row.scope.some(entry => !(isNonEmptyString(entry) && (entry.startsWith('area:') || scopeMatches(entry))));
1643
+ }
1644
+
1645
+ /**
1646
+ * Integrity problems of the active ledger rows, in anchor order:
1647
+ * duplicate-obs-id another active row carries the same observation id
1648
+ * ledger-without-log no log row carries the row's id
1649
+ * scope-matches-nothing a glob in a v2 row's scope matches no tracked file
1650
+ * (checked only when scopeMatches is given)
1651
+ *
1652
+ * @param {object[]} ledger
1653
+ * @param {object[]} log
1654
+ * @param {{ scopeMatches?: (glob: string) => boolean }} [opts]
1655
+ * @returns {Array<{ anchor_id: string, id: unknown, flags: string[] }>} only rows with a flag
1656
+ */
1657
+ function integrityFlags(ledger, log, { scopeMatches } = {}) {
1658
+ const active = activeAnchoredRows(ledger);
1659
+ const logIds = new Set(log.map(row => row.id).filter(isNonEmptyString));
1660
+ const carriers = new Map();
1661
+ for (const row of active) {
1662
+ if (isNonEmptyString(row.id)) carriers.set(row.id, (carriers.get(row.id) || 0) + 1);
1663
+ }
1664
+ const flagged = [];
1665
+ for (const row of sortedByAnchor(active)) {
1666
+ const flags = [];
1667
+ if (isNonEmptyString(row.id) && carriers.get(row.id) > 1) flags.push('duplicate-obs-id');
1668
+ if (!isNonEmptyString(row.id) || !logIds.has(row.id)) flags.push('ledger-without-log');
1669
+ if (scopeMatches && hasUnmatchedScope(row, scopeMatches)) flags.push('scope-matches-nothing');
1670
+ if (flags.length > 0) flagged.push({ anchor_id: row.anchor_id, id: row.id, flags });
1671
+ }
1672
+ return flagged;
1673
+ }
1674
+
1675
+ /** A row's title for a listing: the v2 title or the v1 pattern, on one line, cut to the title limit. */
1676
+ function listingTitle(row) {
1677
+ const raw = isV2(row) ? row.title : row.pattern;
1678
+ return typeof raw === 'string' ? cutTo(singleLine(raw), FIELD_LIMITS.title) : '';
1679
+ }
1680
+
1681
+ /**
1682
+ * Why an inactive entry is inactive, in a few words, or '' when it records nothing:
1683
+ * `encoded in <path>`, else `superseded by <anchor>`, else the status note. `list`
1684
+ * and the rendered Inactive table both show it.
1685
+ *
1686
+ * @param {object} row - a ledger row
1687
+ * @returns {string}
1688
+ */
1689
+ function inactiveNote(row) {
1690
+ if (isPlainObject(row.encoded_at) && isNonEmptyString(row.encoded_at.path)) return `encoded in ${row.encoded_at.path}`;
1691
+ if (isNonEmptyString(row.superseded_by)) return `superseded by ${row.superseded_by}`;
1692
+ if (isNonEmptyString(row.status_note)) return row.status_note;
1693
+ return '';
1694
+ }
1695
+
1696
+ /** The first log row carrying each observation id; a row with no id is left out. */
1697
+ function firstLogRowById(log) {
1698
+ const byId = new Map();
1699
+ for (const row of log) {
1700
+ if (isNonEmptyString(row.id) && !byId.has(row.id)) byId.set(row.id, row);
1701
+ }
1702
+ return byId;
1703
+ }
1704
+
1705
+ /** A log row's observation count (a v1 row's count) and last sighting; each is null when the row records none or there is no row. */
1706
+ function sightingsOf(logRow) {
1707
+ return {
1708
+ observations: (logRow && (positiveInteger(logRow.observations) || positiveInteger(logRow.count))) || null,
1709
+ last_seen: logRow && isNonEmptyString(logRow.last_seen) ? logRow.last_seen : null,
1710
+ };
1711
+ }
1712
+
1713
+ /** One ledger row as a listing line, with the sightings of `logRow`, the log row carrying its id. */
1714
+ function listingRow(row, logRow) {
1715
+ const entry = {
1716
+ anchor_id: row.anchor_id,
1717
+ id: row.id,
1718
+ type: row.type,
1719
+ status: isNonEmptyString(row.decisions_status) ? row.decisions_status : null,
1720
+ title: listingTitle(row),
1721
+ schema: isV2(row) ? 2 : 1,
1722
+ };
1723
+ if (isNonEmptyString(row.last_verified)) entry.last_verified = row.last_verified;
1724
+ return { ...entry, ...sightingsOf(logRow), scope: Array.isArray(row.scope) ? copyJson(row.scope) : null };
1725
+ }
1726
+
1727
+ /** One unpromoted log row as a listing line. */
1728
+ function observationListingRow(row) {
1729
+ return {
1730
+ id: row.id,
1731
+ type: row.type,
1732
+ title: listingTitle(row),
1733
+ schema: isV2(row) ? 2 : 1,
1734
+ ...sightingsOf(row),
1735
+ };
1736
+ }
1737
+
1738
+ /**
1739
+ * The data behind `list`: active and inactive entries in anchor order, the
1740
+ * observations no ledger row carries (by id), the integrity flags and the
1741
+ * malformed-line counts. A v1 title is its pattern cut to the title limit. Each
1742
+ * entry carries its ledger row's last_verified (when set) and scope (null when it
1743
+ * has none, as a v1 row does), and the observation count and last sighting of the
1744
+ * log row carrying its id — the first such row — or null for each without one.
1745
+ *
1746
+ * @param {object[]} ledger
1747
+ * @param {object[]} log
1748
+ * @param {{ scopeMatches?: (glob: string) => boolean, rejected?: { ledger?: unknown[], log?: unknown[] } }} [opts]
1749
+ * @returns {{ active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } }}
1750
+ */
1751
+ function buildListing(ledger, log, { scopeMatches, rejected = {} } = {}) {
1752
+ const anchored = ledger.filter(row => isNonEmptyString(row.anchor_id));
1753
+ const isCarried = carriedBy(ledger);
1754
+ const logById = firstLogRowById(log);
1755
+ const entryRow = row => listingRow(row, logById.get(row.id));
1756
+ return {
1757
+ active: sortedByAnchor(anchored.filter(row => isActive(row))).map(entryRow),
1758
+ inactive: sortedByAnchor(anchored.filter(row => !isActive(row)))
1759
+ .map(row => ({ ...entryRow(row), note: singleLine(inactiveNote(row)) })),
1760
+ observations: log
1761
+ .filter(row => isNonEmptyString(row.id) && !isCarried(row))
1762
+ .sort((a, b) => compareText(a.id, b.id))
1763
+ .map(observationListingRow),
1764
+ integrity: integrityFlags(ledger, log, { scopeMatches }),
1765
+ malformed: { ledger: (rejected.ledger || []).length, log: (rejected.log || []).length },
1766
+ };
1767
+ }
1768
+
1769
+ /**
1770
+ * An entry's size for the maintenance budget: the UTF-8 bytes of its ledger row
1771
+ * plus those of its log row, both as compact JSON.
1772
+ *
1773
+ * @param {object} ledgerRow
1774
+ * @param {object|null|undefined} logRow
1775
+ * @returns {number}
1776
+ */
1777
+ function entrySize(ledgerRow, logRow) {
1778
+ const ledgerBytes = Buffer.byteLength(JSON.stringify(ledgerRow), 'utf8');
1779
+ return logRow ? ledgerBytes + Buffer.byteLength(JSON.stringify(logRow), 'utf8') : ledgerBytes;
1780
+ }
1781
+
1782
+ /**
1783
+ * Pick the entries maintenance works on next.
1784
+ *
1785
+ * D-DUE-ORDER: maintenance hands out active entries in three classes, in order:
1786
+ * entries with an integrity flag (by anchor), legacy v1 entries (decisions before
1787
+ * pitfalls, then by number), then v2 entries last verified more than
1788
+ * DUE.verifyAgeDays ago (oldest first, never verified counting as oldest). An
1789
+ * entry attempted within DUE.leaseHours is skipped. At most DUE.maxEntries are
1790
+ * handed out, stopping at the first entry that would take the total past
1791
+ * DUE.byteBudget, and always at least one. Reason: a broken entry misleads every
1792
+ * reader until it is fixed, a v1 entry stays outside the v2 ops until it is
1793
+ * rewritten, the lease stops a run that died mid-entry from having the same entry
1794
+ * handed out again at once, and the cap and budget keep one run's reading bounded.
1795
+ *
1796
+ * @param {object[]} ledger
1797
+ * @param {object[]} log
1798
+ * @param {{ now?: number, integrity?: Array<{ anchor_id: string, flags: string[] }>, maxEntries?: number, budgetBytes?: number }} [opts]
1799
+ * now: epoch ms; integrity: integrityFlags' result.
1800
+ * @returns {Array<{ anchor_id: string, reason: string, bytes: number }>} reason is
1801
+ * the integrity flags joined by commas, `legacy-v1` or `verify-age`
1802
+ */
1803
+ function selectDue(ledger, log, { now = Date.now(), integrity = [], maxEntries = DUE.maxEntries, budgetBytes = DUE.byteBudget } = {}) {
1804
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) throw new TypeError('selectDue: maxEntries must be a positive integer');
1805
+ if (!Number.isFinite(budgetBytes) || budgetBytes < 1) throw new TypeError('selectDue: budgetBytes must be positive');
1806
+ const leaseMs = DUE.leaseHours * HOUR_MS;
1807
+ const verifyAgeMs = DUE.verifyAgeDays * DAY_MS;
1808
+
1809
+ const logById = firstLogRowById(log);
1810
+ const flagsByAnchor = new Map(integrity.map(entry => [entry.anchor_id, entry.flags]));
1811
+ const leased = row => {
1812
+ const attemptedAt = Date.parse(row.last_attempt);
1813
+ return Number.isFinite(attemptedAt) && now >= attemptedAt && now - attemptedAt < leaseMs;
1814
+ };
1815
+ const verifiedAt = row => {
1816
+ const at = Date.parse(row.last_verified);
1817
+ return Number.isFinite(at) ? at : -Infinity;
1818
+ };
1819
+
1820
+ const candidates = activeAnchoredRows(ledger).filter(row => !leased(row));
1821
+ const flagged = candidates.filter(row => flagsByAnchor.has(row.anchor_id));
1822
+ const unflagged = candidates.filter(row => !flagsByAnchor.has(row.anchor_id));
1823
+ const ordered = [
1824
+ ...sortedByAnchor(flagged).map(row => ({ row, reason: flagsByAnchor.get(row.anchor_id).join(',') })),
1825
+ ...sortedByAnchor(unflagged.filter(row => !isV2(row))).map(row => ({ row, reason: 'legacy-v1' })),
1826
+ ...unflagged
1827
+ .filter(row => isV2(row) && now - verifiedAt(row) > verifyAgeMs)
1828
+ .sort((a, b) => (verifiedAt(a) - verifiedAt(b)) || compareByAnchor(a, b))
1829
+ .map(row => ({ row, reason: 'verify-age' })),
1830
+ ];
1831
+
1832
+ const due = [];
1833
+ let total = 0;
1834
+ for (const { row, reason } of ordered) {
1835
+ if (due.length >= maxEntries) break;
1836
+ const bytes = entrySize(row, logById.get(row.id));
1837
+ if (due.length > 0 && total + bytes > budgetBytes) break;
1838
+ due.push({ anchor_id: row.anchor_id, reason, bytes });
1839
+ total += bytes;
1840
+ }
1841
+ return due;
1842
+ }
1843
+
1844
+ /** Whitespace-normalized text, or '' for a non-string. */
1845
+ function normalizeWhitespace(value) {
1846
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
1847
+ }
1848
+
1849
+ /**
1850
+ * The ledger-only-content flag of one ledger row, as a one-element list, or none.
1851
+ * A v1 row is flagged when its whitespace-normalized details are non-empty and not
1852
+ * contained in its log row's; a v2 row when any projected content field differs
1853
+ * from its log row's.
1854
+ */
1855
+ function ledgerOnlyContentFlags(ledgerRow, logRow) {
1856
+ let fields;
1857
+ if (isV2(ledgerRow)) {
1858
+ fields = PROJECTED_CONTENT_KEYS.filter(key => !sameJson(ledgerRow[key], logRow ? logRow[key] : undefined));
1859
+ } else {
1860
+ const ledgerDetails = normalizeWhitespace(ledgerRow.details);
1861
+ const logDetails = normalizeWhitespace(logRow ? logRow.details : undefined);
1862
+ fields = ledgerDetails !== '' && !logDetails.includes(ledgerDetails) ? ['details'] : [];
1863
+ }
1864
+ return fields.length > 0 ? [{ anchor_id: ledgerRow.anchor_id, flag: 'ledger-only-content', fields }] : [];
1865
+ }
1866
+
1867
+ /**
1868
+ * The data behind `show <anchor|obs_id>`: every ledger row carrying the entry's
1869
+ * observation id (in anchor order), its log row, its stored history versions,
1870
+ * and a `ledger-only-content` flag for each ledger row holding content its log
1871
+ * row does not.
1872
+ *
1873
+ * @param {string} key - an anchor id or an observation id
1874
+ * @param {object[]} ledger
1875
+ * @param {object[]} log
1876
+ * @param {{ historyVersions?: (id: string) => object[] }} [opts]
1877
+ * @returns {{ ok: true, value: { key: string, ledger: object[], log: object|null, history_versions: object[], flags: object[] } }
1878
+ * | { ok: false, error: { kind: 'invalid-key'|'not-found', message: string } }}
1879
+ */
1880
+ function showEntry(key, ledger, log, { historyVersions: versionsOf } = {}) {
1881
+ const notFound = { ok: false, error: { kind: 'not-found', message: `show: no entry '${key}' in the ledger or the log` } };
1882
+ let id;
1883
+ let rows;
1884
+ if (typeof key === 'string' && ANCHOR_ID_RE.test(key)) {
1885
+ const anchored = ledger.find(row => row.anchor_id === key);
1886
+ if (!anchored) return notFound;
1887
+ id = isNonEmptyString(anchored.id) ? anchored.id : null;
1888
+ rows = id === null ? [anchored] : ledger.filter(row => row.id === id);
1889
+ } else if (typeof key === 'string' && OBS_ID_RE.test(key)) {
1890
+ id = key;
1891
+ rows = ledger.filter(row => row.id === key);
1892
+ } else {
1893
+ return {
1894
+ ok: false,
1895
+ error: { kind: 'invalid-key', message: `show: ${JSON.stringify(key)} is neither an anchor id nor an observation id` },
1896
+ };
1897
+ }
1898
+ const logRow = id === null ? null : log.find(row => row.id === id) || null;
1899
+ if (rows.length === 0 && logRow === null) return notFound;
1900
+ const shown = sortedByAnchor(rows);
1901
+ return {
1902
+ ok: true,
1903
+ value: {
1904
+ key,
1905
+ ledger: shown,
1906
+ log: logRow,
1907
+ history_versions: id !== null && versionsOf ? versionsOf(id) : [],
1908
+ flags: shown.flatMap(row => ledgerOnlyContentFlags(row, logRow)),
1909
+ },
1910
+ };
1911
+ }
1912
+
1913
+ /** The full commit id `ref` resolves to under `root`, or null. */
1914
+ function commitAt(root, ref) {
1915
+ let out;
1916
+ try {
1917
+ out = git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
1918
+ } catch {
1919
+ return null;
1920
+ }
1921
+ const commit = out.trim();
1922
+ return COMMIT_ID_RE.test(commit) ? commit : null;
1923
+ }
1924
+
1925
+ /**
1926
+ * The ref and commit claims about the code are checked at.
1927
+ *
1928
+ * D-VERIFY-REF: a claim about the code is checked at the default branch as last
1929
+ * fetched — origin/HEAD — and at HEAD only when the repository has no usable
1930
+ * origin/HEAD; never at the working tree or the index. Reason: the ledger serves
1931
+ * every checkout of the repository, so a claim that holds only on one branch or in
1932
+ * uncommitted edits would mislead every other one, and a ref read needs no network
1933
+ * and runs no repository hook.
1934
+ *
1935
+ * @param {string} root - project root
1936
+ * @returns {{ ref: 'origin/HEAD'|'HEAD', commit: string } | null} null outside a
1937
+ * repository or before its first commit
1938
+ */
1939
+ function resolveVerifyRef(root) {
1940
+ const fetched = commitAt(root, 'refs/remotes/origin/HEAD');
1941
+ if (fetched !== null) return { ref: 'origin/HEAD', commit: fetched };
1942
+ const head = commitAt(root, 'HEAD');
1943
+ return head === null ? null : { ref: 'HEAD', commit: head };
1944
+ }
1945
+
1946
+ // ---------------------------------------------------------------------------
1947
+ // Rendering
1948
+ // ---------------------------------------------------------------------------
1949
+
1950
+ /**
1951
+ * Render decisions.md, pitfalls.md and index.md from `ledgerRows` and write each
1952
+ * atomically, the index last. The caller holds .decisions.lock
1953
+ * (D-ONE-LEARNING-LOCK), so the learning directory exists. render-decisions.cjs
1954
+ * requires this module at load time, so it is required here, on first use. Prints
1955
+ * nothing.
1956
+ *
1957
+ * @param {string} root - project root
1958
+ * @param {object[]} ledgerRows - every ledger row
1959
+ */
1960
+ function renderAll(root, ledgerRows) {
1961
+ const { renderLearningFiles } = require('./render-decisions.cjs');
1962
+ for (const file of renderLearningFiles(root, ledgerRows)) writeFileAtomic(file.path, file.content);
1963
+ }
1964
+
1965
+ // ---------------------------------------------------------------------------
1966
+ // put-observation
1967
+ // ---------------------------------------------------------------------------
1968
+
1969
+ /** The counter keys of a v1 row that the v2 counters replace: count becomes observations, created first_seen. */
1970
+ const LEGACY_COUNTER_KEYS = Object.freeze(['count', 'created']);
1971
+
1972
+ /** A validation field that prints as it is; any other prints as JSON, on one line. */
1973
+ const PLAIN_FIELD_RE = /^[\w.()[\]-]{1,64}$/;
1974
+
1975
+ /**
1976
+ * The anchored ledger rows carrying observation `id`, in anchor order: the
1977
+ * entries the observation backs (D-LEDGER-REGISTRY).
1978
+ *
1979
+ * @param {{ byObsId: Map<string, object[]> }} registry
1980
+ * @param {string|null} id
1981
+ * @returns {object[]}
1982
+ */
1983
+ function anchoredCarriers(registry, id) {
1984
+ const carriers = id === null ? [] : registry.byObsId.get(id) || [];
1985
+ return sortedByAnchor(carriers.filter(row => isNonEmptyString(row.anchor_id)));
1986
+ }
1987
+
1988
+ /** True when two rows differ in any CONTENT_KEYS value. */
1989
+ function contentDiffers(a, b) {
1990
+ return CONTENT_KEYS.some(key => !sameJson(a[key], b[key]));
1991
+ }
1992
+
1993
+ /** The log row a create stores: the content after the schema, then one observation, first and last seen now. */
1994
+ function createdRow(content, now) {
1995
+ const at = new Date(now).toISOString();
1996
+ return { schema: SCHEMA_VERSION, ...content, observations: 1, first_seen: at, last_seen: at };
1997
+ }
1998
+
1999
+ /** The log row an update stores: the new content, then `existing`'s counters (a v1 row's converted). */
2000
+ function updatedRow(content, existing, now) {
2001
+ return { schema: SCHEMA_VERSION, ...content, ...toV2Counters(existing, { now }) };
2002
+ }
2003
+
2004
+ /**
2005
+ * `existing` with one more observation, last seen now. Every other key stays,
2006
+ * so a v1 row stays v1; its legacy counters give way to the v2 ones.
2007
+ */
2008
+ function reinforcedRow(existing, now) {
2009
+ const counters = toV2Counters(existing, { now });
2010
+ const kept = Object.fromEntries(Object.entries(existing).filter(([key]) => !LEGACY_COUNTER_KEYS.includes(key)));
2011
+ return {
2012
+ ...kept,
2013
+ observations: counters.observations + 1,
2014
+ first_seen: counters.first_seen,
2015
+ last_seen: new Date(now).toISOString(),
2016
+ };
2017
+ }
2018
+
2019
+ /**
2020
+ * The log row a put stores and the outcome it reports, or null for an update
2021
+ * whose content a v2 row already holds. A v1 row is never unchanged: an update
2022
+ * converts it.
2023
+ */
2024
+ function plannedLogRow(mode, content, existing, now) {
2025
+ if (mode === 'reinforce') return { outcome: 'reinforced', logRow: reinforcedRow(existing, now) };
2026
+ if (mode === 'create') return { outcome: 'created', logRow: createdRow(content, now) };
2027
+ if (isV2(existing) && !contentDiffers(existing, content)) return null;
2028
+ return { outcome: 'updated', logRow: updatedRow(content, existing, now) };
2029
+ }
2030
+
2031
+ /** Why active entry `row` cannot take a log row of `type`, or null when it can. */
2032
+ function reprojectionProblem(row, type) {
2033
+ if (row.type !== type) return `it is a ${row.type} entry and the observation is a ${type}`;
2034
+ if (!ANCHOR_ID_RE.test(row.anchor_id) || !row.anchor_id.startsWith(`${anchorPrefixFor(type)}-`)) {
2035
+ return `its anchor does not name a ${type} entry`;
2036
+ }
2037
+ return null;
2038
+ }
2039
+
2040
+ /**
2041
+ * Active entry `prior` re-projected from `logRow` (D-PUT-REPROJECTS). A status
2042
+ * outside ENTRY_STATUSES — absent or unknown, which still counts as active —
2043
+ * becomes the type's active status; every other ledger-owned key carries over.
2044
+ */
2045
+ function reprojectedRow(logRow, prior) {
2046
+ const status = ENTRY_STATUSES.includes(prior.decisions_status) ? undefined : activeStatusFor(logRow.type);
2047
+ return toLedgerRowV2(logRow, prior, { status, expectType: prior.type });
2048
+ }
2049
+
2050
+ /** A put's refusal: `message` follows the op name, and `extra` joins the error. */
2051
+ function putRefusal(kind, message, extra = {}) {
2052
+ return { ok: false, error: { kind, message: `put-observation: ${message}`, ...extra } };
2053
+ }
2054
+
2055
+ /** How a validation field prints: a plain name as it is, anything else as JSON on one line. */
2056
+ function fieldLabel(field) {
2057
+ return PLAIN_FIELD_RE.test(field) ? field : cutTo(singleLine(JSON.stringify(field)), 80);
2058
+ }
2059
+
2060
+ /** The refusal of an op's stdin input: every problem, one per line, after the op name. */
2061
+ function invalidInput(opName, problems) {
2062
+ const count = `${problems.length} problem${problems.length === 1 ? '' : 's'}`;
2063
+ const lines = problems.map(problem => ` ${fieldLabel(problem.field)}: ${singleLine(problem.message)}`);
2064
+ const message = [`${opName}: the input has ${count}; nothing was written`, ...lines].join('\n');
2065
+ return { ok: false, error: { kind: 'invalid-input', message, problems } };
2066
+ }
2067
+
2068
+ /** The refusal of a put on an observation whose entries are all inactive. */
2069
+ function restoreFirst(id, carriers) {
2070
+ const entries = carriers.map(row => `${row.anchor_id} ${row.decisions_status}`).join(', ');
2071
+ return putRefusal('restore-first', `'${id}' belongs only to inactive entries (${singleLine(entries)}); restore first`);
2072
+ }
2073
+
2074
+ /**
2075
+ * Store one observation from the put-observation op: create it, replace its
2076
+ * content, or count one more sighting of it.
2077
+ *
2078
+ * D-PUT-REPROJECTS: when a put stores new content for an observation, every
2079
+ * active ledger row carrying the observation's id is re-projected from the new
2080
+ * log row through toLedgerRowV2, and decisions.md, pitfalls.md and index.md are
2081
+ * re-rendered, all under the .decisions.lock the log was written under; a ledger
2082
+ * row carrying the id with an inactive status is written back unchanged.
2083
+ * Reason: an entry's ledger row and rendered text must follow its log row at
2084
+ * once — a separate refresh step can be skipped or interleaved with another
2085
+ * writer, and leaves every reader on the old wording until it runs — while a
2086
+ * retired entry keeps the wording it was retired with.
2087
+ *
2088
+ * Modes, each taking content under D-PUT-NOT-MERGE:
2089
+ * create an id the log does not hold, stored with one observation, first
2090
+ * and last seen now. An active entry already carrying the id is
2091
+ * re-projected: the repair for an entry that lost its log row.
2092
+ * update the whole new content of an id the log holds, never merged with
2093
+ * the old, of the same type. A v1 row becomes a v2 row, its
2094
+ * counters converted by toV2Counters; what only v1 held (pattern,
2095
+ * details, amendments, evidence over the limits) stays in the
2096
+ * history and the pre-v2 backup. Content equal to a v2 row's is
2097
+ * `unchanged` and writes nothing.
2098
+ * reinforce `{ id }` alone: one more observation, last seen now — no
2099
+ * history, no re-projection and no render. A v1 row stays v1, its
2100
+ * counters converted.
2101
+ *
2102
+ * Every mode refuses, writing nothing: an input validation rejects (every
2103
+ * problem at once), an id the log holds twice, an observation whose entries are
2104
+ * all inactive ("restore first"), and an active entry that cannot take the
2105
+ * observation's type. A put that writes backs up a v1 tree first
2106
+ * (D-V1-BACKUP-ONCE), quarantines the malformed lines of each file it rewrites
2107
+ * (D-QUARANTINE-MALFORMED) and records the content it replaces
2108
+ * (D-CONTENT-HISTORY). Entries are found through the ledger alone
2109
+ * (D-LEDGER-REGISTRY).
2110
+ *
2111
+ * @param {string} root - project root
2112
+ * @param {'create'|'update'|'reinforce'} mode
2113
+ * @param {unknown} input - the parsed stdin object
2114
+ * @param {{ now?: number, timeoutMs?: number, scopeMatches?: (glob: string) => boolean }} [opts]
2115
+ * now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
2116
+ * @returns {{ ok: true, value: { outcome: 'created'|'updated'|'unchanged'|'reinforced', id: string, observations: number, reprojected: string[] } }
2117
+ * | { ok: false, error: { kind: string, message: string, problems?: Array<{ field: string, message: string }> } }}
2118
+ * observations: the count the log row holds afterwards; reprojected: the
2119
+ * anchors re-projected, in anchor order. Error kinds: invalid-input (with
2120
+ * problems), duplicate-log-id, restore-first, cannot-reproject, and
2121
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
2122
+ * @throws {TypeError} when `mode` is not a put mode
2123
+ */
2124
+ function putObservation(root, mode, input, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
2125
+ if (!VALIDATION_MODES.includes(mode)) {
2126
+ throw new TypeError(`putObservation: mode must be one of ${VALIDATION_MODES.join(', ')}, got '${mode}'`);
2127
+ }
2128
+ const matches = scopeMatches || gitScopeMatcher(root);
2129
+ return withDecisionsLock('put-observation', root, () => putUnderLock(root, mode, input, { now, scopeMatches: matches }), { timeoutMs });
2130
+ }
2131
+
2132
+ /** putObservation's locked body. */
2133
+ function putUnderLock(root, mode, input, { now, scopeMatches }) {
2134
+ const logPath = getDecisionsLogPath(root);
2135
+ const ledgerPath = getDecisionsLedgerPath(root);
2136
+ const log = readJsonl(logPath);
2137
+ const ledger = readJsonl(ledgerPath);
2138
+ const registry = ledgerRegistry(ledger.rows);
2139
+
2140
+ const id = isPlainObject(input) && typeof input.id === 'string' ? input.id : null;
2141
+ const sameId = id === null ? [] : log.rows.filter(row => row.id === id);
2142
+ const existing = sameId[0] || null;
2143
+ const checked = validateObservationInput(input, { mode, existing, ledgerIds: registry.byAnchor.keys(), scopeMatches });
2144
+ if (!checked.ok) return invalidInput('put-observation', checked.errors);
2145
+ if (sameId.length > 1) {
2146
+ return putRefusal('duplicate-log-id', `the log holds ${sameId.length} rows with id '${id}'; nothing was written`);
2147
+ }
2148
+ const carriers = anchoredCarriers(registry, id);
2149
+ const active = carriers.filter(row => isActive(row));
2150
+ if (carriers.length > 0 && active.length === 0) return restoreFirst(id, carriers);
2151
+
2152
+ const planned = plannedLogRow(mode, checked.value, existing, now);
2153
+ if (planned === null) {
2154
+ const observations = toV2Counters(existing, { now }).observations;
2155
+ return { ok: true, value: { outcome: 'unchanged', id, observations, reprojected: [] } };
2156
+ }
2157
+ const { outcome, logRow } = planned;
2158
+
2159
+ const reprojecting = mode === 'reinforce' ? [] : active;
2160
+ for (const row of reprojecting) {
2161
+ const problem = reprojectionProblem(row, logRow.type);
2162
+ if (problem) {
2163
+ return putRefusal('cannot-reproject', `${singleLine(String(row.anchor_id))} cannot take this observation: ${problem}; nothing was written`);
2164
+ }
2165
+ }
2166
+ const projected = new Map(reprojecting.map(row => [row, reprojectedRow(logRow, row)]));
2167
+ const replacesContent = mode === 'update' || [...projected].some(([prior, row]) => !sameJson(prior, row));
2168
+
2169
+ ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
2170
+ quarantineRejected(logPath, log.rejected, { now });
2171
+ if (replacesContent) appendHistory(root, { id, ledger: registry.byObsId.get(id) || [], log: existing }, { now });
2172
+ writeJsonlAtomic(logPath, existing === null ? [...log.rows, logRow] : log.rows.map(row => (row === existing ? logRow : row)));
2173
+ if (projected.size > 0) {
2174
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
2175
+ const ledgerRows = ledger.rows.map(row => projected.get(row) || row);
2176
+ writeJsonlAtomic(ledgerPath, ledgerRows);
2177
+ renderAll(root, ledgerRows);
2178
+ }
2179
+ return {
2180
+ ok: true,
2181
+ value: { outcome, id, observations: logRow.observations, reprojected: reprojecting.map(row => row.anchor_id) },
2182
+ };
2183
+ }
2184
+
2185
+ // ---------------------------------------------------------------------------
2186
+ // list, show and claim-due
2187
+ // ---------------------------------------------------------------------------
2188
+
2189
+ /**
2190
+ * The data behind `list`, read-only: buildListing over the ledger and the log as
2191
+ * they are, every glob scope checked against the files git tracks. Malformed
2192
+ * lines are counted, never quarantined (D-QUARANTINE-MALFORMED). Refuses without
2193
+ * .devflow/learning/ (D-NO-STRAY-TREE).
2194
+ *
2195
+ * @param {string} root - project root
2196
+ * @param {{ scopeMatches?: (glob: string) => boolean }} [opts] - default gitScopeMatcher(root)
2197
+ * @returns {{ ok: true, value: { active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } } }
2198
+ * | { ok: false, error: { kind: 'no-learning-dir', message: string } }}
2199
+ */
2200
+ function readListing(root, { scopeMatches } = {}) {
2201
+ if (!hasLearningDir(root)) return noLearningDir('list', root);
2202
+ const { ledgerRows, logRows, rejected } = readLearningState(root);
2203
+ return {
2204
+ ok: true,
2205
+ value: buildListing(ledgerRows, logRows, { scopeMatches: scopeMatches || gitScopeMatcher(root), rejected }),
2206
+ };
2207
+ }
2208
+
2209
+ /** A listing token as it prints: itself when it is one word, else `-`. */
2210
+ function listingToken(value) {
2211
+ return isNonEmptyString(value) && !/\s/.test(value) && !CONTROL_CHAR_RE.test(value) ? value : '-';
2212
+ }
2213
+
2214
+ /** A listing's scope token: its entries joined by `,`, each as listingToken prints it, or `-` for none. */
2215
+ function scopeToken(scope) {
2216
+ return Array.isArray(scope) && scope.length > 0 ? scope.map(listingToken).join(',') : '-';
2217
+ }
2218
+
2219
+ /**
2220
+ * The text `list` prints. Each section opens with its name and count, and each
2221
+ * item is a line indented two spaces whose tokens are separated by one space,
2222
+ * the title last and taking the rest of the line:
2223
+ *
2224
+ * ACTIVE <n>
2225
+ * <anchor> <obs_id> v<schema> verified <date|never> observed <count|?> last-seen <last_seen|-> scope <scope|-> <title>
2226
+ * INACTIVE <n>
2227
+ * <anchor> <obs_id> v<schema> <status> <title>
2228
+ * note: <note> only when the entry records one
2229
+ * OBSERVATIONS <n> the log rows no ledger row carries
2230
+ * <obs_id> <type> v<schema> observed <count|?> <title>
2231
+ * INTEGRITY <n>
2232
+ * <anchor> <obs_id> <flag>[,<flag>…]
2233
+ * MALFORMED <n> only when lines were skipped
2234
+ * ledger <k> each file with skipped lines
2235
+ * log <k>
2236
+ *
2237
+ * An ACTIVE line's `verified` is the entry's last_verified date, or `never`;
2238
+ * `observed` and `last-seen` are its log row's observation count and last
2239
+ * sighting; `scope` is its scope entries joined by `,`, or `-` when it has none,
2240
+ * as a v1 entry does. A token that is missing or not one word prints as `-` (a
2241
+ * missing count as `?`), and so does an empty title; titles and notes are
2242
+ * already one line (buildListing).
2243
+ *
2244
+ * @param {{ active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } }} listing - buildListing's result
2245
+ * @returns {string} the lines, with no final newline
2246
+ */
2247
+ function formatListing(listing) {
2248
+ const title = text => (text === '' ? '-' : text);
2249
+ const verified = row => (isNonEmptyString(row.last_verified) ? listingToken(row.last_verified) : 'never');
2250
+ const section = (name, items, toLines) => [`${name} ${items.length}`, ...items.flatMap(toLines)];
2251
+ const lines = [
2252
+ ...section('ACTIVE', listing.active, row => [
2253
+ ` ${listingToken(row.anchor_id)} ${listingToken(row.id)} v${row.schema} verified ${verified(row)}`
2254
+ + ` observed ${row.observations ?? '?'} last-seen ${listingToken(row.last_seen)} scope ${scopeToken(row.scope)}`
2255
+ + ` ${title(row.title)}`,
2256
+ ]),
2257
+ ...section('INACTIVE', listing.inactive, row => [
2258
+ ` ${listingToken(row.anchor_id)} ${listingToken(row.id)} v${row.schema} ${listingToken(row.status)} ${title(row.title)}`,
2259
+ ...(row.note ? [` note: ${row.note}`] : []),
2260
+ ]),
2261
+ ...section('OBSERVATIONS', listing.observations, row => [
2262
+ ` ${listingToken(row.id)} ${listingToken(row.type)} v${row.schema} observed ${row.observations ?? '?'} ${title(row.title)}`,
2263
+ ]),
2264
+ ...section('INTEGRITY', listing.integrity, entry => [
2265
+ ` ${listingToken(entry.anchor_id)} ${listingToken(entry.id)} ${entry.flags.join(',')}`,
2266
+ ]),
2267
+ ];
2268
+ const skipped = ['ledger', 'log'].filter(file => listing.malformed[file] > 0);
2269
+ if (skipped.length > 0) {
2270
+ lines.push(
2271
+ `MALFORMED ${listing.malformed.ledger + listing.malformed.log}`,
2272
+ ...skipped.map(file => ` ${file} ${listing.malformed[file]}`),
2273
+ );
2274
+ }
2275
+ return lines.join('\n');
2276
+ }
2277
+
2278
+ /**
2279
+ * The data behind `show <anchor|obs_id>`, read-only: showEntry over the ledger,
2280
+ * the log and the history as they are. When lines were skipped as malformed
2281
+ * (D-QUARANTINE-MALFORMED), a found entry gains `malformed: { ledger, log }` and
2282
+ * a not-found message says a skipped line may hold it. Refuses without
2283
+ * .devflow/learning/ (D-NO-STRAY-TREE).
2284
+ *
2285
+ * @param {string} root - project root
2286
+ * @param {string} key - an anchor id or an observation id
2287
+ * @returns {{ ok: true, value: { key: string, ledger: object[], log: object|null, history_versions: object[], flags: object[], malformed?: { ledger: number, log: number } } }
2288
+ * | { ok: false, error: { kind: 'no-learning-dir'|'invalid-key'|'not-found', message: string } }}
2289
+ */
2290
+ function showByKey(root, key) {
2291
+ if (!hasLearningDir(root)) return noLearningDir('show', root);
2292
+ const { ledgerRows, logRows, rejected } = readLearningState(root);
2293
+ const shown = showEntry(key, ledgerRows, logRows, { historyVersions: id => historyVersions(root, id) });
2294
+ const malformed = { ledger: rejected.ledger.length, log: rejected.log.length };
2295
+ const skipped = malformed.ledger + malformed.log;
2296
+ if (skipped === 0) return shown;
2297
+ if (shown.ok) return { ok: true, value: { ...shown.value, malformed } };
2298
+ if (shown.error.kind !== 'not-found') return shown;
2299
+ const note = `MALFORMED ${skipped} (ledger ${malformed.ledger}, log ${malformed.log}): a skipped line may hold it`;
2300
+ return { ok: false, error: { ...shown.error, message: `${shown.error.message}; ${note}` } };
2301
+ }
2302
+
2303
+ /**
2304
+ * Ask `scopeMatches` about each glob integrityFlags will ask about for these rows
2305
+ * — the glob scopes of the active v2 entries — so that a memoized matcher answers
2306
+ * from memory, with no git call, once the lock is held.
2307
+ *
2308
+ * @param {object[]} ledgerRows
2309
+ * @param {(glob: string) => boolean} scopeMatches
2310
+ */
2311
+ function warmScopeMatcher(ledgerRows, scopeMatches) {
2312
+ for (const row of activeAnchoredRows(ledgerRows)) {
2313
+ if (!isV2(row) || !Array.isArray(row.scope)) continue;
2314
+ for (const entry of row.scope) {
2315
+ if (isNonEmptyString(entry) && !entry.startsWith('area:')) scopeMatches(entry);
2316
+ }
2317
+ }
2318
+ }
2319
+
2320
+ /**
2321
+ * Hand out the entries maintenance works on next, and lease them.
2322
+ *
2323
+ * Under the learning lock it flags integrity problems and selects the due
2324
+ * entries (D-DUE-ORDER), then stamps each entry it hands out with
2325
+ * `last_attempt` = now: claim-due is the one writer of the field the lease
2326
+ * reads. A hand-out backs up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines
2327
+ * the ledger's malformed lines before it rewrites the ledger
2328
+ * (D-QUARANTINE-MALFORMED); with nothing due it writes nothing. It answers the
2329
+ * ref claims are checked at as well (D-VERIFY-REF). The scope checks' git calls
2330
+ * run before the lock is taken, on the ledger as it stood then; a glob that
2331
+ * appears meanwhile is checked under the lock.
2332
+ *
2333
+ * @param {string} root - project root
2334
+ * @param {{ now?: number, timeoutMs?: number, scopeMatches?: (glob: string) => boolean }} [opts]
2335
+ * now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
2336
+ * @returns {{ ok: true, value: { ref: { ref: 'origin/HEAD'|'HEAD', commit: string } | null, due: Array<{ anchor_id: string, reason: string, bytes: number }> } }
2337
+ * | { ok: false, error: { kind: string, message: string } }}
2338
+ * due is selectDue's answer; errors are withDecisionsLock's not-a-directory,
2339
+ * no-learning-dir and busy
2340
+ */
2341
+ function claimDue(root, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
2342
+ if (!hasLearningDir(root)) return noLearningDir('claim-due', root);
2343
+ const matches = scopeMatches || gitScopeMatcher(root);
2344
+ const ref = resolveVerifyRef(root);
2345
+ warmScopeMatcher(readJsonl(getDecisionsLedgerPath(root)).rows, matches);
2346
+ return withDecisionsLock('claim-due', root, () => {
2347
+ const ledgerPath = getDecisionsLedgerPath(root);
2348
+ const ledger = readJsonl(ledgerPath);
2349
+ const logRows = readJsonl(getDecisionsLogPath(root)).rows;
2350
+ const integrity = integrityFlags(ledger.rows, logRows, { scopeMatches: matches });
2351
+ const due = selectDue(ledger.rows, logRows, { now, integrity });
2352
+ if (due.length > 0) {
2353
+ ensurePreV2Backup(root, { logRows, ledgerRows: ledger.rows });
2354
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
2355
+ const handedOut = new Set(due.map(entry => entry.anchor_id));
2356
+ const attemptedAt = new Date(now).toISOString();
2357
+ writeJsonlAtomic(ledgerPath, ledger.rows.map(row => (
2358
+ handedOut.has(row.anchor_id) && isActive(row) ? { ...row, last_attempt: attemptedAt } : row
2359
+ )));
2360
+ }
2361
+ return { ok: true, value: { ref, due } };
2362
+ }, { timeoutMs });
2363
+ }
2364
+
2365
+ // ---------------------------------------------------------------------------
2366
+ // assign-anchor: numbering and the cited-number scan
2367
+ // ---------------------------------------------------------------------------
2368
+
2369
+ /** The most cited numbers assign-anchor skips before it refuses (D-E4-SKIP). */
2370
+ const E4_MAX_SKIPS = 100;
2371
+
2372
+ /** Directory names the cited-number scan never reads, at any depth (D-E4-SKIP). */
2373
+ const CITED_SCAN_EXCLUDED_SEGMENTS = Object.freeze(['.git', 'node_modules', 'target', 'dist']);
2374
+
2375
+ /** The largest file the cited-number scan reads (bytes); a larger one is skipped. */
2376
+ const CITED_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
2377
+
2378
+ /** The most directory entries the fallback walk examines; the walk stops there. */
2379
+ const CITED_SCAN_MAX_ENTRIES = 200000;
2380
+
2381
+ /** Anchor `n` of `prefix`, its number zero-padded to three digits. */
2382
+ function formatAnchorId(prefix, n) {
2383
+ return `${prefix}-${String(n).padStart(3, '0')}`;
2384
+ }
2385
+
2386
+ /** The highest number any ledger row's anchor of `prefix` carries, whatever its status, or 0. */
2387
+ function highestAnchorNumber(ledgerRows, prefix) {
2388
+ const anchorRe = new RegExp(`^${prefix}-(\\d+)$`);
2389
+ let highest = 0;
2390
+ for (const row of ledgerRows) {
2391
+ const m = isNonEmptyString(row.anchor_id) ? anchorRe.exec(row.anchor_id) : null;
2392
+ if (m) highest = Math.max(highest, parseInt(m[1], 10));
2393
+ }
2394
+ return highest;
2395
+ }
2396
+
2397
+ /**
2398
+ * The next anchor of `type`: one past the highest number any anchored ledger row
2399
+ * of that type carries, inactive rows included, so a retired number is never
2400
+ * reused. Decisions and pitfalls number separately. One pass over the rows.
2401
+ *
2402
+ * @param {object[]} ledgerRows
2403
+ * @param {'decision'|'pitfall'} type
2404
+ * @returns {{ anchorId: string, nextN: string }} nextN is the number, zero-padded to three digits
2405
+ * @throws {TypeError} for any other type
2406
+ */
2407
+ function nextAnchorFromLedger(ledgerRows, type) {
2408
+ const prefix = anchorPrefixFor(type);
2409
+ if (prefix === null) throw new TypeError(`nextAnchorFromLedger: type must be 'decision' or 'pitfall', got '${type}'`);
2410
+ const anchorId = formatAnchorId(prefix, highestAnchorNumber(ledgerRows, prefix) + 1);
2411
+ return { anchorId, nextN: anchorId.slice(prefix.length + 1) };
2412
+ }
2413
+
2414
+ /**
2415
+ * True when a project-relative path is one the cited-number scan never reads: the
2416
+ * learning tree, where every entry cites itself, or anything under an excluded
2417
+ * directory segment.
2418
+ *
2419
+ * @param {string} relPath - relative to the project root, either separator style
2420
+ * @returns {boolean}
2421
+ */
2422
+ function isCitedScanExcluded(relPath) {
2423
+ const norm = relPath.split(path.sep).join('/');
2424
+ if (norm === '.devflow/learning' || norm.startsWith('.devflow/learning/')) return true;
2425
+ return norm.split('/').some(segment => CITED_SCAN_EXCLUDED_SEGMENTS.includes(segment));
2426
+ }
2427
+
2428
+ /**
2429
+ * The files git tracks under `root`, relative to it.
2430
+ *
2431
+ * D-NO-FSMONITOR: `ls-files` reads the index, and reading the index runs the
2432
+ * command a repository's config names in `core.fsmonitor` — code chosen by the
2433
+ * repository this hook runs inside. The call turns it off for itself
2434
+ * (`-c core.fsmonitor=false`), so the listing stays a pure read. Reason: the
2435
+ * learning ops run inside any repository a session opens, and a read that ran
2436
+ * the repository's command would execute it with the user's privileges.
2437
+ *
2438
+ * @param {string} root - project root
2439
+ * @returns {string[]}
2440
+ * @throws when `root` is not in a git working tree, git is missing, or the call
2441
+ * times out or overflows its buffer; the caller then walks the tree instead
2442
+ */
2443
+ function listGitTrackedFiles(root) {
2444
+ return git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
2445
+ }
2446
+
2447
+ /**
2448
+ * The files under `root` a directory walk finds, relative to it and sorted — the
2449
+ * scan's fallback outside a git working tree. It never descends into an excluded
2450
+ * directory, follows no symbolic link (the directory entry of a link is neither a
2451
+ * file nor a directory) and stops after CITED_SCAN_MAX_ENTRIES entries. A
2452
+ * directory it cannot read is skipped.
2453
+ *
2454
+ * @param {string} root - project root
2455
+ * @returns {string[]}
2456
+ */
2457
+ function listFsWalkFiles(root) {
2458
+ const results = [];
2459
+ const stack = [''];
2460
+ let budget = CITED_SCAN_MAX_ENTRIES;
2461
+ while (stack.length > 0 && budget > 0) {
2462
+ const relDir = stack.pop();
2463
+ let entries;
2464
+ try {
2465
+ entries = fs.readdirSync(relDir ? path.join(root, relDir) : root, { withFileTypes: true });
2466
+ } catch {
2467
+ continue;
2468
+ }
2469
+ for (const entry of entries) {
2470
+ if (budget === 0) break;
2471
+ budget -= 1;
2472
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
2473
+ if (isCitedScanExcluded(relPath)) continue;
2474
+ if (entry.isDirectory()) stack.push(relPath);
2475
+ else if (entry.isFile()) results.push(relPath);
2476
+ }
2477
+ }
2478
+ return results.sort();
2479
+ }
2480
+
2481
+ /**
2482
+ * The text of a file the cited-number scan reads, or null for one it skips: a
2483
+ * file it cannot open or read, anything but a regular file — a symbolic link
2484
+ * included, since it opens with O_NOFOLLOW — a file over
2485
+ * CITED_SCAN_MAX_FILE_BYTES, and a binary file (one holding a NUL byte).
2486
+ * O_NONBLOCK keeps a FIFO from blocking the open, and the read never takes more
2487
+ * bytes than the size checked.
2488
+ *
2489
+ * @param {string} file
2490
+ * @returns {string|null}
2491
+ */
2492
+ function readScannedText(file) {
2493
+ let fd;
2494
+ try {
2495
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
2496
+ } catch {
2497
+ return null;
2498
+ }
2499
+ try {
2500
+ const stat = fs.fstatSync(fd);
2501
+ if (!stat.isFile() || stat.size > CITED_SCAN_MAX_FILE_BYTES) return null;
2502
+ const text = readOpenedText(fd, stat.size);
2503
+ return text.includes('\u0000') ? null : text;
2504
+ } catch {
2505
+ return null;
2506
+ } finally {
2507
+ fs.closeSync(fd);
2508
+ }
2509
+ }
2510
+
2511
+ /**
2512
+ * Every anchor the project's files cite as a whole word, each mapped to its first
2513
+ * citation: the scan behind D-E4-SKIP. It reads the files git tracks under `root`
2514
+ * (D-NO-FSMONITOR) or, outside a git working tree, the files a directory walk
2515
+ * finds — never the learning tree, an excluded directory, a symbolic link, a
2516
+ * binary file or a file over the size cap. A file it cannot read is passed over.
2517
+ * It writes nothing and takes no lock.
2518
+ *
2519
+ * @param {string} root - project root
2520
+ * @returns {Map<string, { file: string, line: number }>} the file relative to
2521
+ * `root`, the line 1-based; citations in listing order
2522
+ */
2523
+ function collectCitedAnchorIds(root) {
2524
+ let files;
2525
+ try {
2526
+ files = listGitTrackedFiles(root);
2527
+ } catch {
2528
+ files = listFsWalkFiles(root);
2529
+ }
2530
+ const cited = new Map();
2531
+ for (const relPath of files) {
2532
+ if (isCitedScanExcluded(relPath)) continue;
2533
+ const text = readScannedText(path.join(root, relPath));
2534
+ if (text === null || !(text.includes('ADR-') || text.includes('PF-'))) continue;
2535
+ const lines = text.split('\n');
2536
+ for (let i = 0; i < lines.length; i++) {
2537
+ for (const m of lines[i].matchAll(ANCHOR_WORD_RE)) {
2538
+ if (!cited.has(m[0])) cited.set(m[0], { file: relPath, line: i + 1 });
2539
+ }
2540
+ }
2541
+ }
2542
+ return cited;
2543
+ }
2544
+
2545
+ /** Today on the clock `now`, as the ledger's date fields hold it: YYYY-MM-DD, UTC. */
2546
+ function isoDate(now) {
2547
+ return new Date(now).toISOString().slice(0, 10);
2548
+ }
2549
+
2550
+ /** An assign-anchor refusal: `message` follows the op name. */
2551
+ function assignRefusal(kind, message) {
2552
+ return { ok: false, error: { kind, message: `assign-anchor: ${message}` } };
2553
+ }
2554
+
2555
+ /**
2556
+ * The anchor assign-anchor mints for `type` (D-E4-SKIP): nextAnchorFromLedger's,
2557
+ * or the first number after it that `citedAnchors` does not hold, skipping at most
2558
+ * E4_MAX_SKIPS numbers.
2559
+ *
2560
+ * @param {object[]} ledgerRows
2561
+ * @param {'decision'|'pitfall'} type
2562
+ * @param {ReadonlyMap<string, { file: string, line: number }>} citedAnchors
2563
+ * @returns {{ ok: true, value: { anchor_id: string, skipped: Array<{ anchor_id: string, file: string, line: number }> } }
2564
+ * | { ok: false, error: { kind: 'cited-numbers-exhausted', message: string } }}
2565
+ */
2566
+ function mintAnchor(ledgerRows, type, citedAnchors) {
2567
+ const prefix = anchorPrefixFor(type);
2568
+ const first = highestAnchorNumber(ledgerRows, prefix) + 1;
2569
+ const skipped = [];
2570
+ for (let n = first; n <= first + E4_MAX_SKIPS; n++) {
2571
+ const anchorId = formatAnchorId(prefix, n);
2572
+ const citation = citedAnchors.get(anchorId);
2573
+ if (!citation) return { ok: true, value: { anchor_id: anchorId, skipped } };
2574
+ skipped.push({ anchor_id: anchorId, file: citation.file, line: citation.line });
2575
+ }
2576
+ const last = formatAnchorId(prefix, first + E4_MAX_SKIPS);
2577
+ return assignRefusal(
2578
+ 'cited-numbers-exhausted',
2579
+ `${formatAnchorId(prefix, first)} to ${last} are all cited in tracked files; nothing was written`,
2580
+ );
2581
+ }
2582
+
2583
+ /**
2584
+ * Promote a v2 observation to a new ledger entry of `type` — the assign-anchor op.
2585
+ *
2586
+ * D-E4-SKIP: assign-anchor scans the project's tracked files once, before it takes
2587
+ * the learning lock, for every anchor they cite as a whole word, and mints the
2588
+ * first number past the type's highest anchored number that no file cites; it
2589
+ * reports each number it skips, skips at most E4_MAX_SKIPS of them and refuses
2590
+ * when the next one is cited too. The scan reads neither the learning tree nor
2591
+ * .git or a vendored or build directory (node_modules, target, dist), nor a
2592
+ * symbolic link, a binary file or a file over 5 MB. Reason: a document can cite a
2593
+ * number the ledger has not minted yet, and minting over it silently binds that
2594
+ * citation to an unrelated entry; refusing stopped the run until a person
2595
+ * renamed the citation, while a skipped number costs only a gap.
2596
+ *
2597
+ * The observation must be one v2 log row of `type` that no ledger row carries,
2598
+ * whatever its status (D-LEDGER-REGISTRY). The new row is its projection
2599
+ * (toLedgerRowV2) with the type's active status and today as both date and
2600
+ * last_verified; it is appended to the ledger and the files are re-rendered, all
2601
+ * under the learning lock. The log is never written. Like every writer it backs
2602
+ * up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines the ledger's malformed
2603
+ * lines before it rewrites the ledger (D-QUARANTINE-MALFORMED); a refusal writes
2604
+ * nothing.
2605
+ *
2606
+ * @param {string} root - project root
2607
+ * @param {'decision'|'pitfall'} type
2608
+ * @param {string} obsId - an observation id
2609
+ * @param {{ now?: number, timeoutMs?: number, citedAnchors?: ReadonlyMap<string, { file: string, line: number }> }} [opts]
2610
+ * now: epoch ms (default Date.now()); citedAnchors: default collectCitedAnchorIds(root)
2611
+ * @returns {{ ok: true, value: { anchor_id: string, skipped: Array<{ anchor_id: string, file: string, line: number }> } }
2612
+ * | { ok: false, error: { kind: string, message: string } }}
2613
+ * Error kinds: not-in-log, duplicate-log-id, already-promoted, v1-observation,
2614
+ * type-mismatch, cited-numbers-exhausted, and withDecisionsLock's
2615
+ * not-a-directory, no-learning-dir and busy.
2616
+ * @throws {TypeError} for a type other than decision or pitfall, or a malformed obsId
2617
+ */
2618
+ function assignAnchor(root, type, obsId, { now = Date.now(), timeoutMs, citedAnchors } = {}) {
2619
+ if (anchorPrefixFor(type) === null) throw new TypeError(`assignAnchor: type must be 'decision' or 'pitfall', got '${type}'`);
2620
+ if (typeof obsId !== 'string' || !OBS_ID_RE.test(obsId)) throw new TypeError('assignAnchor: obsId must be an observation id');
2621
+ if (!hasLearningDir(root)) return noLearningDir('assign-anchor', root);
2622
+ const cited = citedAnchors || collectCitedAnchorIds(root);
2623
+ return withDecisionsLock('assign-anchor', root, () => assignUnderLock(root, type, obsId, { now, cited }), { timeoutMs });
2624
+ }
2625
+
2626
+ /** assignAnchor's locked body. */
2627
+ function assignUnderLock(root, type, obsId, { now, cited }) {
2628
+ const ledgerPath = getDecisionsLedgerPath(root);
2629
+ const ledger = readJsonl(ledgerPath);
2630
+ const log = readJsonl(getDecisionsLogPath(root));
2631
+
2632
+ const sameId = log.rows.filter(row => row.id === obsId);
2633
+ if (sameId.length === 0) {
2634
+ return assignRefusal('not-in-log', `'${obsId}' is not in the log; store it with put-observation --create first`);
2635
+ }
2636
+ if (sameId.length > 1) {
2637
+ return assignRefusal('duplicate-log-id', `the log holds ${sameId.length} rows with id '${obsId}'; nothing was written`);
2638
+ }
2639
+ const carriers = ledgerRegistry(ledger.rows).byObsId.get(obsId) || [];
2640
+ if (carriers.length > 0) {
2641
+ const entries = sortedByAnchor(carriers).map(row => `${listingToken(row.anchor_id)} ${listingToken(row.decisions_status)}`);
2642
+ return assignRefusal('already-promoted', `'${obsId}' is already promoted (${entries.join(', ')}); nothing was written`);
2643
+ }
2644
+ const [logRow] = sameId;
2645
+ if (!isV2(logRow)) {
2646
+ return assignRefusal('v1-observation', `'${obsId}' is a v1 observation; rewrite it with put-observation --update first`);
2647
+ }
2648
+ if (logRow.type !== type) {
2649
+ return assignRefusal('type-mismatch', `'${obsId}' is a ${listingToken(logRow.type)} observation, not a ${type}; nothing was written`);
2650
+ }
2651
+ const minted = mintAnchor(ledger.rows, type, cited);
2652
+ if (!minted.ok) return minted;
2653
+
2654
+ const today = isoDate(now);
2655
+ const row = toLedgerRowV2(logRow, { last_verified: today }, {
2656
+ anchorId: minted.value.anchor_id,
2657
+ status: activeStatusFor(type),
2658
+ date: today,
2659
+ expectType: type,
2660
+ });
2661
+ ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
2662
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
2663
+ const ledgerRows = [...ledger.rows, row];
2664
+ writeJsonlAtomic(ledgerPath, ledgerRows);
2665
+ renderAll(root, ledgerRows);
2666
+ return minted;
2667
+ }
2668
+
2669
+ // ---------------------------------------------------------------------------
2670
+ // refresh-anchor
2671
+ // ---------------------------------------------------------------------------
2672
+
2673
+ /**
2674
+ * The one ledger row carrying `anchorId`, or why there is not exactly one: the
2675
+ * anchor is `not in the ledger`, or `held by <n> ledger rows`.
2676
+ *
2677
+ * @param {object[]} ledgerRows
2678
+ * @param {string} anchorId
2679
+ * @returns {{ row: object } | { kind: 'not-found'|'duplicate-anchor', reason: string }}
2680
+ */
2681
+ function rowCarrying(ledgerRows, anchorId) {
2682
+ const rows = ledgerRows.filter(row => row.anchor_id === anchorId);
2683
+ if (rows.length === 1) return { row: rows[0] };
2684
+ return rows.length === 0
2685
+ ? { kind: 'not-found', reason: 'not in the ledger' }
2686
+ : { kind: 'duplicate-anchor', reason: `held by ${rows.length} ledger rows` };
2687
+ }
2688
+
2689
+ /**
2690
+ * Why ledger row `row` is not an active v2 entry, or null: it is inactive, or v1.
2691
+ *
2692
+ * @param {object} row
2693
+ * @returns {string|null}
2694
+ */
2695
+ function activeV2EntryProblem(row) {
2696
+ if (!isActive(row)) return `${listingToken(row.decisions_status)}; restore it with restore-anchor first`;
2697
+ if (!isV2(row)) return 'a v1 entry; rewrite it with put-observation --update';
2698
+ return null;
2699
+ }
2700
+
2701
+ /**
2702
+ * The log row active entry `row` re-projects from, or why it cannot: it has no
2703
+ * observation id, or the log holds no row, two rows or a v1 row with that id, or
2704
+ * one of a type the entry cannot take.
2705
+ *
2706
+ * @param {object} row - an active v2 ledger row
2707
+ * @param {object[]} logRows
2708
+ * @returns {{ logRow: object } | { problem: string }}
2709
+ */
2710
+ function reprojectionSource(row, logRows) {
2711
+ if (!isNonEmptyString(row.id)) return { problem: 'has no observation id' };
2712
+ const sameId = logRows.filter(logRow => logRow.id === row.id);
2713
+ if (sameId.length === 0) return { problem: `no log row has id '${row.id}'; write it back with put-observation --create` };
2714
+ if (sameId.length > 1) return { problem: `the log holds ${sameId.length} rows with id '${row.id}'` };
2715
+ const [logRow] = sameId;
2716
+ if (!isV2(logRow)) return { problem: 'its log row is v1; rewrite it with put-observation --update' };
2717
+ const problem = reprojectionProblem(row, logRow.type);
2718
+ return problem ? { problem: `cannot take its log row: ${problem}` } : { logRow };
2719
+ }
2720
+
2721
+ /** The refusal of a refresh batch: every refused anchor, one per line, each problem on one line. */
2722
+ function refreshRefusal(problems, total) {
2723
+ const listed = problems.map(({ anchor_id, message }) => ({ anchor_id, message: singleLine(message) }));
2724
+ const head = `refresh-anchor: ${listed.length} of ${total} anchor${total === 1 ? '' : 's'} refused; nothing was written`;
2725
+ const lines = listed.map(({ anchor_id, message }) => ` ${anchor_id}: ${message}`);
2726
+ return { ok: false, error: { kind: 'refused', message: [head, ...lines].join('\n'), problems: listed } };
2727
+ }
2728
+
2729
+ /** True when two ledger rows differ in any projected content field. */
2730
+ function projectedContentDiffers(a, b) {
2731
+ return PROJECTED_CONTENT_KEYS.some(key => !sameJson(a[key], b[key]));
2732
+ }
2733
+
2734
+ /**
2735
+ * Record in history, once per observation, the prior ledger rows and the log row
2736
+ * of each re-projection that changes an entry's content (D-CONTENT-HISTORY).
2737
+ */
2738
+ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
2739
+ const carriers = ledgerRegistry(ledgerRows).byObsId;
2740
+ const recorded = new Set();
2741
+ for (const { prior, next, logRow } of plans) {
2742
+ if (recorded.has(logRow.id) || !projectedContentDiffers(prior, next)) continue;
2743
+ recorded.add(logRow.id);
2744
+ appendHistory(root, { id: logRow.id, ledger: carriers.get(logRow.id) || [], log: logRow }, { now });
2745
+ }
2746
+ }
2747
+
2748
+ /**
2749
+ * Re-project active v2 entries from their log rows, or stamp them verified — the
2750
+ * refresh-anchor op.
2751
+ *
2752
+ * Without `verified`, each entry is re-projected from its log row through
2753
+ * toLedgerRowV2 (D-LOG-CONTENT-AUTHORITY): it takes the log row's content
2754
+ * whatever the ledger held, so a rewrite replaces the old text in place, and the
2755
+ * prior ledger rows go to history first whenever an entry's content changes
2756
+ * (D-CONTENT-HISTORY). With `verified`, each entry's last_verified becomes today
2757
+ * and nothing else changes; no log row is needed.
2758
+ *
2759
+ * The batch is all or nothing: an anchor the ledger does not hold or holds twice,
2760
+ * an inactive entry or a v1 one, and — when re-projecting — an entry with no
2761
+ * observation id or whose log row is missing, doubled, v1 or of a type it cannot
2762
+ * take refuses the whole batch, every refused anchor listed, nothing written. A
2763
+ * batch that changes no row writes nothing. Otherwise, under the learning lock,
2764
+ * it backs up a v1 tree (D-V1-BACKUP-ONCE), quarantines the ledger's malformed
2765
+ * lines (D-QUARANTINE-MALFORMED), then writes the ledger once and renders once.
2766
+ *
2767
+ * @param {string} root - project root
2768
+ * @param {string[]} anchorIds - one or more anchor ids; a repeated one counts once
2769
+ * @param {{ verified?: boolean, now?: number, timeoutMs?: number }} [opts]
2770
+ * now: epoch ms (default Date.now())
2771
+ * @returns {{ ok: true, value: { refreshed: Array<{ anchor_id: string, state: 'verified'|'reprojected'|'unchanged' }> } }
2772
+ * | { ok: false, error: { kind: string, message: string, problems?: Array<{ anchor_id: string, message: string }> } }}
2773
+ * refreshed: each anchor once, in the order given. Error kinds: refused (with
2774
+ * problems), and withDecisionsLock's not-a-directory, no-learning-dir and busy.
2775
+ * @throws {TypeError} when anchorIds is empty or holds anything but anchor ids
2776
+ */
2777
+ function refreshAnchors(root, anchorIds, { verified = false, now = Date.now(), timeoutMs } = {}) {
2778
+ const valid = Array.isArray(anchorIds) && anchorIds.length > 0
2779
+ && anchorIds.every(id => typeof id === 'string' && ANCHOR_ID_RE.test(id));
2780
+ if (!valid) throw new TypeError('refreshAnchors: anchorIds must hold one or more anchor ids');
2781
+ const anchors = [...new Set(anchorIds)];
2782
+ return withDecisionsLock('refresh-anchor', root, () => refreshUnderLock(root, anchors, { verified, now }), { timeoutMs });
2783
+ }
2784
+
2785
+ /** refreshAnchors' locked body. */
2786
+ function refreshUnderLock(root, anchors, { verified, now }) {
2787
+ const ledgerPath = getDecisionsLedgerPath(root);
2788
+ const ledger = readJsonl(ledgerPath);
2789
+ const log = readJsonl(getDecisionsLogPath(root));
2790
+ const today = isoDate(now);
2791
+
2792
+ const problems = [];
2793
+ const plans = [];
2794
+ for (const anchorId of anchors) {
2795
+ const found = rowCarrying(ledger.rows, anchorId);
2796
+ const entryProblem = found.row ? activeV2EntryProblem(found.row) : found.reason;
2797
+ if (entryProblem) {
2798
+ problems.push({ anchor_id: anchorId, message: entryProblem });
2799
+ continue;
2800
+ }
2801
+ const prior = found.row;
2802
+ if (verified) {
2803
+ plans.push({ anchor_id: anchorId, prior, next: withLedgerFields(prior, { last_verified: today }) });
2804
+ continue;
2805
+ }
2806
+ const source = reprojectionSource(prior, log.rows);
2807
+ if (source.problem) problems.push({ anchor_id: anchorId, message: source.problem });
2808
+ else plans.push({ anchor_id: anchorId, prior, next: reprojectedRow(source.logRow, prior), logRow: source.logRow });
2809
+ }
2810
+ if (problems.length > 0) return refreshRefusal(problems, anchors.length);
2811
+
2812
+ const changed = plans.filter(plan => !sameJson(plan.prior, plan.next));
2813
+ const stateOf = plan => {
2814
+ if (verified) return 'verified';
2815
+ return changed.includes(plan) ? 'reprojected' : 'unchanged';
2816
+ };
2817
+ const refreshed = plans.map(plan => ({ anchor_id: plan.anchor_id, state: stateOf(plan) }));
2818
+ if (changed.length === 0) return { ok: true, value: { refreshed } };
2819
+
2820
+ ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
2821
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
2822
+ if (!verified) recordRefreshHistory(root, changed, ledger.rows, { now });
2823
+ const replaced = new Map(changed.map(plan => [plan.prior, plan.next]));
2824
+ const ledgerRows = ledger.rows.map(row => replaced.get(row) || row);
2825
+ writeJsonlAtomic(ledgerPath, ledgerRows);
2826
+ renderAll(root, ledgerRows);
2827
+ return { ok: true, value: { refreshed } };
2828
+ }
2829
+
2830
+ // ---------------------------------------------------------------------------
2831
+ // retire-anchor and restore-anchor
2832
+ // ---------------------------------------------------------------------------
2833
+
2834
+ /** The stdin keys each inactive status takes. */
2835
+ const RETIRE_INPUT_KEYS = Object.freeze({
2836
+ Encoded: Object.freeze(['at', 'quote']),
2837
+ Superseded: Object.freeze(['by']),
2838
+ Retired: Object.freeze(['reason']),
2839
+ Deprecated: Object.freeze(['reason']),
2840
+ });
2841
+
2842
+ /** The ledger-owned fields that say why an entry is inactive: a retirement sets one and retired_on; restore clears them. */
2843
+ const RETIREMENT_FIELDS = Object.freeze(['status_note', 'superseded_by', 'encoded_at', 'retired_on']);
2844
+
2845
+ /** `keys` mapped to undefined: the withLedgerFields updates that remove them. */
2846
+ function removing(keys) {
2847
+ return Object.fromEntries(keys.map(key => [key, undefined]));
2848
+ }
2849
+
2850
+ /**
2851
+ * `row` with decisions_status `status` and the ledger-owned fields in `updates`
2852
+ * (withLedgerFields). A status key the row has keeps its place; a row without one
2853
+ * takes it after anchor_id, where the projection puts it.
2854
+ *
2855
+ * @param {object} row - a ledger row that has an anchor_id: its caller found it by anchor
2856
+ * @param {string} status
2857
+ * @param {Record<string, unknown>} updates - ledger-owned fields only
2858
+ * @returns {object} a new row
2859
+ */
2860
+ function withEntryStatus(row, status, updates) {
2861
+ const next = withLedgerFields(row, updates);
2862
+ if (Object.prototype.hasOwnProperty.call(next, 'decisions_status')) {
2863
+ next.decisions_status = status;
2864
+ return next;
2865
+ }
2866
+ const placed = {};
2867
+ for (const [key, value] of Object.entries(next)) {
2868
+ placed[key] = value;
2869
+ if (key === 'anchor_id') placed.decisions_status = status;
2870
+ }
2871
+ return placed;
2872
+ }
2873
+
2874
+ /** The problem with an Encoded path, or null: it names a file from the repository root. */
2875
+ function encodedPathProblem(at) {
2876
+ if (at.startsWith('/')) return 'must be relative to the repository root';
2877
+ if (at.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
2878
+ return 'must not hold an empty, . or .. segment';
2879
+ }
2880
+ return null;
2881
+ }
2882
+
2883
+ /** The problem with an Encoded quote too short once its whitespace collapses, or null. */
2884
+ function quoteLengthProblem(quote) {
2885
+ const length = codePointLength(normalizeWhitespace(quote));
2886
+ return length < FIELD_LIMITS.quoteMin
2887
+ ? `is ${length} characters once its whitespace is collapsed, under the minimum of ${FIELD_LIMITS.quoteMin}`
2888
+ : null;
2889
+ }
2890
+
2891
+ /** The problem with a Superseded successor's shape, or null. */
2892
+ function successorShapeProblem(anchorId, by) {
2893
+ if (typeof by !== 'string' || !ANCHOR_ID_RE.test(by)) return 'must be an anchor id';
2894
+ if (by === anchorId) return 'names this entry; an entry cannot supersede itself';
2895
+ return null;
2896
+ }
2897
+
2898
+ /**
2899
+ * Every problem with a retire-anchor input for `status`: the keys it does not
2900
+ * take, in input order, then its own fields, each with at most one problem.
2901
+ *
2902
+ * @param {string} anchorId - the entry being retired
2903
+ * @param {string} status - an inactive status
2904
+ * @param {unknown} input - the parsed stdin object
2905
+ * @returns {Array<{ field: string, message: string }>}
2906
+ */
2907
+ function retireInputProblems(anchorId, status, input) {
2908
+ if (!isPlainObject(input)) return [{ field: '(input)', message: 'must be one JSON object' }];
2909
+ const accepted = RETIRE_INPUT_KEYS[status];
2910
+ const problems = Object.keys(input)
2911
+ .filter(key => !accepted.includes(key))
2912
+ .map(key => ({ field: key, message: `is not taken by ${status}, which takes ${accepted.join(' and ')}` }));
2913
+ const check = (field, problem) => {
2914
+ if (problem) problems.push({ field, message: problem });
2915
+ };
2916
+ const absent = key => (input[key] === undefined ? 'is required' : null);
2917
+ if (status === 'Encoded') {
2918
+ check('at', absent('at') || textProblem(input.at, FIELD_LIMITS.path) || encodedPathProblem(input.at));
2919
+ check('quote', absent('quote') || textProblem(input.quote, FIELD_LIMITS.quoteMax) || quoteLengthProblem(input.quote));
2920
+ } else if (status === 'Superseded') {
2921
+ check('by', absent('by') || successorShapeProblem(anchorId, input.by));
2922
+ } else {
2923
+ check('reason', absent('reason') || textProblem(input.reason, FIELD_LIMITS.note));
2924
+ }
2925
+ return problems;
2926
+ }
2927
+
2928
+ /** A retire-anchor refusal: `message` follows the op name. */
2929
+ function retireRefusal(kind, message) {
2930
+ return { ok: false, error: { kind, message: `retire-anchor: ${message}` } };
2931
+ }
2932
+
2933
+ /** A restore-anchor refusal: `message` follows the op name. */
2934
+ function restoreRefusal(kind, message) {
2935
+ return { ok: false, error: { kind, message: `restore-anchor: ${message}` } };
2936
+ }
2937
+
2938
+ /**
2939
+ * Check that `quote` appears in file `at` as committed at the verify ref, and
2940
+ * answer the encoded_at record retire-anchor keeps for it.
2941
+ *
2942
+ * D-ENCODED-QUOTE: an entry is retired as Encoded only with a path and a quote
2943
+ * from that file, and only when the quote, every run of whitespace collapsed to
2944
+ * one space on both sides, appears in the file as committed at the verify ref
2945
+ * (D-VERIFY-REF), read with `git cat-file blob <commit>:<path>`; the entry keeps
2946
+ * the path, the quote, the ref and the commit as encoded_at. Reason: a citation is
2947
+ * a claim that nothing else checks — a path alone can point anywhere — so only a
2948
+ * quote found at a ref every checkout shares shows that the lesson now lives in
2949
+ * that file, and the commit lets a later run look at what was checked.
2950
+ *
2951
+ * @param {string} root - project root
2952
+ * @param {string} at - a path from the repository root
2953
+ * @param {string} quote
2954
+ * @param {{ verifyRef?: { ref: 'origin/HEAD'|'HEAD', commit: string } | null }} [opts]
2955
+ * verifyRef: default resolveVerifyRef(root); null when there is no commit to check
2956
+ * @returns {{ ok: true, value: { path: string, quote: string, ref: string, commit: string } }
2957
+ * | { ok: false, error: { kind: 'no-verify-ref'|'not-at-ref'|'git-failed'|'quote-not-found', message: string } }}
2958
+ */
2959
+ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
2960
+ const checkedAt = verifyRef === undefined ? resolveVerifyRef(root) : verifyRef;
2961
+ if (checkedAt === null) {
2962
+ return retireRefusal('no-verify-ref', 'there is no commit to check the quote at (not a git repository, or no commit yet); nothing was written');
2963
+ }
2964
+ const where = `${checkedAt.ref} ${checkedAt.commit.slice(0, 12)}`;
2965
+ const shown = singleLine(at);
2966
+ let blob;
2967
+ try {
2968
+ blob = git(root, ['cat-file', 'blob', `${checkedAt.commit}:${at}`]);
2969
+ } catch (err) {
2970
+ if (err && typeof err.status === 'number') {
2971
+ return retireRefusal('not-at-ref', `'${shown}' is not a file at ${where}; nothing was written`);
2972
+ }
2973
+ return retireRefusal('git-failed', `could not read '${shown}' at ${where}: ${singleLine(String(err && err.message))}; nothing was written`);
2974
+ }
2975
+ if (!normalizeWhitespace(blob).includes(normalizeWhitespace(quote))) {
2976
+ return retireRefusal('quote-not-found', `the quote is not in '${shown}' at ${where}; nothing was written`);
2977
+ }
2978
+ return { ok: true, value: { path: at, quote, ref: checkedAt.ref, commit: checkedAt.commit } };
2979
+ }
2980
+
2981
+ /**
2982
+ * Make an active entry inactive — the retire-anchor op — with the stdin its new
2983
+ * status takes, every problem with that input reported at once:
2984
+ * Retired, Deprecated { reason } at most 120 characters, kept as status_note
2985
+ * Superseded { by } an active entry other than this one, of
2986
+ * either type, kept as superseded_by; every
2987
+ * inactive entry this one superseded is
2988
+ * re-pointed to it
2989
+ * Encoded { at, quote } checked at the verify ref and kept as
2990
+ * encoded_at (D-ENCODED-QUOTE)
2991
+ * Each also sets retired_on to today and clears the other notes; the rest of the
2992
+ * row stays as it is, so a v1 entry stays v1. An entry already inactive, an
2993
+ * anchor the ledger does not hold or holds twice, and a successor absent or
2994
+ * inactive are refused. The input and the quote are checked before the learning
2995
+ * lock is taken; under it the ledger is written once and the files are rendered,
2996
+ * after a v1 tree is backed up (D-V1-BACKUP-ONCE) and malformed ledger lines are
2997
+ * quarantined (D-QUARANTINE-MALFORMED). A refusal writes nothing.
2998
+ *
2999
+ * @param {string} root - project root
3000
+ * @param {string} anchorId
3001
+ * @param {'Encoded'|'Superseded'|'Retired'|'Deprecated'} status
3002
+ * @param {unknown} input - the parsed stdin object
3003
+ * @param {{ now?: number, timeoutMs?: number, verifyRef?: { ref: 'origin/HEAD'|'HEAD', commit: string } | null }} [opts]
3004
+ * now: epoch ms (default Date.now()); verifyRef: see quoteAtRef
3005
+ * @returns {{ ok: true, value: { anchor_id: string, status: string, repointed: string[] } }
3006
+ * | { ok: false, error: { kind: string, message: string, problems?: Array<{ field: string, message: string }> } }}
3007
+ * repointed: the entries re-pointed to the successor, in anchor order. Error
3008
+ * kinds: invalid-input (with problems), quoteAtRef's, not-found,
3009
+ * duplicate-anchor, already-inactive, successor-not-found,
3010
+ * successor-duplicate-anchor, successor-inactive, and withDecisionsLock's
3011
+ * not-a-directory, no-learning-dir and busy.
3012
+ * @throws {TypeError} for a malformed anchorId or a status that is not inactive
3013
+ */
3014
+ function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeoutMs, verifyRef } = {}) {
3015
+ if (typeof anchorId !== 'string' || !ANCHOR_ID_RE.test(anchorId)) throw new TypeError('retireAnchor: anchorId must be an anchor id');
3016
+ if (!INACTIVE_STATUSES.includes(status)) {
3017
+ throw new TypeError(`retireAnchor: status must be one of ${INACTIVE_STATUSES.join(', ')}, got '${status}'`);
3018
+ }
3019
+ if (!hasLearningDir(root)) return noLearningDir('retire-anchor', root);
3020
+ const problems = retireInputProblems(anchorId, status, input);
3021
+ if (problems.length > 0) return invalidInput('retire-anchor', problems);
3022
+ let note;
3023
+ if (status === 'Encoded') {
3024
+ const encoded = quoteAtRef(root, input.at, input.quote, { verifyRef });
3025
+ if (!encoded.ok) return encoded;
3026
+ note = { encoded_at: encoded.value };
3027
+ } else if (status === 'Superseded') {
3028
+ note = { superseded_by: input.by };
3029
+ } else {
3030
+ note = { status_note: input.reason };
3031
+ }
3032
+ return withDecisionsLock('retire-anchor', root, () => retireUnderLock(root, anchorId, status, note, { now }), { timeoutMs });
3033
+ }
3034
+
3035
+ /** retireAnchor's locked body. */
3036
+ function retireUnderLock(root, anchorId, status, note, { now }) {
3037
+ const ledgerPath = getDecisionsLedgerPath(root);
3038
+ const ledger = readJsonl(ledgerPath);
3039
+ const found = rowCarrying(ledger.rows, anchorId);
3040
+ if (!found.row) return retireRefusal(found.kind, `${anchorId} is ${found.reason}; nothing was written`);
3041
+ const prior = found.row;
3042
+ if (!isActive(prior)) {
3043
+ return retireRefusal('already-inactive', `${anchorId} is already ${listingToken(prior.decisions_status)}; nothing was written`);
3044
+ }
3045
+ if (status === 'Superseded') {
3046
+ const successor = rowCarrying(ledger.rows, note.superseded_by);
3047
+ if (!successor.row) {
3048
+ return retireRefusal(`successor-${successor.kind}`, `${note.superseded_by}, the successor, is ${successor.reason}; nothing was written`);
3049
+ }
3050
+ if (!isActive(successor.row)) {
3051
+ return retireRefusal(
3052
+ 'successor-inactive',
3053
+ `${note.superseded_by}, the successor, is ${listingToken(successor.row.decisions_status)}; nothing was written`,
3054
+ );
3055
+ }
3056
+ }
3057
+
3058
+ const retired = withEntryStatus(prior, status, { ...removing(RETIREMENT_FIELDS), ...note, retired_on: isoDate(now) });
3059
+ const repointed = status === 'Superseded'
3060
+ ? sortedByAnchor(ledger.rows.filter(row => row !== prior && !isActive(row) && row.superseded_by === anchorId))
3061
+ : [];
3062
+ const replaced = new Map([
3063
+ [prior, retired],
3064
+ ...repointed.map(row => [row, withLedgerFields(row, { superseded_by: note.superseded_by })]),
3065
+ ]);
3066
+ ensurePreV2Backup(root, { logRows: readJsonl(getDecisionsLogPath(root)).rows, ledgerRows: ledger.rows });
3067
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
3068
+ const ledgerRows = ledger.rows.map(row => replaced.get(row) || row);
3069
+ writeJsonlAtomic(ledgerPath, ledgerRows);
3070
+ renderAll(root, ledgerRows);
3071
+ return { ok: true, value: { anchor_id: anchorId, status, repointed: repointed.map(row => row.anchor_id) } };
3072
+ }
3073
+
3074
+ /**
3075
+ * Make an inactive entry active again — the restore-anchor op. The entry takes
3076
+ * its type's active status (Accepted for a decision, Active for a pitfall) and
3077
+ * loses its retirement notes, its last_verified and its last_attempt, so the next
3078
+ * claim-due hands it out ahead of every verified entry (D-DUE-ORDER); its content
3079
+ * and its date stay, and the files are re-rendered. An entry already active, an
3080
+ * anchor the ledger does not hold or holds twice, and a row whose type its anchor
3081
+ * does not name are refused. It writes as retireAnchor does, under the learning
3082
+ * lock; a refusal writes nothing.
3083
+ *
3084
+ * @param {string} root - project root
3085
+ * @param {string} anchorId
3086
+ * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
3087
+ * @returns {{ ok: true, value: { anchor_id: string, status: 'Accepted'|'Active' } }
3088
+ * | { ok: false, error: { kind: string, message: string } }}
3089
+ * Error kinds: not-found, duplicate-anchor, already-active, type-mismatch, and
3090
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
3091
+ * @throws {TypeError} for a malformed anchorId
3092
+ */
3093
+ function restoreAnchor(root, anchorId, { now = Date.now(), timeoutMs } = {}) {
3094
+ if (typeof anchorId !== 'string' || !ANCHOR_ID_RE.test(anchorId)) throw new TypeError('restoreAnchor: anchorId must be an anchor id');
3095
+ return withDecisionsLock('restore-anchor', root, () => restoreUnderLock(root, anchorId, { now }), { timeoutMs });
3096
+ }
3097
+
3098
+ /** restoreAnchor's locked body. */
3099
+ function restoreUnderLock(root, anchorId, { now }) {
3100
+ const ledgerPath = getDecisionsLedgerPath(root);
3101
+ const ledger = readJsonl(ledgerPath);
3102
+ const found = rowCarrying(ledger.rows, anchorId);
3103
+ if (!found.row) return restoreRefusal(found.kind, `${anchorId} is ${found.reason}; nothing was written`);
3104
+ const prior = found.row;
3105
+ if (isActive(prior)) return restoreRefusal('already-active', `${anchorId} is already active; nothing was written`);
3106
+ const prefix = anchorPrefixFor(prior.type);
3107
+ if (prefix === null || !anchorId.startsWith(`${prefix}-`)) {
3108
+ return restoreRefusal(
3109
+ 'type-mismatch',
3110
+ `${anchorId} holds a row of type ${listingToken(prior.type)}, which its anchor does not name; nothing was written`,
3111
+ );
3112
+ }
3113
+
3114
+ const status = activeStatusFor(prior.type);
3115
+ const restored = withEntryStatus(prior, status, removing([...RETIREMENT_FIELDS, 'last_verified', 'last_attempt']));
3116
+ ensurePreV2Backup(root, { logRows: readJsonl(getDecisionsLogPath(root)).rows, ledgerRows: ledger.rows });
3117
+ quarantineRejected(ledgerPath, ledger.rejected, { now });
3118
+ const ledgerRows = ledger.rows.map(row => (row === prior ? restored : row));
3119
+ writeJsonlAtomic(ledgerPath, ledgerRows);
3120
+ renderAll(root, ledgerRows);
3121
+ return { ok: true, value: { anchor_id: anchorId, status } };
3122
+ }
3123
+
3124
+ module.exports = {
3125
+ // Constants
3126
+ SCHEMA_VERSION,
3127
+ FIELD_LIMITS,
3128
+ ACTIVE_STATUSES,
3129
+ INACTIVE_STATUSES,
3130
+ ENTRY_STATUSES,
3131
+ CONTENT_KEYS,
3132
+ PLUMBING_OWNED_KEYS,
3133
+ LEDGER_OWNED_KEYS,
3134
+ DUE,
3135
+ HISTORY_DEPTH,
3136
+ LOCK_ACQUIRE_TIMEOUT_MS,
3137
+ LOCK_STALE_MS,
3138
+ ANCHOR_ID_RE,
3139
+ OBS_ID_RE,
3140
+ // Status helpers
3141
+ isActive,
3142
+ activeStatusFor,
3143
+ isV2,
3144
+ // Text shared with the renderer
3145
+ singleLine,
3146
+ inactiveNote,
3147
+ // JSONL I/O
3148
+ readJsonl,
3149
+ rejectedPathFor,
3150
+ quarantineRejected,
3151
+ readJsonlForWrite,
3152
+ writeExclusive,
3153
+ writeFileAtomic,
3154
+ writeJsonlAtomic,
3155
+ // Locking
3156
+ hasLearningDir,
3157
+ withDecisionsLock,
3158
+ // State and the ledger registry
3159
+ readLearningState,
3160
+ ledgerRegistry,
3161
+ // Validation
3162
+ validateObservationInput,
3163
+ gitScopeMatcher,
3164
+ // Projection and counters
3165
+ toLedgerRowV2,
3166
+ toV2Counters,
3167
+ // History and the pre-v2 backup
3168
+ appendHistory,
3169
+ historyVersions,
3170
+ ensurePreV2Backup,
3171
+ // Rotation, clearing and resetting
3172
+ rotateObservations,
3173
+ clearUnreferenced,
3174
+ resetLearning,
3175
+ // The queue claim
3176
+ CLAIM_STALE_SECS,
3177
+ CLAIM_TOKEN_RE,
3178
+ CLAIM_OWNER_MAX_BYTES,
3179
+ newClaimToken,
3180
+ claimQueue,
3181
+ releaseClaim,
3182
+ touchClaim,
3183
+ // Integrity, listing, due selection and show
3184
+ integrityFlags,
3185
+ buildListing,
3186
+ entrySize,
3187
+ selectDue,
3188
+ showEntry,
3189
+ resolveVerifyRef,
3190
+ // Entry ops
3191
+ putObservation,
3192
+ readListing,
3193
+ formatListing,
3194
+ showByKey,
3195
+ claimDue,
3196
+ // assign-anchor
3197
+ E4_MAX_SKIPS,
3198
+ nextAnchorFromLedger,
3199
+ collectCitedAnchorIds,
3200
+ assignAnchor,
3201
+ // refresh-anchor
3202
+ refreshAnchors,
3203
+ // retire-anchor and restore-anchor
3204
+ quoteAtRef,
3205
+ retireAnchor,
3206
+ restoreAnchor,
3207
+ };