devflow-kit 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/agents/git.md +2 -2
  4. package/dist/cli/agents-view/index.js +1 -1
  5. package/dist/cli/agents-view/render.js +71 -17
  6. package/dist/cli/agents-view/state.js +42 -16
  7. package/dist/cli/agents-view/terminal.js +5 -5
  8. package/dist/cli/commands/agents.js +142 -51
  9. package/dist/cli/commands/ambient.js +1 -1
  10. package/dist/cli/commands/attribution-prompts.js +8 -8
  11. package/dist/cli/commands/capture.js +1 -1
  12. package/dist/cli/commands/compliance-prompts.js +8 -8
  13. package/dist/cli/commands/compliance.js +8 -7
  14. package/dist/cli/commands/flags.js +33 -31
  15. package/dist/cli/commands/hud.js +1 -1
  16. package/dist/cli/commands/init-seed.js +9 -9
  17. package/dist/cli/commands/init.js +162 -85
  18. package/dist/cli/commands/install-report.js +10 -10
  19. package/dist/cli/commands/learning.js +302 -136
  20. package/dist/cli/commands/memory.js +36 -15
  21. package/dist/cli/commands/proxy.js +23 -23
  22. package/dist/cli/commands/rules.js +6 -5
  23. package/dist/cli/commands/tracker-prompts.js +6 -6
  24. package/dist/cli/commands/tracker.js +9 -9
  25. package/dist/cli/commands/uninstall.js +183 -59
  26. package/dist/cli/flags-view/render.js +5 -5
  27. package/dist/cli/flags-view/state.js +9 -9
  28. package/dist/cli/flags-view/terminal.js +4 -4
  29. package/dist/cli/tui/cells.js +1 -1
  30. package/dist/cli/tui/terminal.js +6 -6
  31. package/dist/commands/code-review.md +0 -2
  32. package/dist/commands/debug.md +14 -11
  33. package/dist/commands/dynamic-build.md +51 -47
  34. package/dist/commands/dynamic-plan.md +27 -7
  35. package/dist/commands/dynamic-profile.md +17 -3
  36. package/dist/commands/dynamic-tickets.md +18 -4
  37. package/dist/commands/explore.md +9 -3
  38. package/dist/commands/implement.md +20 -16
  39. package/dist/commands/plan.md +13 -9
  40. package/dist/commands/release.md +23 -3
  41. package/dist/commands/research.md +9 -3
  42. package/dist/commands/resolve.md +9 -12
  43. package/dist/commands/self-review.md +0 -2
  44. package/dist/core/agent-frontmatter.js +28 -3
  45. package/dist/core/agent-models.js +204 -42
  46. package/dist/core/agent-state.js +28 -6
  47. package/dist/core/ansi.js +2 -2
  48. package/dist/core/assets.js +1 -1
  49. package/dist/core/cache.js +7 -8
  50. package/dist/core/codex-auth-inspect.js +4 -4
  51. package/dist/core/compliance-compose.js +3 -3
  52. package/dist/core/compliance.js +3 -4
  53. package/dist/core/evidence-policy.js +14 -13
  54. package/dist/core/external-models.js +1 -1
  55. package/dist/core/feature-config.js +71 -13
  56. package/dist/core/feature-switch.js +3 -3
  57. package/dist/core/flags.js +49 -25
  58. package/dist/core/fs-atomic.js +6 -7
  59. package/dist/core/learning-queue-cleanup.js +16 -81
  60. package/dist/core/learning-store.js +61 -0
  61. package/dist/core/linked-path.js +46 -0
  62. package/dist/core/manifest.js +5 -5
  63. package/dist/core/mds-variants.js +13 -13
  64. package/dist/core/model-discovery.js +8 -8
  65. package/dist/core/observations.js +17 -101
  66. package/dist/core/orphan-sweep.js +4 -4
  67. package/dist/core/plugins.js +13 -8
  68. package/dist/core/project-paths.js +9 -13
  69. package/dist/core/proxy-log.js +8 -8
  70. package/dist/core/proxy-state.js +3 -3
  71. package/dist/core/queue-drain.js +31 -0
  72. package/dist/core/reference-sweep.js +6 -6
  73. package/dist/core/teammate-mode-cleanup.js +1 -1
  74. package/dist/core/tracker.js +14 -14
  75. package/dist/hud/colors.js +2 -2
  76. package/dist/hud/components/learning-counts.js +54 -22
  77. package/dist/hud/components/version-badge.js +1 -1
  78. package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
  79. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  80. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  81. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  82. package/dist/targets/claude-code/compliance-install.js +17 -15
  83. package/dist/targets/claude-code/hooks.js +2 -2
  84. package/dist/targets/claude-code/installer.js +59 -32
  85. package/dist/targets/claude-code/legacy.js +1 -1
  86. package/dist/targets/claude-code/post-install.js +135 -45
  87. package/dist/targets/claude-code/tracker-install.js +2 -2
  88. package/package.json +1 -1
  89. package/src/assets/agents/code.md +15 -21
  90. package/src/assets/agents/design.md +4 -2
  91. package/src/assets/agents/diagnose.md +3 -1
  92. package/src/assets/agents/evaluate.md +4 -0
  93. package/src/assets/agents/git.mds +2 -2
  94. package/src/assets/agents/knowledge.md +5 -3
  95. package/src/assets/agents/learning.md +281 -196
  96. package/src/assets/agents/research.md +3 -1
  97. package/src/assets/agents/review.md +5 -3
  98. package/src/assets/agents/scrutinize.md +5 -1
  99. package/src/assets/agents/simplify.md +4 -0
  100. package/src/assets/agents/skim.md +4 -2
  101. package/src/assets/agents/synthesize.md +6 -0
  102. package/src/assets/agents/test.md +18 -10
  103. package/src/assets/agents/triage.md +11 -9
  104. package/src/assets/agents/validate.md +14 -10
  105. package/src/assets/commands/_partials/_decisions.mds +8 -3
  106. package/src/assets/commands/_partials/_docs_root.mds +3 -3
  107. package/src/assets/commands/_partials/_engine.mds +16 -32
  108. package/src/assets/commands/_partials/_knowledge.mds +0 -2
  109. package/src/assets/commands/_partials/_preamble.mds +6 -2
  110. package/src/assets/commands/_partials/_settings.mds +2 -2
  111. package/src/assets/commands/_partials/_tracker.mds +1 -1
  112. package/src/assets/commands/code-review.mds +0 -2
  113. package/src/assets/commands/debug.mds +13 -8
  114. package/src/assets/commands/dynamic-build.mds +18 -12
  115. package/src/assets/commands/dynamic-plan.mds +10 -4
  116. package/src/assets/commands/dynamic-profile.mds +1 -1
  117. package/src/assets/commands/dynamic-tickets.mds +2 -2
  118. package/src/assets/commands/explore.mds +9 -1
  119. package/src/assets/commands/implement.mds +19 -13
  120. package/src/assets/commands/plan.mds +12 -8
  121. package/src/assets/commands/release.md +23 -3
  122. package/src/assets/commands/research.mds +9 -3
  123. package/src/assets/commands/resolve.mds +9 -10
  124. package/src/assets/mds/git/_pr.mds +3 -3
  125. package/src/assets/mds/tracker/_common.mds +1 -1
  126. package/src/assets/mds/tracker/_github.mds +3 -3
  127. package/src/assets/mds/tracker/_jira.mds +3 -3
  128. package/src/assets/mds/tracker/_linear.mds +3 -3
  129. package/src/assets/mds/tracker/_mcp.mds +6 -5
  130. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
  131. package/src/assets/scripts/hooks/background-memory-update +97 -33
  132. package/src/assets/scripts/hooks/capture-prompt +4 -3
  133. package/src/assets/scripts/hooks/capture-question +4 -3
  134. package/src/assets/scripts/hooks/capture-turn +5 -20
  135. package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
  136. package/src/assets/scripts/hooks/ensure-proxy +5 -6
  137. package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
  138. package/src/assets/scripts/hooks/git-marker +71 -0
  139. package/src/assets/scripts/hooks/is-hex-sha +1 -1
  140. package/src/assets/scripts/hooks/json-helper.cjs +345 -944
  141. package/src/assets/scripts/hooks/json-parse +25 -129
  142. package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
  143. package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
  144. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
  145. package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
  146. package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
  147. package/src/assets/scripts/hooks/memory-worker +10 -0
  148. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  149. package/src/assets/scripts/hooks/preamble +9 -1
  150. package/src/assets/scripts/hooks/queue-append +55 -23
  151. package/src/assets/scripts/hooks/resolve-project-root +3 -4
  152. package/src/assets/scripts/hooks/session-start-context +146 -45
  153. package/src/assets/scripts/hooks/session-start-memory +33 -11
  154. package/src/assets/scripts/lib/project-config.cjs +2 -2
  155. package/src/assets/scripts/pr-evidence.cjs +3 -3
  156. package/src/assets/scripts/redact-secrets.cjs +20 -20
  157. package/src/assets/scripts/release-trace.cjs +1 -1
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
  159. package/src/assets/scripts/resolve-settings.cjs +3 -3
  160. package/src/assets/scripts/verify-evidence.cjs +2 -2
  161. package/src/assets/skills/apply-decisions/SKILL.md +37 -17
  162. package/src/assets/skills/docs-framework/SKILL.md +2 -2
  163. package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
  164. package/src/assets/skills/test-driven-development/SKILL.md +6 -4
  165. package/dist/core/observation-io.js +0 -50
  166. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -1,498 +1,263 @@
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
- // validate Exit 0 if stdin is valid JSON, 1 otherwise
13
- // compact Compact stdin JSON to single line
14
- // construct <json-template> [--arg k v] Build JSON object with args
15
- // update-field <field> <value> [--json] Set field on stdin JSON (--json parses value)
16
- // update-fields <json-patches> Apply multiple field updates from stdin JSON
14
+ // get-string-field <field> Read field from stdin JSON only when it is a string
17
15
  // extract-cwd-field <field> Extract cwd + arbitrary field, SOH-byte delimited
