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,6 +1,6 @@
1
1
  /**
2
2
  * Rendering for the post-install summary — pure functions that turn an
3
- * {@link InstallReport} into lines, logging nothing (applies ADR-013).
3
+ * {@link InstallReport} into lines, logging nothing.
4
4
  *
5
5
  * Its own module rather than a section of init.ts because `devflow tracker --set`
6
6
  * renders the same overlay outcomes from a different command. A CLI command
@@ -18,18 +18,18 @@ import { prefixSkillName } from '../../core/plugins.js';
18
18
  * shadowed, and a unit it could not refresh is left in one of the states
19
19
  * {@link OverlayFailureState} enumerates — running on the previous install, half
20
20
  * replaced, absent, or recoverable only from a backup path. None of that is visible from
21
- * the filesystem at a glance, so all of it reaches the summary — PF-015: a report field
21
+ * the filesystem at a glance, so all of it reaches the summary — a report field
22
22
  * with no render site is not a report, and a render site that flattens four states into
23
23
  * one sentence is the same defect one layer up.
24
24
  *
25
- * Pure function — returns lines, logs nothing (applies ADR-013).
25
+ * Pure function — returns lines, logs nothing.
26
26
  *
27
27
  * @param skillName - Bare name of the skill hosting the generated references,
28
28
  * rendered `devflow:`-prefixed. Defaults to the core constant the build path and
29
29
  * the installer's overlay trigger both read, so the renderer is never a third
30
- * independent statement of which skill owns them — the divergence PF-013
31
- * describes, where changing the answer means finding every retyped spelling and
32
- * nothing fails if one is missed.
30
+ * independent statement of which skill owns them — a divergence where changing
31
+ * the answer means finding every retyped spelling and nothing fails if one is
32
+ * missed.
33
33
  */
