devflow-kit 3.0.0 → 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 (134) hide show
  1. package/CHANGELOG.md +22 -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 +25 -11
  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/src/targets/claude-code/templates/managed-settings.json +3 -3
  133. package/dist/core/observation-io.js +0 -50
  134. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/assets/scripts/hooks/json-helper.cjs
4
- // Provides jq-equivalent operations for hooks when jq is not installed.
5
- // SECURITY: This is a local CLI helper invoked only by shell hooks with controlled arguments.
6
- // File path arguments come from hook-owned variables, not from external/untrusted input.
4
+ // Provides jq-equivalent operations for hooks when jq is not installed, and the
5
+ // learning ops the Learning agent runs from the project root.
6
+ // SECURITY: This is a local CLI helper invoked only by shell hooks and the
7
+ // Learning agent with controlled arguments. No operation takes a file path: a
8
+ // file's content arrives on stdin (json-parse redirects it), and the learning ops
9
+ // build every path from the current directory.
7
10
  // Usage: node json-helper.cjs <operation> [args...]
8
11
  //
9
12
  // Operations:
10
13
  // get-field <field> [default] Read field from stdin JSON
11
- // get-field-file <file> <field> [def] Read field from JSON file
12
14
  // validate Exit 0 if stdin is valid JSON, 1 otherwise
13
15
  // compact Compact stdin JSON to single line
14
16
  // construct <json-template> [--arg k v] Build JSON object with args
@@ -17,414 +19,158 @@
17
19
  // extract-cwd-field <field> Extract cwd + arbitrary field, SOH-byte delimited
18
20
  // extract-text-messages Extract text content from Claude message format
19
21
  // merge-evidence Flatten, dedupe, limit to 10 from stdin JSON
20
- // slurp-sort <file> <field> [limit] Read JSONL, sort by field desc, limit results
21
- // slurp-cap <file> <field> <limit> Read JSONL, sort by field desc, output limit lines
22
+ // slurp-sort <field> [limit] Read stdin JSONL, sort by field desc, limit results
23
+ // slurp-cap <field> [limit] Read stdin JSONL, sort by field desc, output limit lines
22
24
  // array-length <path> Get length of array at dotted path in stdin JSON
23
25
  // array-item <path> <index> Get item at index from array at path in stdin JSON
24
26
  // session-output <context> Build SessionStart output envelope
25
27
  // prompt-output <context> Build UserPromptSubmit output envelope
26
28
  // backup-construct Build pre-compact backup JSON from --arg pairs
27
- // assign-anchor <type> <obs_id> [--allow-collision]
28
- // Claim next ADR/PF number, render both .md files.
29
- // Refuses on a pre-mint citation collision (E4) unless
30
- // --allow-collision is passed.
31
- // next-anchor <type> Read-only: print the next candidate ADR/PF id and
32
- // any pre-mint collision hits; mutates nothing (E4)
33
- // retire-anchor <anchor_id> <status> Flip ledger row status, re-render both .md files
34
- // refresh-anchor <anchor_id> Re-project log obs onto ledger row, re-render
35
- // rotate-observations [<log>] [<arch>] Archive observing rows older than 30 days
29
+ // assign-anchor <decision|pitfall> <obs_id>
30
+ // Promote a v2 observation to the next ADR/PF number,
31
+ // skipping numbers tracked files cite; re-renders
32
+ // retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
33
+ // Make an entry inactive with the one JSON object on
34
+ // stdin its status takes; re-renders
35
+ // restore-anchor <anchor> Make an inactive entry active again and due for
36
+ // maintenance again, ordered after integrity problems and
37
+ // legacy entries (among them if it is one); re-renders
38
+ // refresh-anchor <anchor>... [--verified]
39
+ // Re-project active v2 entries from the log, or stamp
40
+ // them verified today; re-renders
41
+ // rotate-observations Archive unreferenced observations idle 30+ days
42
+ // put-observation --create|--update|--reinforce
43
+ // Store one observation from one JSON object on
44
+ // stdin; re-projects and re-renders its entries
45
+ // list Read-only: print the ledger and the log by section
46
+ // show <anchor|obs_id> Read-only: print one entry as pretty JSON
47
+ // claim-due Hand out the entries due for maintenance, leased
48
+ // for a day, after the ref their claims are checked at
49
+ // claim-queue Claim the learning queue for this run; prints
50
+ // claimed <token>[ takeover] | busy | none
51
+ // release-claim <token> Release the claim the token owns; prints
52
+ // released | not-owner | gone
53
+ //
54
+ // Every learning op above except claim-queue and release-claim first refreshes
55
+ // the mtime of an existing queue claim — the heartbeat (D-OWNED-CLAIM).
36
56
 
37
57
  'use strict';
38
58
 
39
59
  const fs = require('fs');
40
- const path = require('path');
41
- const { execFileSync } = require('child_process');
60
+ const { constants: { MAX_STRING_LENGTH } } = require('buffer');
42
61
 
43
62
  const op = process.argv[2];
44
63
  const args = process.argv.slice(3);
45
64
 
46
- const { safePath } = require('./lib/safe-path.cjs');
47
- const {
48
- getDecisionsUsagePath,
49
- getDecisionsLockDir,
50
- getDecisionsLedgerPath,
51
- getDecisionsLogPath,
52
- getDecisionsArchivePath,
53
- getObservationsLockDir,
54
- } = require('./lib/project-paths.cjs');
55
- const {
56
- initDecisionsContent,
57
- toLedgerRow,
58
- } = require('./lib/decisions-format.cjs');
59
- const {
60
- renderAndWriteAll,
61
- parseLedger,
62
- } = require('./lib/render-decisions.cjs');
63
- const { acquireMkdirLock, releaseLock } = require('./lib/mkdir-lock.cjs');
64
-
65
- function readStdin() {
66
- try {
67
- return fs.readFileSync('/dev/stdin', 'utf8').trim();
68
- } catch {
69
- return '';
70
- }
71
- }
72
-
73
- function getNestedField(obj, field) {
74
- const parts = field.split('.');
75
- let current = obj;
76
- for (const part of parts) {
77
- if (current == null || typeof current !== 'object') return undefined;
78
- current = current[part];
79
- }
80
- return current;
81
- }
82
-
83
- function parseJsonl(file) {
84
- const lines = fs.readFileSync(safePath(file), 'utf8').trim().split('\n').filter(Boolean);
85
- return lines.map(l => {
86
- try { return JSON.parse(l); } catch { return null; }
87
- }).filter(Boolean);
88
- }
89
-
90
- /**
91
- * Strip leading YAML frontmatter from content that the model may have included
92
- * despite being told not to. Belt-and-suspenders defense against duplicate frontmatter.
93
- */
94
- function stripLeadingFrontmatter(text) {
95
- if (!text) return '';
96
- const trimmed = text.replace(/^\s*\n/, '');
97
- if (!trimmed.startsWith('---')) return text;
98
- const match = trimmed.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
99
- return match ? trimmed.slice(match[0].length) : text;
100
- }
101
-
102
- /**
103
- * Write `tmp` with O_EXCL (wx flag) so the kernel rejects the open if a file or
104
- * symlink already exists at that path, preventing TOCTOU symlink-follow attacks.
105
- * On EEXIST (stale or attacker-placed .tmp) we unlink and retry once.
106
- * @param {string} tmp - Path to the temporary file.
107
- * @param {string} content - Content to write.
108
- */
109
- function writeExclusive(tmp, content) {
110
- try {
111
- fs.writeFileSync(tmp, content, { flag: 'wx' });
112
- } catch (err) {
113
- if (err.code !== 'EEXIST') throw err;
114
- // Stale or attacker-placed .tmp — remove it and retry once.
115
- try { fs.unlinkSync(tmp); } catch { /* race — already removed */ }
116
- fs.writeFileSync(tmp, content, { flag: 'wx' });
117
- }
118
- }
119
-
120
- function writeJsonlAtomic(file, entries) {
121
- // PID-scope the tmp name so concurrent writers from different processes
122
- // never collide on the same .tmp path. mirrors fs-atomic.ts and proxy-log.ts.
123
- const tmp = file + '.tmp.' + process.pid;
124
- const content = entries.length > 0
125
- ? entries.map(e => JSON.stringify(e)).join('\n') + '\n'
126
- : '';
127
- writeExclusive(tmp, content);
128
- fs.renameSync(tmp, file);
129
- }
130
-
131
- /** Atomically write a text file via a .tmp sibling and rename. */
132
- function writeFileAtomic(file, content) {
133
- // PID-scope the tmp name so concurrent writers from different processes
134
- // never collide on the same .tmp path. mirrors fs-atomic.ts and proxy-log.ts.
135
- const tmp = file + '.tmp.' + process.pid;
136
- writeExclusive(tmp, content);
137
- fs.renameSync(tmp, file);
138
- }
65
+ /** The learning modules, once loaded; see learning(). */
66
+ let learningModules = null;
139
67
 