18
- // extract-text-messages Extract text content from Claude message format
19
- // 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
- // array-length <path> Get length of array at dotted path in stdin JSON
23
- // array-item <path> <index> Get item at index from array at path in stdin JSON
24
16
  // session-output <context> Build SessionStart output envelope
25
17
  // prompt-output <context> Build UserPromptSubmit output envelope
26
18
  // 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
19
+ // assign-anchor <decision|pitfall> <obs_id>
20
+ // Promote a v2 observation to the next ADR/PF number,
21
+ // skipping numbers tracked files cite; re-renders
22
+ // retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
23
+ // Make an entry inactive with the one JSON object on
24
+ // stdin its status takes; re-renders
25
+ // restore-anchor <anchor> Make an inactive entry active again and due for
26
+ // maintenance again, ordered after integrity problems and
27
+ // legacy entries (among them if it is one); re-renders
28
+ // refresh-anchor <anchor>... [--verified]
29
+ // Re-project active v2 entries from the log, or stamp
30
+ // them verified today; re-renders
31
+ // rotate-observations Archive unreferenced observations idle 30+ days
32
+ // put-observation --create|--update|--reinforce
33
+ // Store one observation from one JSON object on
34
+ // stdin; re-projects and re-renders its entries
35
+ // list Read-only: print the ledger and the log by section
36
+ // show <anchor|obs_id> Read-only: print one entry as pretty JSON
37
+ // claim-due Hand out the entries due for maintenance, leased
38
+ // for a day, after the ref their claims are checked at
39
+ // claim-queue Claim the learning queue for this run; prints
40
+ // claimed <token>[ takeover] | busy | none
41
+ // release-claim <token> Release the claim the token owns; prints
42
+ // released | not-owner | gone
43
+ //
44
+ // Every learning op above except claim-queue and release-claim first refreshes
45
+ // the mtime of an existing queue claim — the heartbeat (D-OWNED-CLAIM).
36
46
 
