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
@@ -3,25 +3,117 @@ import { promises as fs } from 'fs';
3
3
  import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
- import { getLearningDir, getLearningTuningConfigPath, getDecisionsLogPath, getDecisionsLockDir, } from '../../core/project-paths.js';
6
+ import { getLearningDir, getLearningTuningConfigPath, } from '../../core/project-paths.js';
7
7
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
8
8
  import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
9
9
  import { getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
10
10
  import { getLedgerRoot } from '../../core/ledger-root.js';
11
- import { sweepLegacyDreamMarkers, drainLearningQueue } from '../../core/learning-queue-cleanup.js';
12
- import { readObservations, warnIfInvalid, } from '../../core/observation-io.js';
11
+ import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
12
+ import { formatLearningStoreUnavailable, loadLearningStore, } from '../../core/learning-store.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Shared helpers
15
+ // ---------------------------------------------------------------------------
16
+ /** What the read and restore commands say about a project with no `.devflow/learning/`. */
17
+ const NO_LEARNING_DATA = 'No learning data in this project yet.';
18
+ /**
19
+ * How long a writer here (--restore, --clear, --reset) waits for the learning lock
20
+ * before it refuses as busy. An op holds the lock for milliseconds, so a longer
21
+ * wait means a stuck holder, and the store's own 30 s wait would leave the command
22
+ * looking hung.
23
+ */
24
+ const LOCK_WAIT_MS = 5000;
25
+ /**
26
+ * Code point ranges JSON.stringify leaves raw that a terminal may act on, or may
27
+ * display reordered: DEL and the C1 controls, the directional marks, the line and
28
+ * paragraph separators, and the bidirectional embeddings, overrides and isolates.
29
+ */
30
+ const TERMINAL_UNSAFE_RANGES = [
31
+ [0x7f, 0x9f],
32
+ [0x200e, 0x200f],
33
+ [0x2028, 0x2029],
34
+ [0x202a, 0x202e],
35
+ [0x2066, 0x2069],
36
+ ];
37
+ /**
38
+ * `value` as pretty JSON a terminal shows as written: every character in
39
+ * TERMINAL_UNSAFE_RANGES becomes its `\uXXXX` escape. Such characters can occur
40
+ * only inside JSON strings, where the escape means the same character, so the
41
+ * text still parses back to `value`.
42
+ */
43
+ function terminalSafeJson(value) {
44
+ let text = '';
45
+ for (const ch of JSON.stringify(value, null, 2)) {
46
+ const code = ch.codePointAt(0) ?? 0;
47
+ const unsafe = TERMINAL_UNSAFE_RANGES.some(([low, high]) => code >= low && code <= high);
48
+ text += unsafe ? `\\u${code.toString(16).padStart(4, '0')}` : ch;
49
+ }
50
+ return text;
51
+ }
52
+ /** `1 decision`, `2 decisions`. */
53
+ function counted(count, noun) {
54
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
55
+ }
56
+ /**
57
+ * The learning store (D-LEARNING-STORE-SEAM), or null after reporting why it
58
+ * cannot be used and setting exit code 1.
59
+ */
60
+ function requireStore() {
61
+ const loaded = loadLearningStore();
62
+ if (loaded.ok)
63
+ return loaded.value;
64
+ p.log.error(`Learning: ${formatLearningStoreUnavailable(loaded.error)}`);
65
+ process.exitCode = 1;
66
+ return null;
67
+ }
68
+ /** What a writer says when the store refused; `undone` completes "Nothing was …". */
69
+ function storeRefusal(error, undone) {
70
+ switch (error.kind) {
71
+ case 'no-learning-dir': return NO_LEARNING_DATA;
72
+ case 'busy': return `The learning store is busy: another run holds its lock. Nothing was ${undone}; try again in a moment.`;
73
+ default: return error.message;
74
+ }
75
+ }
76
+ /**
77
+ * Ask `message` on a terminal and say whether to go on, logging `cancelled` when the
78
+ * answer is no. With no terminal there is no one to ask: a script that passed the
79
+ * flag has decided.
80
+ */
81
+ async function confirmOnTerminal(message, cancelled) {
82
+ if (!process.stdin.isTTY)
83
+ return true;
84
+ const confirmed = await p.confirm({ message, initialValue: false });
85
+ if (p.isCancel(confirmed) || !confirmed) {
86
+ p.log.info(cancelled);
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+ /** True when `dir` is a directory; false when nothing, or something else, is there. */
92
+ async function isDirectory(dir) {
93
+ try {
94
+ return (await fs.stat(dir)).isDirectory();
95
+ }
96
+ catch (err) {
97
+ const code = err.code;
98
+ if (code === 'ENOENT' || code === 'ENOTDIR')
99
+ return false;
100
+ throw err;
101
+ }
102
+ }
13
103
  // ---------------------------------------------------------------------------
14
104
  // Sub-command handlers
15
105
  // ---------------------------------------------------------------------------
16
106
  function printUsage() {
17
107
  p.intro(color.bgCyan(color.black(' Learning ')));
18
- p.note(`${color.cyan('devflow learning --enable')} Enable learning in every project (a repository can opt out)\n` +
19
- `${color.cyan('devflow learning --disable')} Disable learning in every project (drains this project's queue)\n` +
20
- `${color.cyan('devflow learning --status')} Show learning status\n` +
21
- `${color.cyan('devflow learning --list')} Show all observations\n` +
22
- `${color.cyan('devflow learning --configure')} Configuration wizard\n` +
23
- `${color.cyan('devflow learning --clear')} Truncate decisions log\n` +
24
- `${color.cyan('devflow learning --reset')} Remove all learning state files`, 'Usage');
108
+ p.note(`${color.cyan('devflow learning --enable')} Enable learning in every project (a repository can opt out)\n` +
109
+ `${color.cyan('devflow learning --disable')} Disable learning in every project (drains this project's queue)\n` +
110
+ `${color.cyan('devflow learning --status')} Show learning status and entry counts\n` +
111
+ `${color.cyan('devflow learning --list')} List entries, inactive entries and observations\n` +
112
+ `${color.cyan('devflow learning --show <id>')} Print one entry or observation as JSON\n` +
113
+ `${color.cyan('devflow learning --restore <id>')} Make an inactive entry active again\n` +
114
+ `${color.cyan('devflow learning --configure')} Configuration wizard\n` +
115
+ `${color.cyan('devflow learning --clear')} Drop the observations no entry uses, and drain the queue\n` +
116
+ `${color.cyan('devflow learning --reset')} Remove all learning state files`, 'Usage');
25
117
  p.outro(color.dim('Detects architectural decisions and known pitfalls from your sessions'));
26
118
  }
27
119
  /**
@@ -40,10 +132,35 @@ async function requireLedgerRoot(actionSuffix) {
40
132
  }
41
133
  return ledgerRoot;
42
134
  }
135
+ /**
136
+ * The --status entry lines: active entries by type, inactive ones by status (in
137
+ * the store's order, each status present), the active entries still in the v1
138
+ * format — the migration still to do — and the observations.
139
+ */
140
+ function entryCountLines(listing, logRowCount, inactiveStatuses) {
141
+ const active = listing.active;
142
+ const decisions = active.filter(entry => entry.type === 'decision').length;
143
+ const pitfalls = active.filter(entry => entry.type === 'pitfall').length;
144
+ const byStatus = inactiveStatuses
145
+ .map(status => ({ status, count: listing.inactive.filter(entry => entry.status === status).length }))
146
+ .filter(({ count }) => count > 0)
147
+ .map(({ status, count }) => `${status} ${count}`);
148
+ const legacy = active.filter(entry => entry.schema === 1).length;
149
+ return [
150
+ `Entries: ${active.length} active (${counted(decisions, 'decision')}, ${counted(pitfalls, 'pitfall')}), `
151
+ + `${listing.inactive.length} inactive${byStatus.length > 0 ? ` (${byStatus.join(', ')})` : ''}`,
152
+ `Legacy v1 entries: ${legacy} of ${active.length} active`,
153
+ `Observations: ${logRowCount} in the log, ${listing.observations.length} not yet promoted`,
154
+ ];
155
+ }
156
+ /**
157
+ * `--status`: the machine switch, then the counts the store reads. Reads only:
158
+ * malformed lines are counted, never quarantined, and no scope is checked.
159
+ */
43
160
  async function handleStatus() {
44
161
  // D-FEATURES-NARROW-ONLY: the machine switch is the manifest's and reads the
45
162
  // same from every directory; a repository layer can only narrow it, and adds a
46
- // line only when it does. The observation counts are per-project.
163
+ // line only when it does. The entry counts are per-project.
47
164
  const enabled = await readMachineFeature(getDevFlowDirectory(), 'learning');
48
165
  const settingsModule = loadSettingsModule();
49
166
  const narrowed = enabled ? narrowedSwitchLabel(settingsModule, { dir: process.cwd() }, 'learning') : null;
@@ -54,68 +171,89 @@ async function handleStatus() {
54
171
  p.log.warn(trackedWarning);
55
172
  const ledgerRoot = await getLedgerRoot();
56
173
  if (!ledgerRoot) {
57
- p.log.info(`${stateLine}\nObservations: not in a git project`);
58
- return;
59
- }
60
- const logPath = getDecisionsLogPath(ledgerRoot);
61
- const { observations, invalidCount } = await readObservations(logPath);
62
- const decisionObs = observations.filter(o => o.type === 'decision' || o.type === 'pitfall');
63
- const decisions = observations.filter(o => o.type === 'decision');
64
- const pitfalls = observations.filter(o => o.type === 'pitfall');
65
- const created = decisionObs.filter(o => o.status === 'created');
66
- const ready = decisionObs.filter(o => o.status === 'ready');
67
- const observing = decisionObs.filter(o => o.status === 'observing');
68
- const deprecated = decisionObs.filter(o => o.status === 'deprecated');
69
- const lines = [stateLine];
70
- if (decisionObs.length === 0) {
71
- lines.push('Observations: none');
174
+ p.log.info(`${stateLine}\nEntries: not in a git project`);
175
+ return;
72
176
  }
73
- else {
74
- lines.push(`Observations: ${decisionObs.length} total`);
75
- lines.push(` Decisions: ${decisions.length}, Pitfalls: ${pitfalls.length}`);
76
- lines.push(` Status: ${observing.length} observing, ${ready.length} ready, ${created.length} promoted, ${deprecated.length} deprecated`);
177
+ const loaded = loadLearningStore();
178
+ if (!loaded.ok) {
179
+ p.log.info(`${stateLine}\nEntries: unavailable (${formatLearningStoreUnavailable(loaded.error)})`);
180
+ return;
181
+ }
182
+ const store = loaded.value;
183
+ const { ledgerRows, logRows, rejected } = store.readLearningState(ledgerRoot);
184
+ const listing = store.buildListing(ledgerRows, logRows, { rejected });
185
+ p.log.info([stateLine, ...entryCountLines(listing, logRows.length, store.INACTIVE_STATUSES)].join('\n'));
186
+ const { ledger, log } = listing.malformed;
187
+ if (ledger + log > 0) {
188
+ p.log.warn(`Malformed lines skipped: ${ledger} in the ledger, ${log} in the log. ` +
189
+ 'The next op that rewrites a file moves its malformed lines to a .rejected.jsonl file beside it.');
77
190
  }
78
- p.log.info(lines.join('\n'));
79
- warnIfInvalid(invalidCount);
80
191
  }
192
+ /**
193
+ * `--list`: the store's listing — the same text json-helper's `list` op prints —
194
+ * on stdout. Read-only. Outside a git project it reads the current directory.
195
+ */
81
196
  async function handleList() {
82
- // Resolve the log from the ledger root (matches --status, --clear, --reset,
83
- // --disable) so `--list` run from a subdirectory or a linked worktree finds
84
- // the real log instead of a nonexistent one under process.cwd(). Falls back
85
- // to cwd when not in a git project, preserving the prior behavior for that case.
86
- const ledgerRoot = await getLedgerRoot();
87
- const logPath = getDecisionsLogPath(ledgerRoot ?? process.cwd());
88
- let logExists = true;
89
- try {
90
- await fs.access(logPath);
197
+ const root = (await getLedgerRoot()) ?? process.cwd();
198
+ const store = requireStore();
199
+ if (!store)
200
+ return;
201
+ const listed = store.readListing(root);
202
+ if (!listed.ok) {
203
+ if (listed.error.kind === 'no-learning-dir') {
204
+ p.log.info(NO_LEARNING_DATA);
205
+ return;
206
+ }
207
+ p.log.error(listed.error.message);
208
+ process.exitCode = 1;
209
+ return;
91
210
  }
92
- catch {
93
- logExists = false;
211
+ process.stdout.write(`${store.formatListing(listed.value)}\n`);
212
+ }
213
+ /**
214
+ * `--show <id>`: one entry, by its anchor or its observation id, as the JSON
215
+ * json-helper's `show` op prints, made terminal-safe. Read-only. Outside a git
216
+ * project it reads the current directory.
217
+ */
218
+ async function handleShow(key) {
219
+ const root = (await getLedgerRoot()) ?? process.cwd();
220
+ const store = requireStore();
221
+ if (!store)
222
+ return;
223
+ const shown = store.showByKey(root, key);
224
+ if (!shown.ok) {
225
+ p.log.error(shown.error.kind === 'no-learning-dir' ? NO_LEARNING_DATA : shown.error.message);
226
+ process.exitCode = 1;
227
+ return;
94
228
  }
95
- if (!logExists) {
96
- p.log.info('No observations yet. Decisions log not found.');
229
+ process.stdout.write(`${terminalSafeJson(shown.value)}\n`);
230
+ }
231
+ /**
232
+ * `--restore <id>`: make an inactive entry active again through the store's
233
+ * restoreAnchor, which clears its notes and its verification so maintenance
234
+ * reviews it again, and re-renders the files. A refusal writes nothing.
235
+ */
236
+ async function handleRestore(anchor) {
237
+ const ledgerRoot = await requireLedgerRoot('restore not performed');
238
+ if (!ledgerRoot) {
239
+ process.exitCode = 1;
97
240
  return;
98
241
  }
99
- const { observations, invalidCount } = await readObservations(logPath);
100
- const filtered = observations.filter(o => o.type === 'decision' || o.type === 'pitfall');
101
- if (filtered.length === 0) {
102
- p.log.info('No decision/pitfall observations recorded yet.');
242
+ const store = requireStore();
243
+ if (!store)
244
+ return;
245
+ if (!store.ANCHOR_ID_RE.test(anchor)) {
246
+ p.log.error(`--restore takes an entry id (ADR-NNN or PF-NNN), not ${JSON.stringify(anchor)}`);
247
+ process.exitCode = 1;
103
248
  return;
104
249
  }
105
- // Sort by confidence descending
106
- filtered.sort((a, b) => b.confidence - a.confidence);
107
- p.intro(color.bgCyan(color.black(' Learning Observations ')));
108
- for (const obs of filtered) {
109
- const typeIcon = obs.type === 'decision' ? 'D' : 'F';
110
- const statusIcon = obs.status === 'created' ? color.green('created')
111
- : obs.status === 'ready' ? color.yellow('ready')
112
- : obs.status === 'deprecated' ? color.dim('deprecated')
113
- : color.dim('observing');
114
- const conf = (obs.confidence * 100).toFixed(0);
115
- p.log.info(`[${typeIcon}] ${color.cyan(obs.pattern)} (${conf}% | ${obs.observations}x | ${statusIcon})`);
250
+ const restored = store.restoreAnchor(ledgerRoot, anchor, { timeoutMs: LOCK_WAIT_MS });
251
+ if (!restored.ok) {
252
+ p.log.error(storeRefusal(restored.error, 'restored'));
253
+ process.exitCode = 1;
254
+ return;
116
255
  }
117
- warnIfInvalid(invalidCount);
118
- p.outro(color.dim(`${filtered.length} observation(s) total`));
256
+ p.log.success(`Restored ${restored.value.anchor_id} (${restored.value.status}); it is due for review again.`);
119
257
  }
120
258
  async function handleConfigure() {
121
259
  p.intro(color.bgCyan(color.black(' Learning Configuration ')));
@@ -173,84 +311,74 @@ async function handleConfigure() {
173
311
  }
174
312
  p.outro(color.green('Configuration saved.'));
175
313
  }
314
+ /**
315
+ * `--reset`: remove all learning state — entries, observations, rendered files,
316
+ * the tuning config, and the queue with its claim — through the store's
317
+ * resetLearning, under the learning lock every learning writer takes
318
+ * (D-ONE-LEARNING-LOCK, D-RESET-UNDER-LOCK). A project with no learning directory
319
+ * has nothing to reset, and nothing is created there (D-NO-STRAY-TREE).
320
+ */
176
321
  async function handleReset() {
177
322
  const ledgerRoot = await requireLedgerRoot('reset not performed');
178
323
  if (!ledgerRoot)
179
324
  return;
180
- const lockDir = getDecisionsLockDir(ledgerRoot);
181
- // Ensure the parent directory exists so a second reset (after .devflow/learning/
182
- // was already removed) does not fail with ENOENT and emit a false contention error.
183
- await fs.mkdir(path.dirname(lockDir), { recursive: true });
184
- // Acquire lock to prevent conflict with a concurrent `devflow learning` invocation.
185
- // Non-recursive: EEXIST still means genuine contention.
186
- try {
187
- await fs.mkdir(lockDir);
188
- }
189
- catch {
190
- p.log.error('Learning system is currently running. Try again in a moment.');
325
+ const store = requireStore();
326
+ if (!store)
327
+ return;
328
+ if (!(await isDirectory(getLearningDir(ledgerRoot)))) {
329
+ p.log.info('No learning data to reset.');
191
330
  return;
192
331
  }
193
- try {
194
- if (process.stdin.isTTY) {
195
- const confirm = await p.confirm({
196
- message: 'Remove all learning state files? This cannot be undone.',
197
- initialValue: false,
198
- });
199
- if (p.isCancel(confirm) || !confirm) {
200
- p.log.info('Reset cancelled.');
201
- return;
202
- }
203
- }
204
- // Remove the entire learning directory (contains queue files, content files,
205
- // ledger, and tuning config). Single-dir semantics: all learning state lives here.
206
- try {
207
- await fs.rm(getLearningDir(ledgerRoot), { recursive: true, force: true });
208
- }
209
- catch { /* best effort */ }
210
- // Clean legacy dream marker-pipeline stamps from old installs.
211
- // Best-effort: sweeps the now-absent dir silently (ENOENT-tolerant).
212
- try {
213
- await sweepLegacyDreamMarkers(getLearningDir(ledgerRoot));
214
- }
215
- catch { /* best effort */ }
216
- p.log.success('Reset complete — removed .devflow/learning/ state.');
217
- }
218
- finally {
219
- try {
220
- await fs.rmdir(lockDir);
332
+ const proceed = await confirmOnTerminal('Remove all learning state files? This cannot be undone.', 'Reset cancelled.');
333
+ if (!proceed)
334
+ return;
335
+ const reset = store.resetLearning(ledgerRoot, { timeoutMs: LOCK_WAIT_MS });
336
+ if (!reset.ok) {
337
+ if (reset.error.kind === 'no-learning-dir') {
338
+ p.log.info('No learning data to reset.');
339
+ return;
221
340
  }
222
- catch { /* already cleaned */ }
341
+ p.log.error(storeRefusal(reset.error, 'reset'));
342
+ process.exitCode = 1;
343
+ return;
223
344
  }
345
+ p.log.success('Reset complete — removed .devflow/learning/ state.');
224
346
  }
347
+ /**
348
+ * `--clear`: drop the observations no entry uses (D-CLEAR-UNREFERENCED), then
349
+ * drain the learning queue. The queue is drained only once the clear succeeded:
350
+ * a busy lock or a refusal leaves both the log and the queue as they were, so a
351
+ * failed clear changes nothing.
352
+ */
225
353
  async function handleClear() {
226
354
  const ledgerRoot = await requireLedgerRoot('clear not performed');
227
355
  if (!ledgerRoot)
228
356
  return;
229
- const decisionsLogPath = getDecisionsLogPath(ledgerRoot);
230
- try {
231
- await fs.access(decisionsLogPath);
232
- }
233
- catch {
234
- p.log.info('No decisions log to clear.');
357
+ const store = requireStore();
358
+ if (!store)
359
+ return;
360
+ if (!(await isDirectory(getLearningDir(ledgerRoot)))) {
361
+ p.log.info('No learning data to clear.');
235
362
  return;
236
363
  }
237
- if (process.stdin.isTTY) {
238
- const confirm = await p.confirm({
239
- message: 'Clear all decision/pitfall observations? This cannot be undone.',
240
- initialValue: false,
241
- });
242
- if (p.isCancel(confirm) || !confirm) {
243
- p.log.info('Clear cancelled.');
364
+ const proceed = await confirmOnTerminal('Drop every observation no entry uses? Entries and the observations they use are kept. This cannot be undone.', 'Clear cancelled.');
365
+ if (!proceed)
366
+ return;
367
+ const cleared = store.clearUnreferenced(ledgerRoot, { timeoutMs: LOCK_WAIT_MS });
368
+ if (!cleared.ok) {
369
+ if (cleared.error.kind === 'no-learning-dir') {
370
+ p.log.info('No learning data to clear.');
244
371
  return;
245
372
  }
373
+ p.log.error(storeRefusal(cleared.error, 'cleared'));
374
+ process.exitCode = 1;
375
+ return;
246
376
  }
247
- await fs.writeFile(decisionsLogPath, '', 'utf-8');
248
- // Drain the learning (decisions-detection) queue so stale turns don't process
249
- // on the next session — mirrors memory.ts's drain-on-disable behavior for
250
- // the sibling memory queue. A mid-run Learning agent whose claimed batch
251
- // vanishes aborts without changes — the desired outcome of clearing.
377
+ // A mid-run Learning agent whose claimed batch vanishes stops without further
378
+ // writes — the desired outcome of clearing.
252
379
  await drainLearningQueue(ledgerRoot);
253
- p.log.success('Decisions log cleared.');
380
+ p.log.success(`Cleared ${counted(cleared.value.cleared, 'observation')} no entry uses and kept ${cleared.value.kept} ` +
381
+ 'that entries use; drained the learning queue.');
254
382
  }
255
383
  /**
256
384
  * `--enable` / `--disable`: the machine-wide switch (D-FEATURES-NARROW-ONLY),
@@ -283,24 +411,26 @@ export const learningCommand = new Command('learning')
283
411
  .description('Enable or disable learning (decision/pitfall detection) in every project')
284
412
  .option('--enable', 'Enable learning in every project (a repository can opt out)')
285
413
  .option('--disable', 'Disable learning in every project')
286
- .option('--status', 'Show learning status and observation counts')
287
- .option('--list', 'Show all decision/pitfall observations sorted by confidence')
414
+ .option('--status', 'Show learning status and entry counts')
415
+ .option('--list', 'List entries, inactive entries with their notes, and observations')
416
+ .option('--show <id>', 'Print one entry (ADR-NNN or PF-NNN) or observation (obs_...) as JSON')
417
+ .option('--restore <id>', 'Make an inactive entry active again')
288
418
  .option('--configure', 'Interactive configuration wizard for learning.json')
289
- .option('--clear', 'Truncate decisions log (removes all observations)')
419
+ .option('--clear', 'Drop the observations no entry uses, and drain the learning queue')
290
420
  .option('--reset', 'Remove all learning state files and artifacts')
291
421
  .action(async (options) => {
292
422
  const knownFlags = [
293
- 'enable', 'disable', 'status', 'list', 'configure',
423
+ 'enable', 'disable', 'status', 'list', 'show', 'restore', 'configure',
294
424
  'clear', 'reset',
295
425
  ];
296
- const hasFlag = knownFlags.some((f) => options[f]);
426
+ const hasFlag = knownFlags.some((f) => options[f] !== undefined);
297
427
  if (!hasFlag) {
298
428
  printUsage();
299
429
  return;
300
430
  }
301
- // Thin router — dispatch order matches the precedence of the original
302
- // inline implementation (status, list, configure, reset, clear, enable,
303
- // disable). Each handler owns its own path resolution and I/O.
431
+ // Thin router — the read-only commands first (status, list, show), then
432
+ // configure, reset, clear, restore, enable, disable. Each handler owns its own
433
+ // path resolution and I/O.
304
434
  if (options.status) {
305
435
  await handleStatus();
306
436
  return;
@@ -309,6 +439,10 @@ export const learningCommand = new Command('learning')
309
439
  await handleList();
310
440
  return;
311
441
  }
442
+ if (options.show !== undefined) {
443
+ await handleShow(options.show);
444
+ return;
445
+ }
312
446
  if (options.configure) {
313
447
  await handleConfigure();
314
448
  return;
@@ -321,6 +455,10 @@ export const learningCommand = new Command('learning')
321
455
  await handleClear();
322
456
  return;
323
457
  }
458
+ if (options.restore !== undefined) {
459
+ await handleRestore(options.restore);
460
+ return;
461
+ }
324
462
  if (options.enable) {
325
463
  await handleToggle(true);
326
464
  return;
@@ -83,7 +83,7 @@ export function addMemoryHooks(settingsJson, devflowDir) {
83
83
  export function removeMemoryHooks(input) {
84
84
  const settingsJson = typeof input === 'string' ? input : JSON.stringify(input);
85
85
  const settings = typeof input === 'string' ? JSON.parse(input) : structuredClone(input);
86
- // Evaluate every removal into a local — never short-circuit (PF-015).
86
+ // Evaluate every removal into a local — never short-circuit.
87
87
  let changed = false;
88
88
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
89
89
  const removed = removeHooks(settings, hookType, isMemoryHook(marker));