140
68
  /**
141
- * Compute the next anchor ID for the given type by scanning the anchored ledger.
142
- * O(anchored) — single pass. Includes ALL anchored rows (Retired, Deprecated, Superseded).
143
- * ADR and PF sequences are independent.
69
+ * The learning store, loaded on first use and memoized. The generic ops never
70
+ * call it, so a hook that falls back from jq to node never pays for loading it,
71
+ * and the store loads the renderer only when an op renders.
144
72
  *
145
- * @param {object[]} ledgerRows - All rows from the ledger (from parseLedger)
146
- * @param {'decision'|'pitfall'} type
147
- * @returns {{ anchorId: string, nextN: string }}
73
+ * @returns {{ store: object }}
148
74
  */
149
- function nextAnchorFromLedger(ledgerRows, type) {
150
- const prefix = type === 'decision' ? 'ADR' : 'PF';
151
- const prefixRe = new RegExp(`^${prefix}-`);
152
- let maxN = 0;
153
- for (const row of ledgerRows) {
154
- if (!row.anchor_id || !prefixRe.test(row.anchor_id)) continue;
155
- const m = row.anchor_id.match(/(\d+)$/);
156
- if (m) {
157
- const n = parseInt(m[1], 10);
158
- if (n > maxN) maxN = n;
159
- }
75
+ function learning() {
76
+ if (learningModules === null) {
77
+ learningModules = { store: require('./lib/learning-store.cjs') };
160
78
  }
161
- const nextN = (maxN + 1).toString().padStart(3, '0');
162
- return { anchorId: `${prefix}-${nextN}`, nextN };
79
+ return learningModules;
163
80
  }
164
81
 
165
- // ---------------------------------------------------------------------------
166
- // Pre-mint collision guard (E4).
167
- //
168
- // A design doc can cite a design-local number ("PF-017") in tracked source
169
- // before the ledger ever mints that same number for an unrelated entry — the
170
- // two silently collide and nothing catches it until a human notices the text
171
- // doesn't match. This scans the project tree for a whole-word citation of the
172
- // candidate id BEFORE assign-anchor writes it, and refuses to mint over a hit.
173
- // The consumer-side counterpart lives in mdl's scripts/verify-ledger-citations.mjs.
174
- // ---------------------------------------------------------------------------
82
+ /** The bytes each read of stdin asks for. */
83
+ const STDIN_READ_BYTES = 64 * 1024;
175
84
 
176
- /** Directory names excluded from collision scanning at any depth (E4). */
177
- const COLLISION_SCAN_EXCLUDED_SEGMENTS = new Set(['.git', 'node_modules', 'target', 'dist']);
85
+ /** How long a read of stdin sleeps when a non-blocking descriptor has no input yet. */
86
+ const STDIN_WAIT_MS = 10;
178
87
 
179
- /** Files larger than this are skipped during collision scanning — bounds the scan (E4). */
180
- const COLLISION_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
88
+ /** The most sleeps one read of stdin takes: 10 s in all, long after any writer the helper's callers use has written. */
89
+ const STDIN_MAX_WAITS = 1000;
181
90
 
182
- /**
183
- * True when a project-relative path must be excluded from collision scanning:
184
- * the ledger's own files (`.devflow/learning/**`, self-citation is expected,
185
- * not a collision) or any of the excluded directory segments.
186
- *
187
- * @param {string} relPath - path relative to the project root, either separator style
188
- * @returns {boolean}
189
- */
190
- function isCollisionScanExcluded(relPath) {
191
- const norm = relPath.split(path.sep).join('/');
192
- if (norm === '.devflow/learning' || norm.startsWith('.devflow/learning/')) return true;
193
- return norm.split('/').some(seg => COLLISION_SCAN_EXCLUDED_SEGMENTS.has(seg));
194
- }
91
+ /** What Atomics.wait sleeps on: nothing notifies it, so each wait runs its full time. */
92
+ const STDIN_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
195
93
 
196
94
  /**
197
- * List tracked files via `git ls-files` (respects .gitignore; args passed as an
198
- * array — never shelled through a string-built command). Throws when the
199
- * project root is not a git working tree or the `git` binary is unavailable;
200
- * callers fall back to `listFsWalkFiles`.
95
+ * Read stdin to its end, keeping at most `maxBytes`. Every op that reads stdin
96
+ * reads it here, so the generic ops and the learning ops cannot read it two ways.
97
+ * Never throws: a failure is a refusal.
201
98
  *
202
- * D-NO-FSMONITOR: `ls-files` reads the index, and reading the index runs the
203
- * command a repository's config names in `core.fsmonitor` — code chosen by the
204
- * repository this hook runs inside. The call turns it off for itself
205
- * (`-c core.fsmonitor=false`), so the listing stays a pure read.
99
+ * D-STDIN-FD0: stdin is read from file descriptor 0 itself, in reads repeated
100
+ * until one returns no bytes — never by opening /dev/stdin, and never by the size
101
+ * fstat reports. Reason: Linux opens /dev/stdin through /proc/self/fd/0, which
102
+ * refuses a socket with ENXIO, and a node parent's 'pipe' hands its child a
103
+ * socket; and for a pipe or a socket fstat reports only what is buffered so far,
104
+ * 0 on Linux, not what the writer has still to send.
206
105
  *
207
- * @param {string} projectRoot
208
- * @returns {string[]} project-relative paths
209
- */
210
- function listGitTrackedFiles(projectRoot) {
211
- const out = execFileSync('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-z'], {
212
- cwd: projectRoot,
213
- stdio: ['ignore', 'pipe', 'ignore'],
214
- });
215
- return out.toString('utf8').split('\0').filter(Boolean);
216
- }
217
-
218
- /**
219
- * Bounded, non-recursing-into-excluded-dirs fs walk — fallback for a project
220
- * root that is not a git working tree. The walk is bounded by construction:
221
- * it only descends into directories actually present on disk, and never
222
- * descends into an excluded directory at all (E4).
106
+ * A read of a non-blocking descriptor with no input yet fails with EAGAIN (Linux
107
+ * and macOS give EWOULDBLOCK the same number, which node reports as EAGAIN). That
108
+ * is not the end of the input: the reader sleeps STDIN_WAIT_MS and reads again,
109
+ * at most STDIN_MAX_WAITS times in all. The loop is bounded: each pass takes at
110
+ * least one byte, ends it, or spends one of those waits, and it stops once it
111
+ * holds one byte more than `maxBytes`, so it never reads past that byte.
223
112
  *
224
- * @param {string} projectRoot
225
- * @returns {string[]} project-relative paths
113
+ * @param {number} maxBytes
114
+ * @returns {{ ok: true, value: string } | { ok: false, error: { kind: 'too-large' | 'unreadable', message: string } }}
115
+ * the text; or `too-large` when stdin holds more than `maxBytes`, `unreadable` when a read fails
226
116
  */
227
- function listFsWalkFiles(projectRoot) {
228
- const results = [];
229
- const stack = [''];
230
- while (stack.length > 0) {
231
- const relDir = stack.pop();
232
- const absDir = relDir ? path.join(projectRoot, relDir) : projectRoot;
233
- let entries;
117
+ function readStdinUpTo(maxBytes) {
118
+ const buf = Buffer.alloc(Math.min(STDIN_READ_BYTES, maxBytes + 1));
119
+ const chunks = [];
120
+ let total = 0;
121
+ let waits = 0;
122
+ while (total <= maxBytes) {
123
+ let read;
234
124
  try {
235
- entries = fs.readdirSync(absDir, { withFileTypes: true });
236
- } catch {
237
- continue; // unreadable dir — best-effort scan, skip
238
- }
239
- for (const entry of entries) {
240
- const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
241
- if (isCollisionScanExcluded(relPath)) continue;
242
- if (entry.isDirectory()) {
243
- stack.push(relPath);
244
- } else if (entry.isFile()) {
245
- results.push(relPath);
125
+ read = fs.readSync(0, buf, 0, Math.min(buf.length, maxBytes + 1 - total), null);
126
+ } catch (err) {
127
+ if (!err || err.code !== 'EAGAIN') {
128
+ return { ok: false, error: { kind: 'unreadable', message: `stdin could not be read: ${err && err.message ? err.message : String(err)}` } };
246
129
  }
247
- }
248
- }
249
- return results;
250
- }
251
-
252
- /**
253
- * Scan the project tree for a whole-word citation of `id` (e.g. `ADR-042`),
254
- * excluding the ledger's own files and common vendored/build directories.
255
- * Prefers tracked files (`git ls-files`) when the project root is a git
256
- * working tree; falls back to a bounded fs walk otherwise. Best-effort:
257
- * unreadable, binary, or oversized files are skipped rather than failing
258
- * the scan.
259
- *
260
- * @param {string} projectRoot
261
- * @param {string} id - e.g. 'ADR-042' or 'PF-017'
262
- * @returns {{ file: string, line: number }[]} hits, empty when no collision
263
- */
264
- function scanForAnchorCollision(projectRoot, id) {
265
- let files;
266
- try {
267
- files = listGitTrackedFiles(projectRoot);
268
- } catch {
269
- files = listFsWalkFiles(projectRoot);
270
- }
271
-
272
- const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
273
- const pattern = new RegExp(`\\b${escaped}\\b`);
274
- const hits = [];
275
- for (const relPath of files) {
276
- if (isCollisionScanExcluded(relPath)) continue;
277
- const absPath = path.join(projectRoot, relPath);
278
- let stat;
279
- try {
280
- stat = fs.statSync(absPath);
281
- } catch {
282
- continue; // race: listed then removed — best-effort scan, skip
283
- }
284
- if (!stat.isFile() || stat.size > COLLISION_SCAN_MAX_FILE_BYTES) continue;
285
- let content;
286
- try {
287
- content = fs.readFileSync(absPath, 'utf8');
288
- } catch {
289
- continue; // unreadable or invalid utf8 — best-effort scan, skip
290
- }
291
- if (content.includes('\u0000')) continue; // binary heuristic
292
- const lines = content.split('\n');
293
- for (let i = 0; i < lines.length; i++) {
294
- if (pattern.test(lines[i])) {
295
- hits.push({ file: relPath, line: i + 1 });
130
+ if (waits === STDIN_MAX_WAITS) {
131
+ return { ok: false, error: { kind: 'unreadable', message: `stdin could not be read: it had not ended after ${STDIN_MAX_WAITS * STDIN_WAIT_MS} ms of waiting for input` } };
296
132
  }
133
+ waits += 1;
134
+ Atomics.wait(STDIN_WAIT_CELL, 0, 0, STDIN_WAIT_MS);
135
+ continue;
297
136
  }
137
+ if (read === 0) break;
138
+ chunks.push(Buffer.from(buf.subarray(0, read)));
139
+ total += read;
298
140
  }
299
- return hits;
300
- }
301
-
302
- /**
303
- * Format collision hits for a stderr/stdout report — one `file:line` per line,
304
- * two-space indented (E4).
305
- *
306
- * @param {{ file: string, line: number }[]} hits
307
- * @returns {string}
308
- */
309
- function formatCollisionHits(hits) {
310
- return hits.map(h => ` ${h.file}:${h.line}`).join('\n');
141
+ if (total > maxBytes) return { ok: false, error: { kind: 'too-large', message: `stdin holds more than ${maxBytes} bytes` } };
142
+ return { ok: true, value: Buffer.concat(chunks, total).toString('utf8') };
311
143
  }
312
144
 
313
145
  /**
314
- * Read .decisions-usage.json. Returns {version, entries} or empty default.
315
- * @param {string} projectRoot - Path to project root (cwd)
316
- * @returns {{version: number, entries: Object}}
146
+ * The most stdin a generic op reads: the longest string node can build. A hook's
147
+ * input, a Stop hook's last assistant message among it, has no limit of its own,
148
+ * so a generic op refuses only what it could not hold as text.
317
149
  */
318
- function readUsageFile(projectRoot) {
319
- const filePath = getDecisionsUsagePath(projectRoot);
320
- try {
321
- const raw = fs.readFileSync(filePath, 'utf8');
322
- const data = JSON.parse(raw);
323
- if (data && data.version === 1 && typeof data.entries === 'object') return data;
324
- } catch { /* ENOENT or malformed — return default */ }
325
- return { version: 1, entries: {} };
326
- }
150
+ const STDIN_TEXT_MAX_BYTES = MAX_STRING_LENGTH;
327
151
 
328
- /**
329
- * Write .decisions-usage.json atomically.
330
- * @param {string} projectRoot - Path to project root (cwd)
331
- * @param {{version: number, entries: Object}} data
332
- */
333
- function writeUsageFile(projectRoot, data) {
334
- writeFileAtomic(getDecisionsUsagePath(projectRoot), JSON.stringify(data, null, 2) + '\n');
152
+ /** A generic op's stdin, trimmed: '' when it cannot be read, so the op fails as it does on empty input. */
153
+ function readStdin() {
154
+ const text = readStdinUpTo(STDIN_TEXT_MAX_BYTES);
155
+ return text.ok ? text.value.trim() : '';
335
156
  }
336
157
 
337
- /**
338
- * Register an entry in .decisions-usage.json with initial cite count.
339
- * @param {string} projectRoot - Path to project root (cwd)
340
- * @param {string} anchorId - e.g. 'ADR-001' or 'PF-003'
341
- */
342
- function registerUsageEntry(projectRoot, anchorId) {
343
- const data = readUsageFile(projectRoot);
344
- if (!data.entries[anchorId]) {
345
- data.entries[anchorId] = {
346
- cites: 0,
347
- last_cited: null,
348
- created: new Date().toISOString(),
349
- };
350
- writeUsageFile(projectRoot, data);
158
+ function getNestedField(obj, field) {
159
+ const parts = field.split('.');
160
+ let current = obj;
161
+ for (const part of parts) {
162
+ if (current == null || typeof current !== 'object') return undefined;
163
+ current = current[part];
351
164
  }
165
+ return current;
352
166
  }
353
167
 
354
- /**
355
- * Internal rotation logic for rotate-observations. Separated for testability.
356
- * Moves rows where status === 'observing' AND no anchor_id AND age > 30 days
357
- * from logPath to archivePath (append). Returns count of rotated rows.
358
- *
359
- * @param {string} logPath - Path to decisions-log.jsonl
360
- * @param {string} archivePath - Path to decisions-log.archive.jsonl
361
- * @param {number} nowMs - Current time as epoch ms (injectable for tests)
362
- * @returns {number} count of rotated rows
363
- */
364
- function rotateObservations(logPath, archivePath, nowMs) {
365
- const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
366
- const cutoffMs = nowMs - THIRTY_DAYS_MS;
367
-
368
- let logEntries = [];
369
- if (fs.existsSync(logPath)) {
370
- logEntries = parseLedger(logPath);
371
- }
372
-
373
- const kept = [];
374
- const stale = [];
375
-
376
- for (const row of logEntries) {
377
- // Only move 'observing' rows without anchor_id (unanchored)
378
- if (row.status !== 'observing' || row.anchor_id) {
379
- kept.push(row);
380
- continue;
381
- }
382
- // Check age using last_seen if present, else first_seen
383
- const tsField = row.last_seen || row.first_seen;
384
- if (!tsField) {
385
- kept.push(row);
386
- continue;
387
- }
388
- const rowMs = new Date(tsField).getTime();
389
- if (isNaN(rowMs) || rowMs > cutoffMs) {
390
- kept.push(row);
391
- } else {
392
- stale.push(row);
393
- }
394
- }
395
-
396
- if (stale.length === 0) return 0;
397
-
398
- // D003: Dedup stale rows against the existing archive by id before appending.
399
- // An interrupt-then-retry (process killed after archive write but before log
400
- // rewrite) would re-classify the same rows as stale and attempt to archive
401
- // them a second time. Reading existing archive IDs into a Set and filtering
402
- // prevents duplicate rows in the archive. Cost is O(archive) on retry; O(1)
403
- // on the normal path when the archive is absent.
404
- //
405
- // True append (appendFileSync) is used instead of read-entire-archive+rewrite
406
- // so cost is O(stale) rather than O(archive) on the write path. The archive
407
- // is gitignored/recovery-only, so an incomplete final newline on ENOENT is
408
- // safe — parseLedger handles trailing-newline variance.
409
- const existingArchiveIds = new Set();
410
- if (fs.existsSync(archivePath)) {
411
- const existingRows = parseLedger(archivePath);
412
- for (const r of existingRows) {
413
- if (r.id) existingArchiveIds.add(r.id);
414
- }
415
- }
416
-
417
- const newStale = stale.filter(r => !existingArchiveIds.has(r.id));
418
- if (newStale.length > 0) {
419
- // True append — O(newStale), not O(archive)
420
- const appendContent = newStale.map(r => JSON.stringify(r)).join('\n') + '\n';
421
- fs.appendFileSync(archivePath, appendContent, 'utf8');
422
- }
423
-
424
- // Write remaining rows back to log
425
- writeJsonlAtomic(logPath, kept);
426
-
427
- return stale.length;
168
+ /** The JSON values of a JSONL text's lines; a line that does not parse is skipped. */
169
+ function parseJsonlText(text) {
170
+ const lines = text.split('\n').filter(Boolean);
171
+ return lines.map(l => {
172
+ try { return JSON.parse(l); } catch { return null; }
173
+ }).filter(Boolean);
428
174
  }
429
175
 
430
176
  function parseArgs(argList) {
@@ -447,52 +193,96 @@ function parseArgs(argList) {
447
193
  }
448
194
 
449
195
  // ---------------------------------------------------------------------------
450
- // Lock helpers — shared by the three decisions ledger ops (assign-anchor,
451
- // retire-anchor, refresh-anchor). rotate-observations uses a DIFFERENT lock
452
- // (.observations.lock) and keeps its own scaffold (avoids over-generalising).
196
+ // Learning-op adapter
453
197
  // ---------------------------------------------------------------------------
454
198
 
455
- /** Acquire-timeout for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
456
- const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
457
- /** Stale-break threshold for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
458
- const LOCK_STALE_MS = 60000;
459
-
460
199
  /**
461
- * Run fn() under .decisions.lock.
200
+ * Print a learning op's Result: `format(value)` and a newline on stdout, or the
201
+ * error message and a newline on stderr. Returns the exit code for the op to set
202
+ * as process.exitCode, so the process exits once, after the op has returned and
203
+ * every lock it took is released.
462
204
  *
463
- * Never call process.exit() inside fn — throw instead (PF-014): the throw propagates
464
- * through the try/finally so releaseLock always runs. process.exit is reserved for
465
- * the acquire-failure path where no lock is held and no cleanup is needed.
205
+ * @param {{ ok: true, value: unknown } | { ok: false, error: { message: string } }} result
206
+ * @param {(value: any) => string} format - the stdout text for the value
207
+ * @returns {0|1}
208
+ */
209
+ function emit(result, format) {
210
+ if (result.ok) {
211
+ process.stdout.write(`${format(result.value)}\n`);
212
+ return 0;
213
+ }
214
+ process.stderr.write(`${result.error.message}\n`);
215
+ return 1;
216
+ }
217
+
218
+ /**
219
+ * Refuse a malformed command line: print `<op>: usage: <usage>` on stderr and exit 1,
220
+ * before the op takes any lock.
466
221
  *
467
- * PF-013: parent directory of the lock dir is created before acquireMkdirLock is
468
- * called so a fresh-project cold-path does not throw ENOENT inside the lock lib.
222
+ * @param {string} usage - the usage text, starting with the op's name
223
+ * @returns {never}
224
+ */
225
+ function exitWithUsage(usage) {
226
+ process.stderr.write(`${op}: usage: ${usage}\n`);
227
+ process.exit(1);
228
+ }
229
+
230
+ /** The most stdin a learning op reads: far above any valid input, so a runaway writer is refused, not parsed. */
231
+ const STDIN_JSON_MAX_BYTES = 64 * 1024;
232
+
233
+ /**
234
+ * A learning op's stdin as one JSON object: the one way text reaches a learning
235
+ * op, so no field of it ever passes through argv or a shell word. Never throws.
469
236
  *
470
- * @param {string} opName - operation name for error messages
471
- * @param {string} projectRoot - project root (cwd)
472
- * @param {() => unknown} fn - body to execute under the lock
237
+ * @param {string} opName - for the message
238
+ * @returns {{ ok: true, value: object } | { ok: false, error: { kind: 'invalid-input', message: string } }}
473
239
  */
474
- function withDecisionsLock(opName, projectRoot, fn) {
475
- const lockDir = getDecisionsLockDir(projectRoot);
476
- // PF-013: ensure parent directory exists before acquiring lock
477
- fs.mkdirSync(path.dirname(lockDir), { recursive: true });
478
- if (!acquireMkdirLock(lockDir, LOCK_ACQUIRE_TIMEOUT_MS, LOCK_STALE_MS)) {
479
- process.stderr.write(`${opName}: timeout acquiring lock at ${lockDir}\n`);
480
- process.exit(1);
240
+ function readStdinJson(opName) {
241
+ const refuse = message => ({ ok: false, error: { kind: 'invalid-input', message: `${opName}: ${message}` } });
242
+ const text = readStdinUpTo(STDIN_JSON_MAX_BYTES);
243
+ if (!text.ok) {
244
+ return refuse(text.error.kind === 'too-large' ? `${text.error.message}; it must hold one JSON object` : text.error.message);
245
+ }
246
+ let value;
247
+ try {
248
+ value = JSON.parse(text.value);
249
+ } catch {
250
+ return refuse('stdin must hold one JSON object');
481
251
  }
482
- try { return fn(); } finally { releaseLock(lockDir); }
252
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return refuse('stdin must hold one JSON object');
253
+ return { ok: true, value };
483
254
  }
484
255
 
256
+ /** put-observation's flags and the store modes they name. */
257
+ const PUT_MODES = new Map([['--create', 'create'], ['--update', 'update'], ['--reinforce', 'reinforce']]);
258
+
259
+ /** The entry types assign-anchor takes. */
260
+ const ENTRY_TYPES = new Set(['decision', 'pitfall']);
261
+
485
262
  /**
486
- * Serialize ledger rows to a JSONL string with trailing newline.
487
- * Extracted to avoid repeating the same expression at four sites (COMP-4).
488
- *
489
- * @param {object[]} rows
490
- * @returns {string}
263
+ * The learning ops whose run sends the claim heartbeat first (D-OWNED-CLAIM).
264
+ * claim-queue and release-claim manage the claim themselves, and the generic ops
265
+ * never touch it. A new learning op joins this set.
491
266
  */
492
- const serializeLedger = rows => rows.map(r => JSON.stringify(r)).join('\n') + '\n';
267
+ const LEARNING_OPS = new Set([
268
+ 'assign-anchor', 'retire-anchor', 'restore-anchor', 'refresh-anchor', 'rotate-observations',
269
+ 'put-observation', 'list', 'show', 'claim-due',
270
+ ]);
271
+
272
+ /** Send the claim heartbeat; a failure is reported on stderr and never stops the op. */
273
+ function heartbeat(root) {
274
+ const beat = learning().store.touchClaim(root);
275
+ if (!beat.ok) process.stderr.write(`${beat.error.message}\n`);
276
+ }
493
277
 
278
+ // The learning ops run from the project root and take no path to a learning file:
279
+ // each builds its paths from the current directory. Every op that writes takes the
280
+ // store's one learning lock through withDecisionsLock (D-ONE-LEARNING-LOCK) and
281
+ // refuses, creating nothing, when .devflow/learning/ is absent (D-NO-STRAY-TREE).
282
+ // A locked body returns its Result; emit prints it once the lock is released.
494
283
  if (require.main === module) {
495
284
  try {
285
+ if (LEARNING_OPS.has(op)) heartbeat(process.cwd());
496
286
  switch (op) {
497
287
  case 'get-field': {
498
288
  const input = JSON.parse(readStdin());
@@ -503,17 +293,6 @@ try {
503
293
  break;
504
294
  }
505
295
 
506
- case 'get-field-file': {
507
- const file = safePath(args[0]);
508
- const field = args[1];
509
- const def = args[2] || '';
510
- const content = fs.readFileSync(file, 'utf8').trim();
511
- const input = JSON.parse(content);
512
- const val = getNestedField(input, field);
513
- console.log(val != null ? String(val) : def);
514
- break;
515
- }
516
-
517
296
  case 'validate': {
518
297
  try {
519
298
  const text = readStdin();
@@ -605,10 +384,9 @@ try {
605
384
  }
606
385
 
607
386
  case 'slurp-sort': {
608
- const file = args[0];
609
- const field = args[1];
610
- const limit = parseInt(args[2]) || 30;
611
- const parsed = parseJsonl(file);
387
+ const field = args[0];
388
+ const limit = parseInt(args[1]) || 30;
389
+ const parsed = parseJsonlText(readStdin());
612
390
  parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
613
391
  console.log(JSON.stringify(parsed.slice(0, limit)));
614
392
  break;
@@ -616,10 +394,9 @@ try {
616
394
 
617
395
  case 'slurp-cap': {
618
396
  // Read JSONL, sort by field desc, output top N as JSONL (one per line)
619
- const file = args[0];
620
- const field = args[1];
621
- const limit = parseInt(args[2]) || 100;
622
- const parsed = parseJsonl(file);
397
+ const field = args[0];
398
+ const limit = parseInt(args[1]) || 100;
399
+ const parsed = parseJsonlText(readStdin());
623
400
  parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
624
401
  for (const item of parsed.slice(0, limit)) {
625
402
  console.log(JSON.stringify(item));
@@ -687,422 +464,195 @@ try {
687
464
  }
688
465
 
689
466
  // -------------------------------------------------------------------------
690
- // assign-anchor <type> <obs_id> [--allow-collision]
691
- // AC-A2: Assign next anchor ID for the given type (decision|pitfall) to the
692
- // observation identified by obs_id in decisions-log.jsonl. Atomic under a
693
- // single .decisions.lock acquisition. Registers usage, re-renders both .md.
694
- //
695
- // E4: before writing, refuses if the candidate id is already cited as a
696
- // whole word somewhere in tracked source (a pre-mint collision — see
697
- // scanForAnchorCollision above). --allow-collision skips the scan and
698
- // mints anyway, for the human-ruled case where the citation should be
699
- // superseded by the ledger's number.
700
- //
701
- // Locking discipline: holds ONLY .decisions.lock (never .observations.lock).
702
- // O(anchored) — single pass for max numeric suffix (AC-P2).
467
+ // assign-anchor <decision|pitfall> <obs_id>
468
+ // Promote a v2 observation no ledger row carries to the next entry of its
469
+ // type, skipping each number a tracked file cites (assignAnchor,
470
+ // learning-store.cjs: D-LEDGER-REGISTRY, D-E4-SKIP). It never writes the log.
471
+ // stdout: the anchor; stderr: `assign-anchor: skipped <anchor>, cited in
472
+ // <path>:<line>` for each number skipped
703
473
  // -------------------------------------------------------------------------
704
474
  case 'assign-anchor': {
705
- const aaKnownFlags = new Set(['--allow-collision']);
706
- const aaFlags = args.filter(a => a.startsWith('--'));
707
- const aaUnknownFlags = aaFlags.filter(f => !aaKnownFlags.has(f));
708
- if (aaUnknownFlags.length > 0) {
709
- process.stderr.write(`assign-anchor: unknown flag(s): ${aaUnknownFlags.join(', ')}\n`);
710
- process.exit(1);
711
- }
712
- const aaAllowCollision = aaFlags.includes('--allow-collision');
713
- const aaPositional = args.filter(a => !a.startsWith('--'));
714
-
715
- const assignType = aaPositional[0]; // 'decision' or 'pitfall'
716
- const assignObsId = aaPositional[1];
717
-
718
- if (!assignType || !assignObsId) {
719
- process.stderr.write('assign-anchor: usage: assign-anchor <type> <obs_id> [--allow-collision]\n');
720
- process.exit(1);
721
- }
722
- if (assignType !== 'decision' && assignType !== 'pitfall') {
723
- process.stderr.write(`assign-anchor: type must be 'decision' or 'pitfall', got '${assignType}'\n`);
724
- process.exit(1);
475
+ const { store } = learning();
476
+ if (args.length !== 2 || !ENTRY_TYPES.has(args[0]) || !store.OBS_ID_RE.test(args[1])) {
477
+ exitWithUsage('assign-anchor <decision|pitfall> <obs_id> (run from the project root)');
725
478
  }
726
-
727
- const aaProjectRoot = process.cwd();
728
- const aaLedgerPath = getDecisionsLedgerPath(aaProjectRoot);
729
- const aaLogPath = getDecisionsLogPath(aaProjectRoot);
730
-
731
- withDecisionsLock('assign-anchor', aaProjectRoot, () => {
732
- // Read existing ledger (absent = empty)
733
- const aaLedgerRows = parseLedger(aaLedgerPath);
734
-
735
- // Compute next anchor — O(anchored), single pass
736
- const { anchorId: aaAnchorId } = nextAnchorFromLedger(aaLedgerRows, assignType);
737
-
738
- // E4: pre-mint collision guard — refuse if the candidate id is already
739
- // cited (as a whole word) somewhere in tracked source with a different
740
- // meaning, before any ledger write. Never auto-skip to the next free
741
- // number — the collision is a human call (rename the citation, or
742
- // rerun with --allow-collision to mint over it deliberately).
743
- if (!aaAllowCollision) {
744
- const aaCollisionHits = scanForAnchorCollision(aaProjectRoot, aaAnchorId);
745
- if (aaCollisionHits.length > 0) {
746
- throw new Error(
747
- `assign-anchor: '${aaAnchorId}' is already cited in source with a different ` +
748
- `meaning; resolve the collision before minting (or pass --allow-collision):\n` +
749
- formatCollisionHits(aaCollisionHits)
750
- );
751
- }
752
- }
753
-
754
- // Read observation from log
755
- let aaLogEntries = parseLedger(aaLogPath);
756
- const aaObsIdx = aaLogEntries.findIndex(e => e.id === assignObsId);
757
- if (aaObsIdx === -1) {
758
- throw new Error(`assign-anchor: obs_id '${assignObsId}' not found in ${aaLogPath}`);
479
+ const result = store.assignAnchor(process.cwd(), args[0], args[1]);
480
+ if (result.ok) {
481
+ for (const skip of result.value.skipped) {
482
+ process.stderr.write(`assign-anchor: skipped ${skip.anchor_id}, cited in ${store.singleLine(skip.file)}:${skip.line}\n`);
759
483
  }
760
- const aaObs = aaLogEntries[aaObsIdx];
761
-
762
- // Precondition assertions — both checked under the lock so they are
763
- // race-free against concurrent assign-anchor callers (avoids silent
764
- // ledger corruption; assert-preconditions per reliability rule).
765
- //
766
- // (a) The newly computed anchor_id must not already appear in the ledger.
767
- // nextAnchorFromLedger is deterministic-monotone, so this should
768
- // never fire in normal operation — it guards against double-assign
769
- // bugs (e.g. assign called twice for the same obs_id in a crash loop).
770
- if (aaLedgerRows.some(r => r.anchor_id === aaAnchorId)) {
771
- throw new Error(
772
- `assign-anchor: anchor_id '${aaAnchorId}' already present in ledger — ` +
773
- `possible double-assign; refusing to overwrite committed entry`
774
- );
775
- }
776
- //
777
- // (b) The target observation must not already have an anchor_id set.
778
- // Re-anchoring an already-anchored obs would mint a duplicate number
779
- // (the old anchor would remain in the ledger AND the new one would
780
- // be added), corrupting the committed source of truth.
781
- if (aaObs.anchor_id) {
782
- throw new Error(
783
- `assign-anchor: obs_id '${assignObsId}' is already anchored as '${aaObs.anchor_id}'; ` +
784
- `use retire-anchor to change its status instead`
785
- );
786
- }
787
-
788
- // Build canonical committed-ledger row via toLedgerRow projector.
789
- // Whitelists only the canonical fields — excludes all observation-lifecycle
790
- // state (evidence, confidence, quality_ok, count, first_seen, last_seen, …)
791
- // that must stay in the log only. applies ADR-008.
792
- const aaDate = new Date().toISOString().slice(0, 10);
793
- const aaActiveStatus = assignType === 'decision' ? 'Accepted' : 'Active';
794
- // Date stamped on ALL entry types (decisions + pitfalls). Prefer the
795
- // date from the observation (content authority per ADR-022); fall back
796
- // to today. Both types carry a date so refresh-anchor can re-project
797
- // them correctly (pattern refreshes too — consumers match anchor headings, never titles, per ADR-022).
798
- const aaEntryDate = aaObs.date || aaDate;
799
- const aaLedgerRow = toLedgerRow(aaObs, {
800
- anchorId: aaAnchorId,
801
- status: aaActiveStatus,
802
- date: aaEntryDate,
803
- });
804
-
805
- // Append anchored row to ledger (atomic temp+rename).
806
- //
807
- // D002: Crash window — if the process is killed between this write and
808
- // renderAndWriteAll below, the ledger will be ahead of decisions.md /
809
- // pitfalls.md. This is git-recoverable: the ledger is the source of
810
- // truth and `render-decisions.cjs render <worktree>` re-renders the
811
- // .md files. The render is kept as the FINAL write under the lock so
812
- // the window is as narrow as possible.
813
- const aaNewLedgerRows = [...aaLedgerRows, aaLedgerRow];
814
- writeFileAtomic(aaLedgerPath, serializeLedger(aaNewLedgerRows));
815
-
816
- // Mark log row as created and stamp anchor_id so guard (b) fires on
817
- // any subsequent assign-anchor call for the same obs_id. Without this
818
- // write-back the guard is dead: aaObs.anchor_id would be undefined on
819
- // a re-read and a second assign would silently mint a duplicate number.
820
- // applies ADR-022 (log is content authority; anchor_id written back to arm guard).
821
- aaLogEntries[aaObsIdx] = Object.assign({}, aaObs, { status: 'created', anchor_id: aaAnchorId });
822
- writeJsonlAtomic(aaLogPath, aaLogEntries);
823
-
824
- // Register usage entry
825
- registerUsageEntry(aaProjectRoot, aaAnchorId);
826
-
827
- // Re-render both .md files (lock-free — we already hold .decisions.lock).
828
- // This is the FINAL write in the lock scope — see D002 above.
829
- renderAndWriteAll(aaProjectRoot, aaNewLedgerRows);
830
-
831
- // Print assigned anchor id to stdout
832
- process.stdout.write(aaAnchorId + '\n');
833
- });
484
+ }
485
+ process.exitCode = emit(result, assigned => assigned.anchor_id);
834
486
  break;
835
487
  }
836
488
 
837
489
  // -------------------------------------------------------------------------
838
- // next-anchor <type>
839
- // E4: Read-only preview of what assign-anchor would mint next — no lock
840
- // acquired, no file written, no usage entry registered. Prints the
841
- // candidate id and, when a pre-mint collision guard would fire, its
842
- // file:line hits — so a caller can check before committing to assign-anchor.
490
+ // retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
491
+ // Make an active entry inactive with the one JSON object on stdin its status
492
+ // takes: { reason } for Retired and Deprecated, { by } for Superseded, and
493
+ // { at, quote } for Encoded, the quote checked at the verify ref
494
+ // (retireAnchor, learning-store.cjs: D-ENCODED-QUOTE).
495
+ // stdout: the new status in lower case and the anchor, then `repointed
496
+ // <anchor>` for each entry re-pointed to the successor
843
497
  // -------------------------------------------------------------------------
844
- case 'next-anchor': {
845
- const naType = args[0];
846
-
847
- if (!naType) {
848
- process.stderr.write('next-anchor: usage: next-anchor <type>\n');
849
- process.exit(1);
850
- }
851
- if (naType !== 'decision' && naType !== 'pitfall') {
852
- process.stderr.write(`next-anchor: type must be 'decision' or 'pitfall', got '${naType}'\n`);
853
- process.exit(1);
854
- }
855
-
856
- const naProjectRoot = process.cwd();
857
- const naLedgerRows = parseLedger(getDecisionsLedgerPath(naProjectRoot));
858
- const { anchorId: naAnchorId } = nextAnchorFromLedger(naLedgerRows, naType);
859
- const naHits = scanForAnchorCollision(naProjectRoot, naAnchorId);
860
-
861
- process.stdout.write(naAnchorId + '\n');
862
- if (naHits.length > 0) {
863
- process.stderr.write(
864
- `next-anchor: '${naAnchorId}' is already cited in source — collision hits:\n` +
865
- formatCollisionHits(naHits) + '\n'
866
- );
867
- process.exit(1);
498
+ case 'retire-anchor': {
499
+ const { store } = learning();
500
+ if (args.length !== 2 || !store.ANCHOR_ID_RE.test(args[0]) || !store.INACTIVE_STATUSES.includes(args[1])) {
501
+ exitWithUsage('retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated> (one JSON object on stdin; run from the project root)');
868
502
  }
503
+ const input = readStdinJson('retire-anchor');
504
+ const result = input.ok ? store.retireAnchor(process.cwd(), args[0], args[1], input.value) : input;
505
+ process.exitCode = emit(result, retired => [
506
+ `${retired.status.toLowerCase()} ${retired.anchor_id}`,
507
+ ...retired.repointed.map(anchorId => `repointed ${anchorId}`),
508
+ ].join('\n'));
869
509
  break;
870
510
  }
871
511
 
872
512
  // -------------------------------------------------------------------------
873
- // retire-anchor <anchor_id> <status>
874
- // AC-A3, AC-F5, AC-F7: Flip decisions_status on the ledger row. Idempotent.
875
- // Re-renders both .md (retired entry vanishes from .md, stays in ledger).
876
- //
877
- // status must be Deprecated | Superseded | Retired.
878
- // Locking discipline: holds ONLY .decisions.lock.
513
+ // restore-anchor <anchor>
514
+ // Make an inactive entry active again, its notes, last_verified and
515
+ // last_attempt cleared so it is due for maintenance again, ordered after
516
+ // integrity problems and legacy entries, or among them when it is one
517
+ // (restoreAnchor, learning-store.cjs: D-DUE-ORDER).
518
+ // stdout: restored <anchor>
879
519
  // -------------------------------------------------------------------------
880
- case 'retire-anchor': {
881
- const retireAnchorId = args[0];
882
- const retireStatus = args[1];
883
-
884
- const RETIRE_STATUSES = new Set(['Deprecated', 'Superseded', 'Retired']);
885
-
886
- if (!retireAnchorId || !retireStatus) {
887
- process.stderr.write('retire-anchor: usage: retire-anchor <anchor_id> <status>\n');
888
- process.exit(1);
889
- }
890
- if (!RETIRE_STATUSES.has(retireStatus)) {
891
- process.stderr.write(`retire-anchor: status must be Deprecated|Superseded|Retired, got '${retireStatus}'\n`);
892
- process.exit(1);
520
+ case 'restore-anchor': {
521
+ const { store } = learning();
522
+ if (args.length !== 1 || !store.ANCHOR_ID_RE.test(args[0])) {
523
+ exitWithUsage('restore-anchor <anchor> (run from the project root)');
893
524
  }
894
-
895
- const raProjectRoot = process.cwd();
896
- const raLedgerPath = getDecisionsLedgerPath(raProjectRoot);
897
-
898
- withDecisionsLock('retire-anchor', raProjectRoot, () => {
899
- const raRows = parseLedger(raLedgerPath);
900
- const raIdx = raRows.findIndex(r => r.anchor_id === retireAnchorId);
901
- if (raIdx === -1) {
902
- throw new Error(`retire-anchor: anchor_id '${retireAnchorId}' not found in ledger`);
903
- }
904
-
905
- // Idempotent: if already set to same status, still write (no-op equivalent)
906
- raRows[raIdx] = Object.assign({}, raRows[raIdx], { decisions_status: retireStatus });
907
- writeFileAtomic(raLedgerPath, serializeLedger(raRows));
908
-
909
- // Re-render both .md (lock-free — we already hold .decisions.lock)
910
- renderAndWriteAll(raProjectRoot, raRows);
911
-
912
- // Echo anchor_id to stdout matching the other three ops (CON-P1).
913
- process.stdout.write(retireAnchorId + '\n');
914
- });
525
+ process.exitCode = emit(store.restoreAnchor(process.cwd(), args[0]), restored => `restored ${restored.anchor_id}`);
915
526
  break;
916
527
  }
917
528
 
918
529
  // -------------------------------------------------------------------------
919
- // refresh-anchor <anchor_id> [<anchor_id>...]
920
- // ADR-022: Re-project log observations onto committed ledger rows and
921
- // re-render all three files (decisions.md, pitfalls.md, index.md). Each write
922
- // is atomic; the sequence is not transactional — a crash between writes self-heals
923
- // on the next ledger op. Variadic — accepts 1..N anchor ids and performs
924
- // ONE lock acquisition, ONE ledger parse, ONE log parse, and ONE render
925
- // (PERF-1: collapses N agent turns into 1, N re-renders into 1).
926
- //
927
- // All-or-nothing semantics: every anchor is validated before any write;
928
- // a throw on any anchor leaves the ledger and .md files untouched.
929
- //
930
- // Algorithm:
931
- // 1. Read ledger and log ONCE (outside the per-anchor loop).
932
- // 2. For each anchor: locate ledger row, run precondition checks, run
933
- // REG-1 details divergence guard (ADR-022: consumers match anchor headings not
934
- // titles so pattern replacement is sanctioned; only details containment is enforced),
935
- // re-project via toLedgerRow (which carries PF-023 sink validation for pattern/raw_body/type).
936
- // 3. Assert row count unchanged (REL-6 — bounds parseLedger silent-drop exposure).
937
- // 4. Write ledger once, render once, echo all ids to stdout (one per line).
938
- //
939
- // Locking discipline: holds ONLY .decisions.lock.
530
+ // refresh-anchor <anchor> [<anchor>...] [--verified]
531
+ // Re-project active v2 entries from their log rows, or under --verified stamp
532
+ // them verified today; all or nothing (refreshAnchors, learning-store.cjs).
533
+ // stdout: `reprojected <anchor>` or `unchanged <anchor>` per anchor, or
534
+ // `verified <anchor>` under --verified, in the order given
940
535
  // -------------------------------------------------------------------------
941
536
  case 'refresh-anchor': {
942
- const refreshAnchorIds = args.filter(Boolean);
943
-
944
- if (refreshAnchorIds.length === 0) {
945
- process.stderr.write('refresh-anchor: usage: refresh-anchor <anchor_id> [<anchor_id>...]\n');
946
- process.exit(1);
537
+ const { store } = learning();
538
+ const verifiedFlags = args.filter(arg => arg === '--verified').length;
539
+ const anchors = args.filter(arg => arg !== '--verified');
540
+ if (verifiedFlags > 1 || anchors.length === 0 || !anchors.every(arg => store.ANCHOR_ID_RE.test(arg))) {
541
+ exitWithUsage('refresh-anchor <anchor> [<anchor>...] [--verified] (run from the project root)');
947
542
  }
543
+ const result = store.refreshAnchors(process.cwd(), anchors, { verified: verifiedFlags === 1 });
544
+ process.exitCode = emit(result, ({ refreshed }) => refreshed.map(entry => `${entry.state} ${entry.anchor_id}`).join('\n'));
545
+ break;
546
+ }
948
547
 
949
- const rfProjectRoot = process.cwd();
950
- const rfLedgerPath = getDecisionsLedgerPath(rfProjectRoot);
951
- const rfLogPath = getDecisionsLogPath(rfProjectRoot);
548
+ // -------------------------------------------------------------------------
549
+ // rotate-observations
550
+ // Archive the log rows no ledger row carries once 30 days have passed since
551
+ // their last activity, and delete the usage telemetry's leftovers
552
+ // (D-ROTATE-UNREFERENCED, learning-store.cjs). Takes no argument: the log and
553
+ // the archive are the project root's.
554
+ // stdout: rotated <N> observations
555
+ // -------------------------------------------------------------------------
556
+ case 'rotate-observations': {
557
+ if (args.length > 0) exitWithUsage('rotate-observations (no arguments; run from the project root)');
558
+ const result = learning().store.rotateObservations(process.cwd());
559
+ process.exitCode = emit(result, ({ rotated }) => `rotated ${rotated} observations`);
560
+ break;
561
+ }
952
562
 
953
- // SEC-S3: refuse when no ledger exists at the resolved project root. A refresh
954
- // is only valid for a project with a committed ledger — invoked from the wrong
955
- // cwd withDecisionsLock would otherwise silently materialise a stray
956
- // .devflow/learning/ tree before throwing 'not found in ledger'.
957
- if (!fs.existsSync(rfLedgerPath)) {
958
- throw new Error(
959
- `refresh-anchor: no decisions-ledger.jsonl found at '${rfLedgerPath}' — ` +
960
- `cannot refresh an entry where no ledger exists`
961
- );
563
+ // -------------------------------------------------------------------------
564
+ // put-observation --create|--update|--reinforce
565
+ // Store one observation from the JSON object on stdin (D-PUT-NOT-MERGE,
566
+ // D-PUT-REPROJECTS, learning-store.cjs). Exactly one mode flag; no other argv.
567
+ // stdout: created <id> | updated <id> | unchanged <id> | reinforced <id> <n>,
568
+ // then one `reprojected <anchor>` line per entry re-projected
569
+ // -------------------------------------------------------------------------
570
+ case 'put-observation': {
571
+ const mode = args.length === 1 ? PUT_MODES.get(args[0]) : undefined;
572
+ if (mode === undefined) {
573
+ exitWithUsage('put-observation --create|--update|--reinforce (one JSON object on stdin; run from the project root)');
962
574
  }
575
+ const input = readStdinJson('put-observation');
576
+ const result = input.ok ? learning().store.putObservation(process.cwd(), mode, input.value) : input;
577
+ process.exitCode = emit(result, put => [
578
+ put.outcome === 'reinforced' ? `reinforced ${put.id} ${put.observations}` : `${put.outcome} ${put.id}`,
579
+ ...put.reprojected.map(anchorId => `reprojected ${anchorId}`),
580
+ ].join('\n'));
581
+ break;
582
+ }
963
583
 
964
- withDecisionsLock('refresh-anchor', rfProjectRoot, () => {
965
- // (1) Read ledger and log ONCE — shared across all anchor ids (PERF-1).
966
- const rfLedgerRows = parseLedger(rfLedgerPath);
967
- const rfExpectedRowCount = rfLedgerRows.length;
968
- const rfLogEntries = parseLedger(rfLogPath);
969
-
970
- // (2) Validate and re-project each anchor — all-or-nothing: any throw
971
- // propagates out of withDecisionsLock's fn() before any write occurs.
972
- for (const anchorId of refreshAnchorIds) {
973
- // Locate the existing ledger row by anchor_id (stable, canonical key).
974
- // Miss → throw (PF-014: throw, not process.exit, inside a lock scope).
975
- const rfLedgerIdx = rfLedgerRows.findIndex(r => r.anchor_id === anchorId);
976
- if (rfLedgerIdx === -1) {
977
- throw new Error(
978
- `refresh-anchor: anchor_id '${anchorId}' not found in ledger — ` +
979
- `cannot refresh a row that was never committed`
980
- );
981
- }
982
-
983
- const rfExistingRow = rfLedgerRows[rfLedgerIdx];
984
-
985
- // Precondition assertions — checked under the lock (assert-preconditions
986
- // per reliability rule). Mirrors assign-anchor's pattern.
987
- // (a) Ledger row must have an id — undefined===undefined would bind the wrong log row.
988
- if (!rfExistingRow.id) {
989
- throw new Error(
990
- `refresh-anchor: ledger row '${anchorId}' has no id — ` +
991
- `cannot resolve its log observation`
992
- );
993
- }
994
- // (b) Ledger row must have decisions_status — toLedgerRow passes it through;
995
- // absent would cause JSON.stringify to drop the key from the projected row.
996
- if (!rfExistingRow.decisions_status) {
997
- throw new Error(
998
- `refresh-anchor: ledger row '${anchorId}' has no decisions_status — ` +
999
- `refusing to project a row that would drop it`
1000
- );
1001
- }
1002
-
1003
- // Locate the log obs by the LEDGER ROW's id field (content authority, ADR-022).
1004
- // Matching on id (not anchor_id) covers pre-existing obs written before
1005
- // assign-anchor added anchor_id write-back to the log (avoids PF-041).
1006
- const rfObs = rfLogEntries.find(r => r.id === rfExistingRow.id);
1007
- if (!rfObs) {
1008
- throw new Error(
1009
- `refresh-anchor: log obs with id '${rfExistingRow.id}' ` +
1010
- `(for anchor ${anchorId}) not found in log`
1011
- );
1012
- }
1013
-
1014
- // (c) Type must match the committed anchor — re-projecting across types would move
1015
- // a PF-NNN into decisions.md (or vice versa) and corrupt the rendered corpus.
1016
- // This check also satisfies toLedgerRow's expectType guard (PF-023 sink);
1017
- // both fire with their respective messages — this one fires first.
1018
- if (rfObs.type !== rfExistingRow.type) {
1019
- throw new Error(
1020
- `refresh-anchor: log obs '${rfObs.id}' type '${rfObs.type}' does not match committed anchor ` +
1021
- `${anchorId} type '${rfExistingRow.type}' — refusing to re-project across entry types`
1022
- );
1023
- }
1024
-
1025
- // REG-1 (avoids PF-044): divergence guard — refuse to silently overwrite
1026
- // ledger-only curation content. Applies to DETAILS only: pattern replacement
1027
- // is sanctioned (ADR-022 — consumers match '## (ADR|PF)-NNN:' anchors, never
1028
- // titles, so a sharpened log pattern may update the rendered heading).
1029
- // raw_body is handled by isSafeRawBody inside toLedgerRow (PF-023 sink).
1030
- const rfNormWS = (/** @type {unknown} */ s) =>
1031
- typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '';
1032
- const rfLedgerDetails = rfNormWS(rfExistingRow.details);
1033
- const rfLogDetails = rfNormWS(rfObs.details);
1034
- if (rfLedgerDetails && !rfLogDetails.includes(rfLedgerDetails)) {
1035
- throw new Error(
1036
- `refresh-anchor: ledger row '${anchorId}' carries content absent from log obs ` +
1037
- `'${rfExistingRow.id}' (details: ledger ${rfLedgerDetails.length}B / log ${rfLogDetails.length}B). ` +
1038
- `Reconcile the log row first — re-projecting would discard curated content (avoids PF-044).`
1039
- );
1040
- }
1041
-
1042
- // Re-project via toLedgerRow (strict canonical projection — ADR-022).
1043
- // Preserve decisions_status and date from the ledger (ledger-owned fields).
1044
- // expectType passed for PF-023 sink validation (redundant with the check above,
1045
- // but ensures the guard holds even if future callers bypass the outer check).
1046
- rfLedgerRows[rfLedgerIdx] = toLedgerRow(rfObs, {
1047
- anchorId,
1048
- status: rfExistingRow.decisions_status,
1049
- date: rfExistingRow.date,
1050
- expectType: rfExistingRow.type,
1051
- });
1052
- }
1053
-
1054
- // (3) REL-6: assert row count unchanged — bounds parseLedger silent-drop
1055
- // exposure. A whole-file rewrite that shrank the corpus is always a bug.
1056
- if (rfLedgerRows.length !== rfExpectedRowCount) {
1057
- throw new Error(
1058
- `refresh-anchor: ledger row count changed during re-projection ` +
1059
- `(${rfExpectedRowCount} → ${rfLedgerRows.length}) — refusing to write a lossy rewrite`
1060
- );
1061
- }
1062
-
1063
- // (4) Write once and render once (PERF-1 — N anchors, one I/O round-trip).
1064
- writeFileAtomic(rfLedgerPath, serializeLedger(rfLedgerRows));
1065
- renderAndWriteAll(rfProjectRoot, rfLedgerRows);
1066
-
1067
- // Echo all refreshed ids to stdout — one per line, mirrors assign-anchor's
1068
- // contract; callers can confirm which rows were refreshed without parsing stderr.
1069
- process.stdout.write(refreshAnchorIds.join('\n') + '\n');
1070
- });
584
+ // -------------------------------------------------------------------------
585
+ // list
586
+ // Print the ledger and the log by section, read-only (readListing and
587
+ // formatListing, learning-store.cjs). Takes no argument.
588
+ // stdout: the ACTIVE, INACTIVE, OBSERVATIONS and INTEGRITY sections, then
589
+ // MALFORMED when lines were skipped
590
+ // -------------------------------------------------------------------------
591
+ case 'list': {
592
+ if (args.length > 0) exitWithUsage('list (no arguments; run from the project root)');
593
+ const { store } = learning();
594
+ process.exitCode = emit(store.readListing(process.cwd()), store.formatListing);
1071
595
  break;
1072
596
  }
1073
597
 
1074
598
  // -------------------------------------------------------------------------
1075
- // rotate-observations [<log>] [<archive>]
1076
- // AC-F9, AC-P3: Move stale observing rows (>30 days old) to archive.
1077
- // NEVER moves anchored or created/ready rows — only stale 'observing' rows.
1078
- // Runs under .observations.lock (NOT .decisions.lock).
1079
- //
1080
- // Default paths derived from cwd. Accepts explicit log/archive paths as args.
1081
- // For testability, _now_ is injectable via the _nowMs parameter in the
1082
- // internal function; CLI always uses Date.now().
599
+ // show <anchor|obs_id>
600
+ // Print one entry, read-only (showByKey, learning-store.cjs).
601
+ // stdout: pretty JSON { key, ledger, log, history_versions, flags }, plus
602
+ // malformed when lines were skipped
1083
603
  // -------------------------------------------------------------------------
1084
- case 'rotate-observations': {
1085
- // Args may be: [] | [log] | [log, archive]
1086
- const roProjectRoot = process.cwd();
1087
- const roLogPath = args[0] ? safePath(args[0]) : getDecisionsLogPath(roProjectRoot);
1088
- const roArchivePath = args[1] ? safePath(args[1]) : getDecisionsArchivePath(roProjectRoot);
1089
- const roLockDir = getObservationsLockDir(roProjectRoot);
604
+ case 'show': {
605
+ const { store } = learning();
606
+ if (args.length !== 1 || !(store.ANCHOR_ID_RE.test(args[0]) || store.OBS_ID_RE.test(args[0]))) {
607
+ exitWithUsage('show <anchor|obs_id> (run from the project root)');
608
+ }
609
+ process.exitCode = emit(store.showByKey(process.cwd(), args[0]), shown => JSON.stringify(shown, null, 2));
610
+ break;
611
+ }
1090
612
 
1091
- fs.mkdirSync(path.dirname(roLogPath), { recursive: true });
1092
- fs.mkdirSync(path.dirname(roArchivePath), { recursive: true });
1093
- fs.mkdirSync(path.dirname(roLockDir), { recursive: true });
613
+ // -------------------------------------------------------------------------
614
+ // claim-due
615
+ // Hand out the entries due for maintenance and lease each for a day
616
+ // (claimDue, learning-store.cjs: D-DUE-ORDER, D-VERIFY-REF). Takes no argument.
617
+ // stdout: ref <origin/HEAD|HEAD> <sha12>, or ref none; then one
618
+ // `<anchor> <reason> <bytes>` line per entry handed out, or due none
619
+ // -------------------------------------------------------------------------
620
+ case 'claim-due': {
621
+ if (args.length > 0) exitWithUsage('claim-due (no arguments; run from the project root)');
622
+ const { store } = learning();
623
+ process.exitCode = emit(store.claimDue(process.cwd()), ({ ref, due }) => [
624
+ ref === null ? 'ref none' : `ref ${ref.ref} ${ref.commit.slice(0, 12)}`,
625
+ ...(due.length > 0 ? due.map(entry => `${store.singleLine(entry.anchor_id)} ${entry.reason} ${entry.bytes}`) : ['due none']),
626
+ ].join('\n'));
627
+ break;
628
+ }
1094
629
 
1095
- if (!acquireMkdirLock(roLockDir, 30000, 60000)) {
1096
- process.stderr.write('rotate-observations: timeout acquiring .observations.lock\n');
1097
- process.exit(1);
1098
- }
630
+ // -------------------------------------------------------------------------
631
+ // claim-queue
632
+ // Claim the learning queue for this run (D-OWNED-CLAIM, learning-store.cjs).
633
+ // Takes no argument; mints the token itself.
634
+ // stdout: claimed <token> | claimed <token> takeover | busy | none
635
+ // -------------------------------------------------------------------------
636
+ case 'claim-queue': {
637
+ if (args.length > 0) exitWithUsage('claim-queue (no arguments; run from the project root)');
638
+ const result = learning().store.claimQueue(process.cwd());
639
+ process.exitCode = emit(result, claim => (claim.state === 'claimed'
640
+ ? `claimed ${claim.token}${claim.takeover ? ' takeover' : ''}`
641
+ : claim.state));
642
+ break;
643
+ }
1099
644
 
1100
- try {
1101
- const roRotated = rotateObservations(roLogPath, roArchivePath, Date.now());
1102
- process.stdout.write(`rotated ${roRotated} observing rows\n`);
1103
- } finally {
1104
- releaseLock(roLockDir);
645
+ // -------------------------------------------------------------------------
646
+ // release-claim <token>
647
+ // Release the claim the token owns (D-OWNED-CLAIM, learning-store.cjs).
648
+ // stdout: released | not-owner | gone
649
+ // -------------------------------------------------------------------------
650
+ case 'release-claim': {
651
+ const { store } = learning();
652
+ if (args.length !== 1 || !store.CLAIM_TOKEN_RE.test(args[0])) {
653
+ exitWithUsage('release-claim <token> (the 16 hex characters claim-queue printed)');
1105
654
  }
655
+ process.exitCode = emit(store.releaseClaim(process.cwd(), args[0]), release => release.state);
1106
656
  break;
1107
657
  }
1108
658
 
@@ -1115,19 +665,3 @@ try {
1115
665
  process.exit(1);
1116
666
  }
1117
667
  } // end if (require.main === module)
1118
-
1119
- // Expose helpers for unit testing (only when required as a module, not run as CLI)
1120
- if (typeof module !== 'undefined' && module.exports) {
1121
- module.exports = {
1122
- readUsageFile,
1123
- writeUsageFile,
1124
- registerUsageEntry,
1125
- writeFileAtomic,
1126
- writeJsonlAtomic,
1127
- initDecisionsContent,
1128
- nextAnchorFromLedger,
1129
- rotateObservations,
1130
- scanForAnchorCollision,
1131
- isCollisionScanExcluded,
1132
- };
1133
- }