37
47
  'use strict';
38
48
 
39
49
  const fs = require('fs');
40
- const path = require('path');
41
- const { execFileSync } = require('child_process');
50
+ const { constants: { MAX_STRING_LENGTH } } = require('buffer');
42
51
 
43
52
  const op = process.argv[2];
44
53
  const args = process.argv.slice(3);
45
54
 
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
- }
55
+ /** The learning modules, once loaded; see learning(). */
56
+ let learningModules = null;
139
57
 
140
58
  /**
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.
59
+ * The learning store, loaded on first use and memoized. The generic ops never
60
+ * call it, so a hook that falls back from jq to node never pays for loading it,
61
+ * and the store loads the renderer only when an op renders.
144
62
  *
145
- * @param {object[]} ledgerRows - All rows from the ledger (from parseLedger)
146
- * @param {'decision'|'pitfall'} type
147
- * @returns {{ anchorId: string, nextN: string }}
63
+ * @returns {{ store: object }}
148
64
  */
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
- }
65
+ function learning() {
66
+ if (learningModules === null) {
67
+ learningModules = { store: require('./lib/learning-store.cjs') };
160
68
  }
161
- const nextN = (maxN + 1).toString().padStart(3, '0');
162
- return { anchorId: `${prefix}-${nextN}`, nextN };
69
+ return learningModules;
163
70
  }
164
71
 
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
- // ---------------------------------------------------------------------------
72
+ /** The bytes each read of stdin asks for. */
73
+ const STDIN_READ_BYTES = 64 * 1024;
175
74
 
176
- /** Directory names excluded from collision scanning at any depth (E4). */
177
- const COLLISION_SCAN_EXCLUDED_SEGMENTS = new Set(['.git', 'node_modules', 'target', 'dist']);
75
+ /** How long a read of stdin sleeps when a non-blocking descriptor has no input yet. */
76
+ const STDIN_WAIT_MS = 10;
178
77
 
179
- /** Files larger than this are skipped during collision scanning — bounds the scan (E4). */
180
- const COLLISION_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
78
+ /** The most sleeps one read of stdin takes: 10 s in all, long after any writer the helper's callers use has written. */
79
+ const STDIN_MAX_WAITS = 1000;
181
80
 
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
- }
81
+ /** What Atomics.wait sleeps on: nothing notifies it, so each wait runs its full time. */
82
+ const STDIN_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
195
83
 