34
34
  export function formatOverlaySummary(report, skillName = SKILL_REFS_SKILL_NAME) {
35
35
  const lines = [];
@@ -59,7 +59,7 @@ export function formatOverlaySummary(report, skillName = SKILL_REFS_SKILL_NAME)
59
59
  * be told about.
60
60
  *
61
61
  * Exported because `devflow tracker --set` renders the same states when it aborts
62
- * on an overlay failure (applies PF-013 — one sentence per state, in one place,
62
+ * on an overlay failure (one sentence per state, in one place,
63
63
  * rather than a second wording that drifts).
64
64
  *
65
65
  * Exhaustive over {@link OverlayFailureState} — a new state added to the union without a
@@ -103,7 +103,7 @@ export function describeOverlayFailureState(state) {
103
103
  * The delta line is emitted only when something moved: on a steady-state re-init
104
104
  * the counts are noise.
105
105
  *
106
- * Pure function — returns lines, logs nothing (applies ADR-013).
106
+ * Pure function — returns lines, logs nothing.
107
107
  *
108
108
  * @param previous - The provider recorded by the PRIOR manifest, or undefined on
109
109
  * a first install. Rendered only when it differs from `provider`.
@@ -144,7 +144,7 @@ export function formatTrackerAssetSummary(input) {
144
144
  * how the selection was assembled, and a duplicate name in either list is a
145
145
  * manifest detail rather than a different selection.
146
146
  *
147
- * Pure function (applies ADR-013).
147
+ * Pure function.
148
148
  *
149
149
  * @param previousPlugins - `manifest.plugins` as it stands before this run, or
150
150
  * `null` when there is no prior manifest.
@@ -176,7 +176,7 @@ export function isPluginListUnchanged(previousPlugins, effectivePluginNames) {
176
176
  * it is FALSE when there is no prior manifest: a first install removed nothing a
177
177
  * user had, so there is no upgrade to explain (design review L2).
178
178
  *
179
- * Pure function — returns lines, logs nothing (applies ADR-013).
179
+ * Pure function — returns lines, logs nothing.
180
180
  */
181
181
  export function formatSkillScopeSummary(report, pluginListUnchanged) {
182
182
  const lines = [];
@@ -3,25 +3,119 @@ 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 { firstSymbolicLink } from '../../core/linked-path.js';
13
+ import { formatRefusedDrain } from '../../core/queue-drain.js';
14
+ import { formatLearningStoreUnavailable, loadLearningStore, } from '../../core/learning-store.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Shared helpers
17
+ // ---------------------------------------------------------------------------
18
+ /** What the read and restore commands say about a project with no `.devflow/learning/`. */
19
+ const NO_LEARNING_DATA = 'No learning data in this project yet.';
20
+ /**
21
+ * How long a writer here (--restore, --clear, --reset) waits for the learning lock
22
+ * before it refuses as busy. An op holds the lock for milliseconds, so a longer
23
+ * wait means a stuck holder, and the store's own 30 s wait would leave the command
24
+ * looking hung.
25
+ */
26
+ const LOCK_WAIT_MS = 5000;
27
+ /**
28
+ * Code point ranges JSON.stringify leaves raw that a terminal may act on, or may
29
+ * display reordered: DEL and the C1 controls, the directional marks, the line and
30
+ * paragraph separators, and the bidirectional embeddings, overrides and isolates.
31
+ */
32
+ const TERMINAL_UNSAFE_RANGES = [
33
+ [0x7f, 0x9f],
34
+ [0x200e, 0x200f],
35
+ [0x2028, 0x2029],
36
+ [0x202a, 0x202e],
37
+ [0x2066, 0x2069],
38
+ ];
39
+ /**
40
+ * `value` as pretty JSON a terminal shows as written: every character in
41
+ * TERMINAL_UNSAFE_RANGES becomes its `\uXXXX` escape. Such characters can occur
42
+ * only inside JSON strings, where the escape means the same character, so the
43
+ * text still parses back to `value`.
44
+ */
45
+ function terminalSafeJson(value) {
46
+ let text = '';
47
+ for (const ch of JSON.stringify(value, null, 2)) {
48
+ const code = ch.codePointAt(0) ?? 0;
49
+ const unsafe = TERMINAL_UNSAFE_RANGES.some(([low, high]) => code >= low && code <= high);
50
+ text += unsafe ? `\\u${code.toString(16).padStart(4, '0')}` : ch;
51
+ }
52
+ return text;
53
+ }
54
+ /** `1 decision`, `2 decisions`. */
55
+ function counted(count, noun) {
56
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
57
+ }
58
+ /**
59
+ * The learning store (D-LEARNING-STORE-SEAM), or null after reporting why it
60
+ * cannot be used and setting exit code 1.
61
+ */
62
+ function requireStore() {
63
+ const loaded = loadLearningStore();
64
+ if (loaded.ok)
65
+ return loaded.value;
66
+ p.log.error(`Learning: ${formatLearningStoreUnavailable(loaded.error)}`);
67
+ process.exitCode = 1;
68
+ return null;
69
+ }
70
+ /** What a writer says when the store refused; `undone` completes "Nothing was …". */
71
+ function storeRefusal(error, undone) {
72
+ switch (error.kind) {
73
+ case 'no-learning-dir': return NO_LEARNING_DATA;
74
+ case 'busy': return `The learning store is busy: another run holds its lock. Nothing was ${undone}; try again in a moment.`;
75
+ default: return error.message;
76
+ }
77
+ }
78
+ /**
79
+ * Ask `message` on a terminal and say whether to go on, logging `cancelled` when the
80
+ * answer is no. With no terminal there is no one to ask: a script that passed the
81
+ * flag has decided.
82
+ */
83
+ async function confirmOnTerminal(message, cancelled) {
84
+ if (!process.stdin.isTTY)
85
+ return true;
86
+ const confirmed = await p.confirm({ message, initialValue: false });
87
+ if (p.isCancel(confirmed) || !confirmed) {
88
+ p.log.info(cancelled);
89
+ return false;
90
+ }
91
+ return true;
92
+ }
93
+ /** True when `dir` is a directory; false when nothing, or something else, is there. */
94
+ async function isDirectory(dir) {
95
+ try {
96
+ return (await fs.stat(dir)).isDirectory();
97
+ }
98
+ catch (err) {
99
+ const code = err.code;
100
+ if (code === 'ENOENT' || code === 'ENOTDIR')
101
+ return false;
102
+ throw err;
103
+ }
104
+ }
13
105
  // ---------------------------------------------------------------------------
14
106
  // Sub-command handlers
15
107
  // ---------------------------------------------------------------------------
16
108
  function printUsage() {
17
109
  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');
110
+ p.note(`${color.cyan('devflow learning --enable')} Enable learning in every project (a repository can opt out)\n` +
111
+ `${color.cyan('devflow learning --disable')} Disable learning in every project (drains this project's queue)\n` +
112
+ `${color.cyan('devflow learning --status')} Show learning status and entry counts\n` +
113
+ `${color.cyan('devflow learning --list')} List entries, inactive entries and observations\n` +
114
+ `${color.cyan('devflow learning --show <id>')} Print one entry or observation as JSON\n` +
115
+ `${color.cyan('devflow learning --restore <id>')} Make an inactive entry active again\n` +
116
+ `${color.cyan('devflow learning --configure')} Configuration wizard\n` +
117
+ `${color.cyan('devflow learning --clear')} Drop the observations no entry uses, and drain the queue\n` +
118
+ `${color.cyan('devflow learning --reset')} Remove all learning state files`, 'Usage');
25
119
  p.outro(color.dim('Detects architectural decisions and known pitfalls from your sessions'));
26
120
  }
27
121
  /**
@@ -40,10 +134,35 @@ async function requireLedgerRoot(actionSuffix) {
40
134
  }
41
135
  return ledgerRoot;
42
136
  }
137
+ /**
138
+ * The --status entry lines: active entries by type, inactive ones by status (in
139
+ * the store's order, each status present), the active entries still in the v1
140
+ * format — the migration still to do — and the observations.
141
+ */
142
+ function entryCountLines(listing, logRowCount, inactiveStatuses) {
143
+ const active = listing.active;
144
+ const decisions = active.filter(entry => entry.type === 'decision').length;
145
+ const pitfalls = active.filter(entry => entry.type === 'pitfall').length;
146
+ const byStatus = inactiveStatuses
147
+ .map(status => ({ status, count: listing.inactive.filter(entry => entry.status === status).length }))
148
+ .filter(({ count }) => count > 0)
149
+ .map(({ status, count }) => `${status} ${count}`);
150
+ const legacy = active.filter(entry => entry.schema === 1).length;
151
+ return [
152
+ `Entries: ${active.length} active (${counted(decisions, 'decision')}, ${counted(pitfalls, 'pitfall')}), `
153
+ + `${listing.inactive.length} inactive${byStatus.length > 0 ? ` (${byStatus.join(', ')})` : ''}`,
154
+ `Legacy v1 entries: ${legacy} of ${active.length} active`,
155
+ `Observations: ${logRowCount} in the log, ${listing.observations.length} not yet promoted`,
156
+ ];
157
+ }
158
+ /**
159
+ * `--status`: the machine switch, then the counts the store reads. Reads only:
160
+ * malformed lines are counted, never quarantined, and no scope is checked.
161
+ */
43
162
  async function handleStatus() {
44
163
  // D-FEATURES-NARROW-ONLY: the machine switch is the manifest's and reads the
45
164
  // 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.
165
+ // line only when it does. The entry counts are per-project.
47
166
  const enabled = await readMachineFeature(getDevFlowDirectory(), 'learning');
48
167
  const settingsModule = loadSettingsModule();
49
168
  const narrowed = enabled ? narrowedSwitchLabel(settingsModule, { dir: process.cwd() }, 'learning') : null;
@@ -54,69 +173,106 @@ async function handleStatus() {
54
173
  p.log.warn(trackedWarning);
55
174
  const ledgerRoot = await getLedgerRoot();
56
175
  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');
176
+ p.log.info(`${stateLine}\nEntries: not in a git project`);
177
+ return;
72
178
  }
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`);
179
+ const loaded = loadLearningStore();
180
+ if (!loaded.ok) {
181
+ p.log.info(`${stateLine}\nEntries: unavailable (${formatLearningStoreUnavailable(loaded.error)})`);
182
+ return;
183
+ }
184
+ const store = loaded.value;
185
+ const { ledgerRows, logRows, rejected } = store.readLearningState(ledgerRoot);
186
+ const listing = store.buildListing(ledgerRows, logRows, { rejected });
187
+ p.log.info([stateLine, ...entryCountLines(listing, logRows.length, store.INACTIVE_STATUSES)].join('\n'));
188
+ const { ledger, log } = listing.malformed;
189
+ if (ledger + log > 0) {
190
+ p.log.warn(`Malformed lines skipped: ${ledger} in the ledger, ${log} in the log. ` +
191
+ 'The next op that rewrites a file moves its malformed lines to a .rejected.jsonl file beside it.');
77
192
  }
78
- p.log.info(lines.join('\n'));
79
- warnIfInvalid(invalidCount);
80
193
  }
194
+ /**
195
+ * `--list`: the store's listing — the same text json-helper's `list` op prints —
196
+ * on stdout. Read-only. Outside a git project it reads the current directory.
197
+ */
81
198
  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);
199
+ const root = (await getLedgerRoot()) ?? process.cwd();
200
+ const store = requireStore();
201
+ if (!store)
202
+ return;
203
+ const listed = store.readListing(root);
204
+ if (!listed.ok) {
205
+ if (listed.error.kind === 'no-learning-dir') {
206
+ p.log.info(NO_LEARNING_DATA);
207
+ return;
208
+ }
209
+ p.log.error(listed.error.message);
210
+ process.exitCode = 1;
211
+ return;
91
212
  }
92
- catch {
93
- logExists = false;
213
+ process.stdout.write(`${store.formatListing(listed.value)}\n`);
214
+ }
215
+ /**
216
+ * `--show <id>`: one entry, by its anchor or its observation id, as the JSON
217
+ * json-helper's `show` op prints, made terminal-safe. Read-only. Outside a git
218
+ * project it reads the current directory.
219
+ */
220
+ async function handleShow(key) {
221
+ const root = (await getLedgerRoot()) ?? process.cwd();
222
+ const store = requireStore();
223
+ if (!store)
224
+ return;
225
+ const shown = store.showByKey(root, key);
226
+ if (!shown.ok) {
227
+ p.log.error(shown.error.kind === 'no-learning-dir' ? NO_LEARNING_DATA : shown.error.message);
228
+ process.exitCode = 1;
229
+ return;
94
230
  }
95
- if (!logExists) {
96
- p.log.info('No observations yet. Decisions log not found.');
231
+ process.stdout.write(`${terminalSafeJson(shown.value)}\n`);
232
+ }
233
+ /**
234
+ * `--restore <id>`: make an inactive entry active again through the store's
235
+ * restoreAnchor, which clears its notes and its verification so maintenance
236
+ * reviews it again, and re-renders the files. A refusal writes nothing.
237
+ */
238
+ async function handleRestore(anchor) {
239
+ const ledgerRoot = await requireLedgerRoot('restore not performed');
240
+ if (!ledgerRoot) {
241
+ process.exitCode = 1;
97
242
  return;
98
243
  }
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.');
244
+ const store = requireStore();
245
+ if (!store)
246
+ return;
247
+ if (!store.ANCHOR_ID_RE.test(anchor)) {
248
+ p.log.error(`--restore takes an entry id (ADR-NNN or PF-NNN), not ${JSON.stringify(anchor)}`);
249
+ process.exitCode = 1;
103
250
  return;
104
251
  }
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})`);
252
+ const restored = store.restoreAnchor(ledgerRoot, anchor, { timeoutMs: LOCK_WAIT_MS });
253
+ if (!restored.ok) {
254
+ p.log.error(storeRefusal(restored.error, 'restored'));
255
+ process.exitCode = 1;
256
+ return;
116
257
  }
117
- warnIfInvalid(invalidCount);
118
- p.outro(color.dim(`${filtered.length} observation(s) total`));
258
+ p.log.success(`Restored ${restored.value.anchor_id} (${restored.value.status}); it is due for review again.`);
119
259
  }
260
+ /**
261
+ * The scope choices of `devflow learning --configure`.
262
+ *
263
+ * D-LEARNING-MODEL-PRECEDENCE: the session-start hook takes the first layer that
264
+ * supplies a model — the project file, then a `devflow agents` Learning mapping,
265
+ * then the global file. A global file therefore has no effect for a user who has
266
+ * such a mapping, and its hint says so.
267
+ */
268
+ export const CONFIGURE_SCOPE_OPTIONS = [
269
+ { value: 'project', label: 'Project', hint: 'This project only (.devflow/learning/learning.json)' },
270
+ {
271
+ value: 'global',
272
+ label: 'Global',
273
+ hint: 'All projects (~/.devflow/learning.json); a devflow agents Learning mapping takes precedence over it',
274
+ },
275
+ ];
120
276
  async function handleConfigure() {
121
277
  p.intro(color.bgCyan(color.black(' Learning Configuration ')));
122
278
  const model = await p.select({
@@ -141,10 +297,7 @@ async function handleConfigure() {
141
297
  }
142
298
  const scope = await p.select({
143
299
  message: 'Configuration scope',
144
- options: [
145
- { value: 'project', label: 'Project', hint: 'This project only (.devflow/learning/learning.json)' },
146
- { value: 'global', label: 'Global', hint: 'All projects (~/.devflow/learning.json)' },
147
- ],
300
+ options: [...CONFIGURE_SCOPE_OPTIONS],
148
301
  });
149
302
  if (p.isCancel(scope)) {
150
303
  p.cancel('Configuration cancelled.');
@@ -166,91 +319,92 @@ async function handleConfigure() {
166
319
  // from the ledger ($LEDGER_ROOT/.devflow/learning/), so write it there — the
167
320
  // main checkout in a linked worktree; the current directory outside git.
168
321
  const projectRoot = (await getLedgerRoot()) ?? process.cwd();
169
- await fs.mkdir(getLearningDir(projectRoot), { recursive: true });
322
+ const learningDir = getLearningDir(projectRoot);
170
323
  const projectConfigPath = getLearningTuningConfigPath(projectRoot);
324
+ // D-CLI-NO-SYMLINK (core/linked-path.ts): nothing is written through a .devflow,
325
+ // a learning folder or a learning.json that is a symbolic link.
326
+ const linked = await firstSymbolicLink([path.dirname(learningDir), learningDir, projectConfigPath]);
327
+ if (linked !== null) {
328
+ p.log.error(`Project config not written: ${linked} is a symbolic link, and devflow writes nothing through one`);
329
+ process.exitCode = 1;
330
+ return;
331
+ }
332
+ await fs.mkdir(learningDir, { recursive: true });
171
333
  await fs.writeFile(projectConfigPath, configJson, 'utf-8');
172
334
  p.log.success(`Project config written to ${color.dim(projectConfigPath)}`);
173
335
  }
174
336
  p.outro(color.green('Configuration saved.'));
175
337
  }
338
+ /**
339
+ * `--reset`: remove all learning state — entries, observations, rendered files,
340
+ * the tuning config, and the queue with its claim — through the store's
341
+ * resetLearning, under the learning lock every learning writer takes
342
+ * (D-ONE-LEARNING-LOCK, D-RESET-UNDER-LOCK). A project with no learning directory
343
+ * has nothing to reset, and nothing is created there (D-NO-STRAY-TREE).
344
+ */
176
345
  async function handleReset() {
177
346
  const ledgerRoot = await requireLedgerRoot('reset not performed');
178
347
  if (!ledgerRoot)
179
348
  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.');
349
+ const store = requireStore();
350
+ if (!store)
351
+ return;
352
+ if (!(await isDirectory(getLearningDir(ledgerRoot)))) {
353
+ p.log.info('No learning data to reset.');
191
354
  return;
192
355
  }
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);
356
+ const proceed = await confirmOnTerminal('Remove all learning state files? This cannot be undone.', 'Reset cancelled.');
357
+ if (!proceed)
358
+ return;
359
+ const reset = store.resetLearning(ledgerRoot, { timeoutMs: LOCK_WAIT_MS });
360
+ if (!reset.ok) {
361
+ if (reset.error.kind === 'no-learning-dir') {
362
+ p.log.info('No learning data to reset.');
363
+ return;
221
364
  }
222
- catch { /* already cleaned */ }
365
+ p.log.error(storeRefusal(reset.error, 'reset'));
366
+ process.exitCode = 1;
367
+ return;
223
368
  }
369
+ p.log.success('Reset complete — removed .devflow/learning/ state.');
224
370
  }
371
+ /**
372
+ * `--clear`: drop the observations no entry uses (D-CLEAR-UNREFERENCED), then
373
+ * drain the learning queue. The queue is drained only once the clear succeeded:
374
+ * a busy lock or a refusal leaves both the log and the queue as they were, so a
375
+ * failed clear changes nothing.
376
+ */
225
377
  async function handleClear() {
226
378
  const ledgerRoot = await requireLedgerRoot('clear not performed');
227
379
  if (!ledgerRoot)
228
380
  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.');
381
+ const store = requireStore();
382
+ if (!store)
383
+ return;
384
+ if (!(await isDirectory(getLearningDir(ledgerRoot)))) {
385
+ p.log.info('No learning data to clear.');
235
386
  return;
236
387
  }
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.');
388
+ const proceed = await confirmOnTerminal('Drop every observation no entry uses? Entries and the observations they use are kept. This cannot be undone.', 'Clear cancelled.');
389
+ if (!proceed)
390
+ return;
391
+ const cleared = store.clearUnreferenced(ledgerRoot, { timeoutMs: LOCK_WAIT_MS });
392
+ if (!cleared.ok) {
393
+ if (cleared.error.kind === 'no-learning-dir') {
394
+ p.log.info('No learning data to clear.');
244
395
  return;
245
396
  }
397
+ p.log.error(storeRefusal(cleared.error, 'cleared'));
398
+ process.exitCode = 1;
399
+ return;
246
400
  }
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.
252
- await drainLearningQueue(ledgerRoot);
253
- p.log.success('Decisions log cleared.');
401
+ // A mid-run Learning agent whose claimed batch vanishes stops without further
402
+ // writes — the desired outcome of clearing.
403
+ const drain = await drainLearningQueue(ledgerRoot);
404
+ p.log.success(`Cleared ${counted(cleared.value.cleared, 'observation')} no entry uses and kept ${cleared.value.kept} ` +
405
+ `that entries use${drain.drained ? '; drained the learning queue.' : '.'}`);
406
+ if (!drain.drained)
407
+ p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
254
408
  }
255
409
  /**
256
410
  * `--enable` / `--disable`: the machine-wide switch (D-FEATURES-NARROW-ONLY),
@@ -275,7 +429,9 @@ async function handleToggle(enabled) {
275
429
  // batch vanishes aborts without changes — the desired outcome of disabling.
276
430
  const ledgerRoot = await getLedgerRoot();
277
431
  if (ledgerRoot) {
278
- await drainLearningQueue(ledgerRoot);
432
+ const drain = await drainLearningQueue(ledgerRoot);
433
+ if (!drain.drained)
434
+ p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
279
435
  }
280
436
  p.log.success('Learning disabled in every project');
281
437
  }
@@ -283,24 +439,26 @@ export const learningCommand = new Command('learning')
283
439
  .description('Enable or disable learning (decision/pitfall detection) in every project')
284
440
  .option('--enable', 'Enable learning in every project (a repository can opt out)')
285
441
  .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')
442
+ .option('--status', 'Show learning status and entry counts')
443
+ .option('--list', 'List entries, inactive entries with their notes, and observations')
444
+ .option('--show <id>', 'Print one entry (ADR-NNN or PF-NNN) or observation (obs_...) as JSON')
445
+ .option('--restore <id>', 'Make an inactive entry active again')
288
446
  .option('--configure', 'Interactive configuration wizard for learning.json')
289
- .option('--clear', 'Truncate decisions log (removes all observations)')
447
+ .option('--clear', 'Drop the observations no entry uses, and drain the learning queue')
290
448
  .option('--reset', 'Remove all learning state files and artifacts')
291
449
  .action(async (options) => {
292
450
  const knownFlags = [
293
- 'enable', 'disable', 'status', 'list', 'configure',
451
+ 'enable', 'disable', 'status', 'list', 'show', 'restore', 'configure',
294
452
  'clear', 'reset',
295
453
  ];
296
- const hasFlag = knownFlags.some((f) => options[f]);
454
+ const hasFlag = knownFlags.some((f) => options[f] !== undefined);
297
455
  if (!hasFlag) {
298
456
  printUsage();
299
457
  return;
300
458
  }
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.
459
+ // Thin router — the read-only commands first (status, list, show), then
460
+ // configure, reset, clear, restore, enable, disable. Each handler owns its own
461
+ // path resolution and I/O.
304
462
  if (options.status) {
305
463
  await handleStatus();
306
464
  return;
@@ -309,6 +467,10 @@ export const learningCommand = new Command('learning')
309
467
  await handleList();
310
468
  return;
311
469
  }
470
+ if (options.show !== undefined) {
471
+ await handleShow(options.show);
472
+ return;
473
+ }
312
474
  if (options.configure) {
313
475
  await handleConfigure();
314
476
  return;
@@ -321,6 +483,10 @@ export const learningCommand = new Command('learning')
321
483
  await handleClear();
322
484
  return;
323
485
  }
486
+ if (options.restore !== undefined) {
487
+ await handleRestore(options.restore);
488
+ return;
489
+ }
324
490
  if (options.enable) {
325
491
  await handleToggle(true);
326
492
  return;