devflow-kit 3.0.1 → 3.1.0

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