196
84
  /**
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`.
85
+ * Read stdin to its end, keeping at most `maxBytes`. Every op that reads stdin
86
+ * reads it here, so the generic ops and the learning ops cannot read it two ways.
87
+ * Never throws: a failure is a refusal.
201
88
  *
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.
89
+ * D-STDIN-FD0: stdin is read from file descriptor 0 itself, in reads repeated
90
+ * until one returns no bytes — never by opening /dev/stdin, and never by the size
91
+ * fstat reports. Reason: Linux opens /dev/stdin through /proc/self/fd/0, which
92
+ * refuses a socket with ENXIO, and a node parent's 'pipe' hands its child a
93
+ * socket; and for a pipe or a socket fstat reports only what is buffered so far,
94
+ * 0 on Linux, not what the writer has still to send.
206
95
  *
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).
96
+ * A read of a non-blocking descriptor with no input yet fails with EAGAIN (Linux
97
+ * and macOS give EWOULDBLOCK the same number, which node reports as EAGAIN). That
98
+ * is not the end of the input: the reader sleeps STDIN_WAIT_MS and reads again,
99
+ * at most STDIN_MAX_WAITS times in all. The loop is bounded: each pass takes at
100
+ * least one byte, ends it, or spends one of those waits, and it stops once it
101
+ * holds one byte more than `maxBytes`, so it never reads past that byte.
223
102
  *
224
- * @param {string} projectRoot
225
- * @returns {string[]} project-relative paths
103
+ * @param {number} maxBytes
104
+ * @returns {{ ok: true, value: string } | { ok: false, error: { kind: 'too-large' | 'unreadable', message: string } }}
105
+ * the text; or `too-large` when stdin holds more than `maxBytes`, `unreadable` when a read fails
226
106
  */
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;
107
+ function readStdinUpTo(maxBytes) {
108
+ const buf = Buffer.alloc(Math.min(STDIN_READ_BYTES, maxBytes + 1));
109
+ const chunks = [];
110
+ let total = 0;
111
+ let waits = 0;
112
+ while (total <= maxBytes) {
113
+ let read;
234
114
  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);
115
+ read = fs.readSync(0, buf, 0, Math.min(buf.length, maxBytes + 1 - total), null);
116
+ } catch (err) {
117
+ if (!err || err.code !== 'EAGAIN') {
118
+ return { ok: false, error: { kind: 'unreadable', message: `stdin could not be read: ${err && err.message ? err.message : String(err)}` } };
246
119
  }
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 });
120
+ if (waits === STDIN_MAX_WAITS) {
121
+ 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
122
  }
123
+ waits += 1;
124
+ Atomics.wait(STDIN_WAIT_CELL, 0, 0, STDIN_WAIT_MS);
125
+ continue;
297
126
  }
127
+ if (read === 0) break;
128
+ chunks.push(Buffer.from(buf.subarray(0, read)));
129
+ total += read;
298
130
  }
299
- return hits;
131
+ if (total > maxBytes) return { ok: false, error: { kind: 'too-large', message: `stdin holds more than ${maxBytes} bytes` } };
132
+ return { ok: true, value: Buffer.concat(chunks, total).toString('utf8') };
300
133
  }
301
134
 
302
135
  /**
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}
136
+ * The most stdin a generic op reads: the longest string node can build. A hook's
137
+ * input, a Stop hook's last assistant message among it, has no limit of its own,
138
+ * so a generic op refuses only what it could not hold as text.
308
139
  */
309
- function formatCollisionHits(hits) {
310
- return hits.map(h => ` ${h.file}:${h.line}`).join('\n');
311
- }
140
+ const STDIN_TEXT_MAX_BYTES = MAX_STRING_LENGTH;
312
141
 
313
- /**
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}}
317
- */
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
- }
327
-
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');
335
- }
336
-
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);
351
- }
142
+ /** A generic op's stdin, trimmed: '' when it cannot be read, so the op fails as it does on empty input. */
143
+ function readStdin() {
144
+ const text = readStdinUpTo(STDIN_TEXT_MAX_BYTES);
145
+ return text.ok ? text.value.trim() : '';
352
146
  }
353
147
 
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');
148
+ function getNestedField(obj, field) {
149
+ const parts = field.split('.');
150
+ let current = obj;
151
+ for (const part of parts) {
152
+ if (current == null || typeof current !== 'object') return undefined;
153
+ current = current[part];
422
154
  }
423
-
424
- // Write remaining rows back to log
425
- writeJsonlAtomic(logPath, kept);
426
-
427
- return stale.length;
155
+ return current;
428
156
  }
429
157
 
430
158
  function parseArgs(argList) {
431
159
  const result = {};
432
- const jsonArgs = {};
433
160
  for (let i = 0; i < argList.length; i++) {
434
161
  if (argList[i] === '--arg' && i + 2 < argList.length) {
435
162
  result[argList[i + 1]] = argList[i + 2];
436
163
  i += 2;
437
- } else if (argList[i] === '--argjson' && i + 2 < argList.length) {
438
- try {
439
- jsonArgs[argList[i + 1]] = JSON.parse(argList[i + 2]);
440
- } catch {
441
- jsonArgs[argList[i + 1]] = argList[i + 2];
442
- }
443
- i += 2;
444
164
  }
445
165
  }
446
- return { ...result, ...jsonArgs };
166
+ return result;
447
167
  }
448
168
 
449
169
  // ---------------------------------------------------------------------------
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).
170
+ // Learning-op adapter
453
171
  // ---------------------------------------------------------------------------
454
172
 
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
173
  /**
461
- * Run fn() under .decisions.lock.
174
+ * Print a learning op's Result: `format(value)` and a newline on stdout, or the
175
+ * error message and a newline on stderr. Returns the exit code for the op to set
176
+ * as process.exitCode, so the process exits once, after the op has returned and
177
+ * every lock it took is released.
462
178
  *
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.
179
+ * @param {{ ok: true, value: unknown } | { ok: false, error: { message: string } }} result
180
+ * @param {(value: any) => string} format - the stdout text for the value
181
+ * @returns {0|1}
182
+ */
183
+ function emit(result, format) {
184
+ if (result.ok) {
185
+ process.stdout.write(`${format(result.value)}\n`);
186
+ return 0;
187
+ }
188
+ process.stderr.write(`${result.error.message}\n`);
189
+ return 1;
190
+ }
191
+
192
+ /**
193
+ * Refuse a malformed command line: print `<op>: usage: <usage>` on stderr and exit 1,
194
+ * before the op takes any lock.
466
195
  *
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.
196
+ * @param {string} usage - the usage text, starting with the op's name
197
+ * @returns {never}
198
+ */
199
+ function exitWithUsage(usage) {
200
+ process.stderr.write(`${op}: usage: ${usage}\n`);
201
+ process.exit(1);
202
+ }
203
+
204
+ /** The most stdin a learning op reads: far above any valid input, so a runaway writer is refused, not parsed. */
205
+ const STDIN_JSON_MAX_BYTES = 64 * 1024;
206
+
207
+ /**
208
+ * A learning op's stdin as one JSON object: the one way text reaches a learning
209
+ * op, so no field of it ever passes through argv or a shell word. Never throws.
469
210
  *
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
211
+ * @param {string} opName - for the message
212
+ * @returns {{ ok: true, value: object } | { ok: false, error: { kind: 'invalid-input', message: string } }}
473
213
  */
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);
214
+ function readStdinJson(opName) {
215
+ const refuse = message => ({ ok: false, error: { kind: 'invalid-input', message: `${opName}: ${message}` } });
216
+ const text = readStdinUpTo(STDIN_JSON_MAX_BYTES);
217
+ if (!text.ok) {
218
+ return refuse(text.error.kind === 'too-large' ? `${text.error.message}; it must hold one JSON object` : text.error.message);
481
219
  }
482
- try { return fn(); } finally { releaseLock(lockDir); }
220
+ let value;
221
+ try {
222
+ value = JSON.parse(text.value);
223
+ } catch {
224
+ return refuse('stdin must hold one JSON object');
225
+ }
226
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return refuse('stdin must hold one JSON object');
227
+ return { ok: true, value };
483
228
  }
484
229
 
230
+ /** put-observation's flags and the store modes they name. */
231
+ const PUT_MODES = new Map([['--create', 'create'], ['--update', 'update'], ['--reinforce', 'reinforce']]);
232
+
233
+ /** The entry types assign-anchor takes. */
234
+ const ENTRY_TYPES = new Set(['decision', 'pitfall']);
235
+
485
236
  /**
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}
237
+ * The learning ops whose run sends the claim heartbeat first (D-OWNED-CLAIM).
238
+ * claim-queue and release-claim manage the claim themselves, and the generic ops
239
+ * never touch it. A new learning op joins this set.
491
240
  */
492
- const serializeLedger = rows => rows.map(r => JSON.stringify(r)).join('\n') + '\n';
241
+ const LEARNING_OPS = new Set([
242
+ 'assign-anchor', 'retire-anchor', 'restore-anchor', 'refresh-anchor', 'rotate-observations',
243
+ 'put-observation', 'list', 'show', 'claim-due',
244
+ ]);
245
+
246
+ /** Send the claim heartbeat; a failure is reported on stderr and never stops the op. */
247
+ function heartbeat(root) {
248
+ const beat = learning().store.touchClaim(root);
249
+ if (!beat.ok) process.stderr.write(`${beat.error.message}\n`);
250
+ }
493
251
 
252
+ // The learning ops run from the project root and take no path to a learning file:
253
+ // each builds its paths from the current directory. Every op that writes takes the
254
+ // store's one learning lock through withDecisionsLock (D-ONE-LEARNING-LOCK) and
255
+ // refuses, creating nothing, when .devflow/learning/ is absent (D-NO-STRAY-TREE)
256
+ // or when it, or .devflow, is a symbolic link (D-NO-LINKED-TREE).
257
+ // A locked body returns its Result; emit prints it once the lock is released.
494
258
  if (require.main === module) {
495
259
  try {
260
+ if (LEARNING_OPS.has(op)) heartbeat(process.cwd());
496
261
  switch (op) {
497
262
  case 'get-field': {
498
263
  const input = JSON.parse(readStdin());
@@ -503,65 +268,15 @@ try {
503
268
  break;
504
269
  }
505
270
 
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
- case 'validate': {
518
- try {
519
- const text = readStdin();
520
- if (!text) process.exit(1);
521
- JSON.parse(text);
522
- process.exit(0);
523
- } catch {
524
- process.exit(1);
525
- }
526
- break;
527
- }
528
-
529
- case 'compact': {
530
- const input = JSON.parse(readStdin());
531
- console.log(JSON.stringify(input));
532
- break;
533
- }
534
-
535
- case 'construct': {
536
- // Build JSON from --arg/--argjson pairs
537
- const template = parseArgs(args);
538
- console.log(JSON.stringify(template));
539
- break;
540
- }
541
-
542
- case 'update-field': {
543
- const input = JSON.parse(readStdin());
544
- const field = args[0];
545
- const value = args[1];
546
- const isJson = args[2] === '--json';
547
- input[field] = isJson ? JSON.parse(value) : value;
548
- console.log(JSON.stringify(input));
549
- break;
550
- }
551
-
552
- case 'update-fields': {
553
- // Read stdin JSON, apply field updates from args: field1=val1 field2=val2
271
+ case 'get-string-field': {
272
+ // The typed read: get-field stringifies, so the number 42 and the string
273
+ // "42" are the same to it. Here a JSON string prints byte-exact (no
274
+ // trailing newline added, so the caller sees the value's own) and every
275
+ // other type, an absent field, or a string holding a NUL (no shell
276
+ // variable can carry one) prints nothing.
554
277
  const input = JSON.parse(readStdin());
555
- for (const arg of args) {
556
- const eqIdx = arg.indexOf('=');
557
- if (eqIdx > 0) {
558
- const key = arg.slice(0, eqIdx);
559
- const val = arg.slice(eqIdx + 1);
560
- // Try to parse as JSON, fall back to string
561
- try { input[key] = JSON.parse(val); } catch { input[key] = val; }
562
- }
563
- }
564
- console.log(JSON.stringify(input));
278
+ const val = getNestedField(input, args[0]);
279
+ if (typeof val === 'string' && !val.includes('\0')) process.stdout.write(val);
565
280
  break;
566
281
  }
567
282
 
@@ -577,77 +292,6 @@ try {
577
292
  break;
578
293
  }
579
294
 
580
- case 'extract-text-messages': {
581
- const input = JSON.parse(readStdin());
582
- const content = input?.message?.content;
583
- if (typeof content === 'string') {
584
- console.log(content);
585
- break;
586
- }
587
- if (!Array.isArray(content)) {
588
- console.log('');
589
- break;
590
- }
591
- const texts = content
592
- .filter(c => c.type === 'text')
593
- .map(c => c.text);
594
- console.log(texts.join('\n'));
595
- break;
596
- }
597
-
598
- case 'merge-evidence': {
599
- const input = JSON.parse(readStdin());
600
- // input is [[old_evidence], [new_evidence]] — flatten, dedupe, limit
601
- const flat = input.flat();
602
- const unique = [...new Set(flat)];
603
- console.log(JSON.stringify(unique.slice(0, 10)));
604
- break;
605
- }
606
-
607
- 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);
612
- parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
613
- console.log(JSON.stringify(parsed.slice(0, limit)));
614
- break;
615
- }
616
-
617
- case 'slurp-cap': {
618
- // 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);
623
- parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
624
- for (const item of parsed.slice(0, limit)) {
625
- console.log(JSON.stringify(item));
626
- }
627
- break;
628
- }
629
-
630
- case 'array-length': {
631
- const input = JSON.parse(readStdin());
632
- const dotPath = args[0];
633
- const arr = getNestedField(input, dotPath);
634
- console.log(Array.isArray(arr) ? arr.length : 0);
635
- break;
636
- }
637
-
638
- case 'array-item': {
639
- const input = JSON.parse(readStdin());
640
- const dotPath = args[0];
641
- const index = parseInt(args[1]);
642
- const arr = getNestedField(input, dotPath);
643
- if (Array.isArray(arr) && index >= 0 && index < arr.length) {
644
- console.log(JSON.stringify(arr[index]));
645
- } else {
646
- console.log('null');
647
- }
648
- break;
649
- }
650
-
651
295
  case 'session-output': {
652
296
  const ctx = args[0];
653
297
  console.log(JSON.stringify({
@@ -687,422 +331,195 @@ try {
687
331
  }
688
332
 
689
333
  // -------------------------------------------------------------------------
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).
334
+ // assign-anchor <decision|pitfall> <obs_id>
335
+ // Promote a v2 observation no ledger row carries to the next entry of its
336
+ // type, skipping each number a tracked file cites (assignAnchor,
337
+ // learning-store.cjs: D-LEDGER-REGISTRY, D-E4-SKIP). It never writes the log.
338
+ // stdout: the anchor; stderr: `assign-anchor: skipped <anchor>, cited in
339
+ // <path>:<line>` for each number skipped
703
340
  // -------------------------------------------------------------------------
704
341
  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);
342
+ const { store } = learning();
343
+ if (args.length !== 2 || !ENTRY_TYPES.has(args[0]) || !store.OBS_ID_RE.test(args[1])) {
344
+ exitWithUsage('assign-anchor <decision|pitfall> <obs_id> (run from the project root)');
711
345
  }
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);
725
- }
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
- }
346
+ const result = store.assignAnchor(process.cwd(), args[0], args[1]);
347
+ if (result.ok) {
348
+ for (const skip of result.value.skipped) {
349
+ process.stderr.write(`assign-anchor: skipped ${skip.anchor_id}, cited in ${store.singleLine(skip.file)}:${skip.line}\n`);
752
350
  }
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}`);
759
- }
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
- });
351
+ }
352
+ process.exitCode = emit(result, assigned => assigned.anchor_id);
834
353
  break;
835
354
  }
836
355
 
837
356
  // -------------------------------------------------------------------------
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.
357
+ // retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated>
358
+ // Make an active entry inactive with the one JSON object on stdin its status
359
+ // takes: { reason } for Retired and Deprecated, { by } for Superseded, and
360
+ // { at, quote } for Encoded, the quote checked at the verify ref
361
+ // (retireAnchor, learning-store.cjs: D-ENCODED-QUOTE).
362
+ // stdout: the new status in lower case and the anchor, then `repointed
363
+ // <anchor>` for each entry re-pointed to the successor
843
364
  // -------------------------------------------------------------------------
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);
365
+ case 'retire-anchor': {
366
+ const { store } = learning();
367
+ if (args.length !== 2 || !store.ANCHOR_ID_RE.test(args[0]) || !store.INACTIVE_STATUSES.includes(args[1])) {
368
+ exitWithUsage('retire-anchor <anchor> <Encoded|Superseded|Retired|Deprecated> (one JSON object on stdin; run from the project root)');
868
369
  }
370
+ const input = readStdinJson('retire-anchor');
371
+ const result = input.ok ? store.retireAnchor(process.cwd(), args[0], args[1], input.value) : input;
372
+ process.exitCode = emit(result, retired => [
373
+ `${retired.status.toLowerCase()} ${retired.anchor_id}`,
374
+ ...retired.repointed.map(anchorId => `repointed ${anchorId}`),
375
+ ].join('\n'));
869
376
  break;
870
377
  }
871
378
 
872
379
  // -------------------------------------------------------------------------
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.
380
+ // restore-anchor <anchor>
381
+ // Make an inactive entry active again, its notes, last_verified and
382
+ // last_attempt cleared so it is due for maintenance again, ordered after
383
+ // integrity problems and legacy entries, or among them when it is one
384
+ // (restoreAnchor, learning-store.cjs: D-DUE-ORDER).
385
+ // stdout: restored <anchor>
879
386
  // -------------------------------------------------------------------------
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);
387
+ case 'restore-anchor': {
388
+ const { store } = learning();
389
+ if (args.length !== 1 || !store.ANCHOR_ID_RE.test(args[0])) {
390
+ exitWithUsage('restore-anchor <anchor> (run from the project root)');
893
391
  }
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
- });
392
+ process.exitCode = emit(store.restoreAnchor(process.cwd(), args[0]), restored => `restored ${restored.anchor_id}`);
915
393
  break;
916
394
  }
917
395
 
918
396
  // -------------------------------------------------------------------------
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.
397
+ // refresh-anchor <anchor> [<anchor>...] [--verified]
398
+ // Re-project active v2 entries from their log rows, or under --verified stamp
399
+ // them verified today; all or nothing (refreshAnchors, learning-store.cjs).
400
+ // stdout: `reprojected <anchor>` or `unchanged <anchor>` per anchor, or
401
+ // `verified <anchor>` under --verified, in the order given
940
402
  // -------------------------------------------------------------------------
941
403
  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);
404
+ const { store } = learning();
405
+ const verifiedFlags = args.filter(arg => arg === '--verified').length;
406
+ const anchors = args.filter(arg => arg !== '--verified');
407
+ if (verifiedFlags > 1 || anchors.length === 0 || !anchors.every(arg => store.ANCHOR_ID_RE.test(arg))) {
408
+ exitWithUsage('refresh-anchor <anchor> [<anchor>...] [--verified] (run from the project root)');
947
409
  }
410
+ const result = store.refreshAnchors(process.cwd(), anchors, { verified: verifiedFlags === 1 });
411
+ process.exitCode = emit(result, ({ refreshed }) => refreshed.map(entry => `${entry.state} ${entry.anchor_id}`).join('\n'));
412
+ break;
413
+ }
948
414
 
949
- const rfProjectRoot = process.cwd();
950
- const rfLedgerPath = getDecisionsLedgerPath(rfProjectRoot);
951
- const rfLogPath = getDecisionsLogPath(rfProjectRoot);
415
+ // -------------------------------------------------------------------------
416
+ // rotate-observations
417
+ // Archive the log rows no ledger row carries once 30 days have passed since
418
+ // their last activity, and delete the usage telemetry's leftovers
419
+ // (D-ROTATE-UNREFERENCED, learning-store.cjs). Takes no argument: the log and
420
+ // the archive are the project root's.
421
+ // stdout: rotated <N> observations
422
+ // -------------------------------------------------------------------------
423
+ case 'rotate-observations': {
424
+ if (args.length > 0) exitWithUsage('rotate-observations (no arguments; run from the project root)');
425
+ const result = learning().store.rotateObservations(process.cwd());
426
+ process.exitCode = emit(result, ({ rotated }) => `rotated ${rotated} observations`);
427
+ break;
428
+ }
952
429
 
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
- );
430
+ // -------------------------------------------------------------------------
431
+ // put-observation --create|--update|--reinforce
432
+ // Store one observation from the JSON object on stdin (D-PUT-NOT-MERGE,
433
+ // D-PUT-REPROJECTS, learning-store.cjs). Exactly one mode flag; no other argv.
434
+ // stdout: created <id> | updated <id> | unchanged <id> | reinforced <id> <n>,
435
+ // then one `reprojected <anchor>` line per entry re-projected
436
+ // -------------------------------------------------------------------------
437
+ case 'put-observation': {
438
+ const mode = args.length === 1 ? PUT_MODES.get(args[0]) : undefined;
439
+ if (mode === undefined) {
440
+ exitWithUsage('put-observation --create|--update|--reinforce (one JSON object on stdin; run from the project root)');
962
441
  }
442
+ const input = readStdinJson('put-observation');
443
+ const result = input.ok ? learning().store.putObservation(process.cwd(), mode, input.value) : input;
444
+ process.exitCode = emit(result, put => [
445
+ put.outcome === 'reinforced' ? `reinforced ${put.id} ${put.observations}` : `${put.outcome} ${put.id}`,
446
+ ...put.reprojected.map(anchorId => `reprojected ${anchorId}`),
447
+ ].join('\n'));
448
+ break;
449
+ }
963
450
 
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
- });
451
+ // -------------------------------------------------------------------------
452
+ // list
453
+ // Print the ledger and the log by section, read-only (readListing and
454
+ // formatListing, learning-store.cjs). Takes no argument.
455
+ // stdout: the ACTIVE, INACTIVE, OBSERVATIONS and INTEGRITY sections, then
456
+ // MALFORMED when lines were skipped
457
+ // -------------------------------------------------------------------------
458
+ case 'list': {
459
+ if (args.length > 0) exitWithUsage('list (no arguments; run from the project root)');
460
+ const { store } = learning();
461
+ process.exitCode = emit(store.readListing(process.cwd()), store.formatListing);
1071
462
  break;
1072
463
  }
1073
464
 
1074
465
  // -------------------------------------------------------------------------
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().
466
+ // show <anchor|obs_id>
467
+ // Print one entry, read-only (showByKey, learning-store.cjs).
468
+ // stdout: pretty JSON { key, ledger, log, history_versions, flags }, plus
469
+ // malformed when lines were skipped
1083
470
  // -------------------------------------------------------------------------
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);
471
+ case 'show': {
472
+ const { store } = learning();
473
+ if (args.length !== 1 || !(store.ANCHOR_ID_RE.test(args[0]) || store.OBS_ID_RE.test(args[0]))) {
474
+ exitWithUsage('show <anchor|obs_id> (run from the project root)');
475
+ }
476
+ process.exitCode = emit(store.showByKey(process.cwd(), args[0]), shown => JSON.stringify(shown, null, 2));
477
+ break;
478
+ }
1090
479
 
1091
- fs.mkdirSync(path.dirname(roLogPath), { recursive: true });
1092
- fs.mkdirSync(path.dirname(roArchivePath), { recursive: true });
1093
- fs.mkdirSync(path.dirname(roLockDir), { recursive: true });
480
+ // -------------------------------------------------------------------------
481
+ // claim-due
482
+ // Hand out the entries due for maintenance and lease each for a day
483
+ // (claimDue, learning-store.cjs: D-DUE-ORDER, D-VERIFY-REF). Takes no argument.
484
+ // stdout: ref <origin/HEAD|HEAD> <sha12>, or ref none; then one
485
+ // `<anchor> <reason> <bytes>` line per entry handed out, or due none
486
+ // -------------------------------------------------------------------------
487
+ case 'claim-due': {
488
+ if (args.length > 0) exitWithUsage('claim-due (no arguments; run from the project root)');
489
+ const { store } = learning();
490
+ process.exitCode = emit(store.claimDue(process.cwd()), ({ ref, due }) => [
491
+ ref === null ? 'ref none' : `ref ${ref.ref} ${ref.commit.slice(0, 12)}`,
492
+ ...(due.length > 0 ? due.map(entry => `${store.singleLine(entry.anchor_id)} ${entry.reason} ${entry.bytes}`) : ['due none']),
493
+ ].join('\n'));
494
+ break;
495
+ }
1094
496
 
1095
- if (!acquireMkdirLock(roLockDir, 30000, 60000)) {
1096
- process.stderr.write('rotate-observations: timeout acquiring .observations.lock\n');
1097
- process.exit(1);
1098
- }
497
+ // -------------------------------------------------------------------------
498
+ // claim-queue
499
+ // Claim the learning queue for this run (D-OWNED-CLAIM, learning-store.cjs).
500
+ // Takes no argument; mints the token itself.
501
+ // stdout: claimed <token> | claimed <token> takeover | busy | none
502
+ // -------------------------------------------------------------------------
503
+ case 'claim-queue': {
504
+ if (args.length > 0) exitWithUsage('claim-queue (no arguments; run from the project root)');
505
+ const result = learning().store.claimQueue(process.cwd());
506
+ process.exitCode = emit(result, claim => (claim.state === 'claimed'
507
+ ? `claimed ${claim.token}${claim.takeover ? ' takeover' : ''}`
508
+ : claim.state));
509
+ break;
510
+ }
1099
511
 
1100
- try {
1101
- const roRotated = rotateObservations(roLogPath, roArchivePath, Date.now());
1102
- process.stdout.write(`rotated ${roRotated} observing rows\n`);
1103
- } finally {
1104
- releaseLock(roLockDir);
512
+ // -------------------------------------------------------------------------
513
+ // release-claim <token>
514
+ // Release the claim the token owns (D-OWNED-CLAIM, learning-store.cjs).
515
+ // stdout: released | not-owner | gone
516
+ // -------------------------------------------------------------------------
517
+ case 'release-claim': {
518
+ const { store } = learning();
519
+ if (args.length !== 1 || !store.CLAIM_TOKEN_RE.test(args[0])) {
520
+ exitWithUsage('release-claim <token> (the 16 hex characters claim-queue printed)');
1105
521
  }
522
+ process.exitCode = emit(store.releaseClaim(process.cwd(), args[0]), release => release.state);
1106
523
  break;
1107
524
  }
1108
525
 
@@ -1115,19 +532,3 @@ try {
1115
532
  process.exit(1);
1116
533
  }
1117
534
  } // 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
- }