devflow-kit 3.6.0 → 3.8.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 (197) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +15 -11
  3. package/dist/agents/code.md +9 -15
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +11 -13
  6. package/dist/agents/git.md +1 -1
  7. package/dist/agents/harden.md +1 -1
  8. package/dist/agents/refactor.md +7 -10
  9. package/dist/agents/review.md +6 -4
  10. package/dist/agents/skim.md +2 -2
  11. package/dist/agents/triage.md +2 -2
  12. package/dist/cli/commands/capture.js +1 -1
  13. package/dist/cli/commands/curation-move-io.js +102 -0
  14. package/dist/cli/commands/curation-move-step.js +374 -0
  15. package/dist/cli/commands/{learning.js → curation.js} +302 -99
  16. package/dist/cli/commands/gate-level-prompts.js +426 -0
  17. package/dist/cli/commands/gates.js +299 -0
  18. package/dist/cli/commands/init-seed.js +16 -5
  19. package/dist/cli/commands/init.js +263 -54
  20. package/dist/cli/commands/memory.js +20 -2
  21. package/dist/cli/commands/prompt-io.js +37 -0
  22. package/dist/cli/commands/uninstall.js +81 -9
  23. package/dist/cli.js +4 -2
  24. package/dist/commands/bug-analysis.md +25 -36
  25. package/dist/commands/code-review.md +77 -73
  26. package/dist/commands/debug.md +5 -5
  27. package/dist/commands/dynamic-build.md +76 -83
  28. package/dist/commands/dynamic-plan.md +5 -5
  29. package/dist/commands/dynamic-profile.md +5 -5
  30. package/dist/commands/dynamic-tickets.md +5 -5
  31. package/dist/commands/explore.md +6 -6
  32. package/dist/commands/implement.md +68 -102
  33. package/dist/commands/plan.md +19 -29
  34. package/dist/commands/release.md +5 -5
  35. package/dist/commands/research.md +6 -6
  36. package/dist/commands/resolve.md +29 -31
  37. package/dist/commands/self-review.md +5 -13
  38. package/dist/core/assets.js +20 -20
  39. package/dist/core/curate-tuning-config.js +5 -6
  40. package/dist/core/curation-move-notice.js +84 -0
  41. package/dist/core/curation-move-skip.js +36 -0
  42. package/dist/core/curation-move.js +35 -0
  43. package/dist/core/curation-queue-cleanup.js +134 -0
  44. package/dist/core/curation-store.js +98 -0
  45. package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
  46. package/dist/core/echo-value.js +48 -0
  47. package/dist/core/evidence-policy.js +18 -6
  48. package/dist/core/feature-switch.js +95 -19
  49. package/dist/core/fs-atomic.js +1 -1
  50. package/dist/core/gate-level.js +221 -0
  51. package/dist/core/git.js +16 -14
  52. package/dist/core/ledger-root.js +82 -22
  53. package/dist/core/linked-path.js +4 -4
  54. package/dist/core/manifest-lock.js +238 -0
  55. package/dist/core/manifest.js +84 -24
  56. package/dist/core/migrations.js +1 -13
  57. package/dist/core/model-discovery.js +7 -1
  58. package/dist/core/observations.js +2 -2
  59. package/dist/core/plugins.js +15 -15
  60. package/dist/core/pool.js +26 -0
  61. package/dist/core/project-paths.js +103 -34
  62. package/dist/core/queue-drain.js +2 -2
  63. package/dist/core/tracked-areas.js +35 -0
  64. package/dist/core/tracker.js +6 -34
  65. package/dist/{learning-off → curation-off}/agents/code.md +9 -15
  66. package/dist/{learning-off → curation-off}/agents/fix.md +11 -13
  67. package/dist/{learning-off → curation-off}/agents/refactor.md +7 -10
  68. package/dist/{learning-off → curation-off}/agents/review.md +5 -3
  69. package/dist/{learning-off → curation-off}/agents/triage.md +1 -1
  70. package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
  71. package/dist/{learning-off → curation-off}/commands/code-review.md +74 -70
  72. package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
  73. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +73 -80
  74. package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
  75. package/dist/{learning-off → curation-off}/commands/implement.md +65 -99
  76. package/dist/{learning-off → curation-off}/commands/plan.md +15 -25
  77. package/dist/{learning-off → curation-off}/commands/resolve.md +26 -28
  78. package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
  79. package/dist/hud/components/{learning-counts.js → curation-counts.js} +36 -17
  80. package/dist/hud/config.js +1 -1
  81. package/dist/hud/index.js +6 -6
  82. package/dist/hud/render.js +3 -3
  83. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  84. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +20 -14
  85. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +20 -20
  86. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +20 -20
  87. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +99 -67
  88. package/dist/targets/claude-code/installer.js +15 -15
  89. package/dist/targets/claude-code/language-stamp.js +5 -5
  90. package/dist/targets/claude-code/post-install.js +2 -2
  91. package/dist/targets/claude-code/tracker-servers.js +1 -1
  92. package/package.json +3 -2
  93. package/src/assets/agents/_partials/_code.mds +16 -17
  94. package/src/assets/agents/code.mds +4 -7
  95. package/src/assets/agents/curate.md +12 -14
  96. package/src/assets/agents/debug.mds +6 -6
  97. package/src/assets/agents/design.mds +18 -18
  98. package/src/assets/agents/fix.mds +6 -5
  99. package/src/assets/agents/git.mds +1 -1
  100. package/src/assets/agents/harden.mds +7 -7
  101. package/src/assets/agents/knowledge.mds +23 -23
  102. package/src/assets/agents/refactor.mds +2 -2
  103. package/src/assets/agents/research.mds +15 -15
  104. package/src/assets/agents/review.mds +45 -43
  105. package/src/assets/agents/skim.mds +11 -11
  106. package/src/assets/agents/synthesize.md +2 -2
  107. package/src/assets/agents/triage.mds +22 -22
  108. package/src/assets/agents/validate.md +13 -26
  109. package/src/assets/commands/_partials/_decisions.mds +13 -13
  110. package/src/assets/commands/_partials/_engine.mds +8 -8
  111. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  112. package/src/assets/commands/_partials/_preamble.mds +4 -4
  113. package/src/assets/commands/_partials/_settings.mds +3 -3
  114. package/src/assets/commands/_partials/_wave.mds +17 -24
  115. package/src/assets/commands/bug-analysis.mds +37 -48
  116. package/src/assets/commands/code-review.mds +90 -86
  117. package/src/assets/commands/debug.mds +14 -14
  118. package/src/assets/commands/dynamic-build.mds +78 -78
  119. package/src/assets/commands/dynamic-plan.mds +22 -22
  120. package/src/assets/commands/dynamic-profile.mds +2 -2
  121. package/src/assets/commands/dynamic-tickets.mds +8 -8
  122. package/src/assets/commands/explore.mds +20 -20
  123. package/src/assets/commands/implement.mds +92 -118
  124. package/src/assets/commands/plan.mds +41 -51
  125. package/src/assets/commands/release.mds +12 -12
  126. package/src/assets/commands/research.mds +19 -19
  127. package/src/assets/commands/resolve.mds +56 -50
  128. package/src/assets/commands/self-review.mds +20 -20
  129. package/src/assets/mds/git/_ops.mds +3 -1
  130. package/src/assets/mds/git/_ops_pr.mds +1 -1
  131. package/src/assets/mds/git/_ops_steps.mds +13 -7
  132. package/src/assets/mds/git/_pr.mds +7 -4
  133. package/src/assets/mds/tracker/_common.mds +23 -12
  134. package/src/assets/mds/tracker/_github.mds +2 -9
  135. package/src/assets/mds/tracker/_jira.mds +2 -15
  136. package/src/assets/mds/tracker/_linear.mds +2 -15
  137. package/src/assets/rules/quality.md +1 -0
  138. package/src/assets/scripts/ci-wait.cjs +22 -4
  139. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  140. package/src/assets/scripts/hooks/background-memory-update +406 -97
  141. package/src/assets/scripts/hooks/capture-prompt +12 -12
  142. package/src/assets/scripts/hooks/capture-question +11 -11
  143. package/src/assets/scripts/hooks/capture-turn +12 -12
  144. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  145. package/src/assets/scripts/hooks/curation-move +293 -0
  146. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  147. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  148. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  149. package/src/assets/scripts/hooks/git-marker +81 -0
  150. package/src/assets/scripts/hooks/json-helper.cjs +145 -51
  151. package/src/assets/scripts/hooks/json-parse +19 -4
  152. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  153. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +242 -118
  154. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  155. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  156. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  157. package/src/assets/scripts/hooks/lib/render-decisions.cjs +34 -22
  158. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +259 -0
  159. package/src/assets/scripts/hooks/memory-permission +358 -0
  160. package/src/assets/scripts/hooks/memory-worker +1 -1
  161. package/src/assets/scripts/hooks/pre-compact-memory +6 -16
  162. package/src/assets/scripts/hooks/queue-append +228 -103
  163. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  164. package/src/assets/scripts/hooks/session-start-context +145 -71
  165. package/src/assets/scripts/hooks/session-start-memory +27 -46
  166. package/src/assets/scripts/lib/project-config.cjs +31 -9
  167. package/src/assets/scripts/resolve-base.cjs +1043 -0
  168. package/src/assets/scripts/resolve-settings.cjs +238 -46
  169. package/src/assets/skills/agent-references/SKILL.md +9 -2
  170. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  171. package/src/assets/skills/agent-references/references/commands/implement-levels.md +27 -0
  172. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  173. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  174. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +24 -0
  175. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  176. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  177. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  178. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  179. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  180. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  181. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  182. package/templates/lint-pack/.lint-pack.conf +86 -0
  183. package/templates/lint-pack/README.md +190 -0
  184. package/templates/lint-pack/lint-pack.sh +905 -0
  185. package/dist/core/learning-queue-cleanup.js +0 -27
  186. package/dist/core/learning-store.js +0 -61
  187. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  188. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  189. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  190. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  191. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  192. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  193. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  194. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  195. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  196. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  197. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -1,12 +1,12 @@
1
- // src/assets/scripts/hooks/lib/learning-store.cjs
1
+ // src/assets/scripts/hooks/lib/curation-store.cjs
2
2
  //
3
- // The v2 learning store: the one place the learning plumbing reads, validates,
3
+ // The v2 curation store: the one place the curation plumbing reads, validates,
4
4
  // projects and writes the decisions log, the ledger and their side files.
5
5
  //
6
6
  // DESIGN: plumbing only. No function here prints or calls process.exit. Work that
7
7
  // can fail on its input or on the lock returns a Result — { ok: true, value } or
8
8
  // { ok: false, error: { kind, message } } — and its caller prints it: json-helper.cjs
9
- // for the ops, or the `devflow learning` CLI through src/core/learning-store.ts. A throw
9
+ // for the ops, or the `devflow curation` CLI through src/core/curation-store.ts. A throw
10
10
  // means a broken invariant or an I/O failure, never an expected outcome; a lock
11
11
  // held by withDecisionsLock is released on every path, the throw included.
12
12
  //
@@ -14,39 +14,47 @@
14
14
  // decisions-format.cjs or render-decisions.cjs at load time: both require it, for
15
15
  // the status list and the one-line and inactive-note text they share with `list`.
16
16
  //
17
- // Files under <root>/.devflow/learning/:
17
+ // The one-time move of the old ledger folder to its new name is not here: it lives in
18
+ // curation-move.cjs, which requires this module. The store holds only the guard that
19
+ // keeps its writers off an unfinished move (withDecisionsLock reads the move journal),
20
+ // so a store write loads no move code.
21
+ //
22
+ // Files under <root>/.devflow/curation/:
18
23
  // decisions-log.jsonl observation rows — the content authority
19
24
  // decisions-ledger.jsonl anchored rows — projections of log rows
20
25
  // decisions-log.archive.jsonl rotated-out observation rows (D-ROTATE-UNREFERENCED)
21
26
  // decisions-history.jsonl prior content versions (D-CONTENT-HISTORY)
22
27
  // *.rejected.jsonl quarantined malformed lines (D-QUARANTINE-MALFORMED)
23
28
  // *.pre-v2.jsonl one-time copies of the v1 files (D-V1-BACKUP-ONCE)
24
- // .decisions.lock/ the one learning lock (D-ONE-LEARNING-LOCK)
29
+ // .decisions.lock/ the one curation lock (D-ONE-LEARNING-LOCK)
25
30
  // .pending-turns.jsonl the queue the capture hooks append to
26
31
  // .pending-turns.processing the claimed batch, and .pending-turns.owner its
27
32
  // owner's token (D-OWNED-CLAIM)
28
33
  //
29
34
  // TS COUNTERPARTS: src/core/observations.ts mirrors the status lists (D201), and
30
- // src/core/learning-store.ts transcribes the functions the CLI calls from their
35
+ // src/core/curation-store.ts transcribes the functions the CLI calls from their
31
36
  // JSDoc here (D-LEARNING-STORE-SEAM) — change both together.
32
37
 
33
38
  'use strict';
34
39
 
35
40
  const crypto = require('crypto');
36
41
  const fs = require('fs');
42
+ const os = require('os');
37
43
  const path = require('path');
38
44
  const { execFileSync } = require('child_process');
39
45
 
40
46
  const {
41
- getLearningDir,
42
- getLearningPendingTurnsPath,
43
- getLearningPendingTurnsProcessingPath,
44
- getLearningClaimOwnerPath,
47
+ LEGACY_NAMES,
48
+ getCurationDir,
49
+ getCurationPendingTurnsPath,
50
+ getCurationPendingTurnsProcessingPath,
51
+ getCurationClaimOwnerPath,
45
52
  getDecisionsLedgerPath,
46
53
  getDecisionsLogPath,
47
54
  getDecisionsArchivePath,
48
55
  getDecisionsHistoryPath,
49
56
  getDecisionsLockDir,
57
+ curationMoveJournalPath,
50
58
  } = require('./project-paths.cjs');
51
59
  const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
52
60
  const { safePath } = require('./safe-path.cjs');
@@ -401,17 +409,17 @@ function readTextUnlinked(file, { maxBytes = Infinity } = {}) {
401
409
  * delete them without a trace, and a reader that quarantined would make list,
402
410
  * show and the HUD write files.
403
411
  *
404
- * D-NO-LINKED-READ: a learning file that is itself a symbolic link reads as
412
+ * D-NO-LINKED-READ: a curation file that is itself a symbolic link reads as
405
413
  * missing, and nothing is read through it; the pre-v2 backup skips one the same
406
414
  * way (ensurePreV2Backup), and release-claim reads the claim's owner file the same
407
415
  * way, and only up to CLAIM_OWNER_MAX_BYTES (readClaimOwner). readJsonl and
408
416
  * readClaimOwner read a regular file alone (readTextUnlinked): a directory, a FIFO
409
417
  * or a device where the file belongs reads as missing too, and neither waits on
410
- * one. Reason: a repository can commit any learning file as a link to a file
418
+ * one. Reason: a repository can commit any curation file as a link to a file
411
419
  * elsewhere on the machine, and a read that followed it would put that file's
412
420
  * lines into list and show and, through a rewrite, the quarantine or a render,
413
- * into the project's learning folder; a read that followed one to a FIFO or to
414
- * /dev/zero would wait forever or fill memory, holding the learning lock when a
421
+ * into the project's curation folder; a read that followed one to a FIFO or to
422
+ * /dev/zero would wait forever or fill memory, holding the curation lock when a
415
423
  * writer or release-claim reads. The writers never write through a link either:
416
424
  * a rename replaces one, and an append refuses one.
417
425
  *
@@ -448,11 +456,16 @@ function rejectedPathFor(file) {
448
456
 
449
457
  /**
450
458
  * Append to `file`, creating it when absent. O_NOFOLLOW refuses a symlink planted
451
- * at `file` (ELOOP) instead of writing through it.
459
+ * at `file` (ELOOP) instead of writing through it. A file this call creates gets
460
+ * `mode`, as an explicit open mode (the process umask can only remove bits from it).
461
+ *
462
+ * @param {string} file
463
+ * @param {string} content
464
+ * @param {number} [mode] - the mode of a created file (default 0o666, as the umask leaves it)
452
465
  */
453
- function appendNoFollow(file, content) {
466
+ function appendNoFollow(file, content, mode = 0o666) {
454
467
  const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
455
- const fd = fs.openSync(file, flags, 0o666);
468
+ const fd = fs.openSync(file, flags, mode);
456
469
  try {
457
470
  fs.writeFileSync(fd, content);
458
471
  } finally {
@@ -467,17 +480,18 @@ function appendNoFollow(file, content) {
467
480
  *
468
481
  * @param {string} file - the file the lines were read from
469
482
  * @param {Array<{ line: number, text: string }>} rejected
470
- * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
483
+ * @param {{ now?: number, mode?: number }} [opts] - now: epoch ms (default Date.now());
484
+ * mode: the mode of a quarantine file this call creates (default as the umask leaves it)
471
485
  * @returns {number} the number of records appended
472
486
  */
473
- function quarantineRejected(file, rejected, { now = Date.now() } = {}) {
487
+ function quarantineRejected(file, rejected, { now = Date.now(), mode } = {}) {
474
488
  if (rejected.length === 0) return 0;
475
489
  const rejectedAt = new Date(now).toISOString();
476
490
  const source = path.basename(file);
477
491
  const content = rejected
478
492
  .map(r => JSON.stringify({ rejected_at: rejectedAt, source, line: r.line, text: r.text }))
479
493
  .join('\n') + '\n';
480
- appendNoFollow(rejectedPathFor(file), content);
494
+ appendNoFollow(rejectedPathFor(file), content, mode);
481
495
  return rejected.length;
482
496
  }
483
497
 
@@ -545,14 +559,14 @@ function writeJsonlAtomic(file, rows) {
545
559
  // ---------------------------------------------------------------------------
546
560
 
547
561
  /**
548
- * True when `<root>/.devflow/learning` exists and is a directory.
562
+ * True when `<root>/.devflow/curation` exists and is a directory.
549
563
  *
550
564
  * @param {string} root - project root
551
565
  * @returns {boolean}
552
566
  */
553
- function hasLearningDir(root) {
567
+ function hasCurationDir(root) {
554
568
  try {
555
- return fs.statSync(getLearningDir(root)).isDirectory();
569
+ return fs.statSync(getCurationDir(root)).isDirectory();
556
570
  } catch (err) {
557
571
  if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
558
572
  throw err;
@@ -560,17 +574,17 @@ function hasLearningDir(root) {
560
574
  }
561
575
 
562
576
  /**
563
- * The error Result of an op run where `<root>/.devflow/learning/` is absent
577
+ * The error Result of an op run where `<root>/.devflow/curation/` is absent
564
578
  * (D-NO-STRAY-TREE).
565
579
  *
566
580
  * @param {string} opName - operation name, for the message
567
581
  * @param {string} root - project root
568
- * @returns {{ ok: false, error: { kind: 'no-learning-dir', message: string } }}
582
+ * @returns {{ ok: false, error: { kind: 'no-curation-dir', message: string } }}
569
583
  */
570
- function noLearningDir(opName, root) {
584
+ function noCurationDir(opName, root) {
571
585
  return {
572
586
  ok: false,
573
- error: { kind: 'no-learning-dir', message: `${opName}: no .devflow/learning/ under ${root} — run from the project root` },
587
+ error: { kind: 'no-curation-dir', message: `${opName}: no .devflow/curation/ under ${root} — run from the project root` },
574
588
  };
575
589
  }
576
590
 
@@ -585,22 +599,22 @@ function isSymbolicLink(file) {
585
599
  }
586
600
 
587
601
  /**
588
- * The first of `<root>/.devflow` and `<root>/.devflow/learning` that is itself a
602
+ * The first of `<root>/.devflow` and `<root>/.devflow/curation` that is itself a
589
603
  * symbolic link, or null when neither is (D-NO-LINKED-TREE).
590
604
  *
591
605
  * @param {string} root - project root
592
606
  * @returns {string|null}
593
607
  */
594
- function linkedLearningFolder(root) {
595
- const learningDir = getLearningDir(root);
596
- for (const dir of [path.dirname(learningDir), learningDir]) {
608
+ function linkedCurationFolder(root) {
609
+ const curationDir = getCurationDir(root);
610
+ for (const dir of [path.dirname(curationDir), curationDir]) {
597
611
  if (isSymbolicLink(dir)) return dir;
598
612
  }
599
613
  return null;
600
614
  }
601
615
 
602
616
  /**
603
- * The error Result of an op run where `.devflow` or `.devflow/learning` under its
617
+ * The error Result of an op run where `.devflow` or `.devflow/curation` under its
604
618
  * root is a symbolic link (D-NO-LINKED-TREE).
605
619
  *
606
620
  * @param {string} opName - operation name, for the message
@@ -614,6 +628,82 @@ function linkedFolder(opName, dir) {
614
628
  };
615
629
  }
616
630
 
631
+ /** The Stats of `file` without following a link, or null when nothing is there. */
632
+ function lstatOrNull(file) {
633
+ try {
634
+ return fs.lstatSync(file);
635
+ } catch (err) {
636
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
637
+ throw err;
638
+ }
639
+ }
640
+
641
+ /** The physical path of `target`, or its resolved path when it does not exist yet. */
642
+ function physicalPath(target) {
643
+ try {
644
+ return fs.realpathSync(target);
645
+ } catch (err) {
646
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return path.resolve(target);
647
+ throw err;
648
+ }
649
+ }
650
+
651
+ /** The home directory: `HOME`, else the passwd entry, else null. */
652
+ function homeDirOrNull() {
653
+ if (process.env.HOME) return process.env.HOME;
654
+ try {
655
+ return os.homedir() || null;
656
+ } catch {
657
+ return null;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Where a root's move journal is, resolved the way moveLedger resolves its machine
663
+ * directory (`$HOME/.devflow`) and the root's key (the root's physical path); null when
664
+ * no home directory is known.
665
+ */
666
+ function moveJournalPathOrNull(root) {
667
+ const home = homeDirOrNull();
668
+ if (home === null) return null;
669
+ return curationMoveJournalPath(path.join(home, '.devflow'), physicalPath(path.resolve(root)));
670
+ }
671
+
672
+ /**
673
+ * Remove a machine-side journal whose old folder is gone: it has no set left to
674
+ * finish. Only a regular file is removed, never a link or any other entry, and nothing
675
+ * is followed. It never throws: a journal that cannot be removed (EACCES, EPERM, EROFS
676
+ * on the machine directory) stays, and the writer goes on, because the journal holds
677
+ * no writer once its old folder is gone. A later write tries again.
678
+ *
679
+ * @param {string} journal
680
+ */
681
+ function dropStaleMoveJournal(journal) {
682
+ try {
683
+ const stat = lstatOrNull(journal);
684
+ if (stat === null || !stat.isFile()) return;
685
+ removeIfPresent(journal);
686
+ } catch {
687
+ // Best effort only: see the note above.
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Whether the journal at `journal` holds store writers off: it exists and the old
693
+ * folder it belongs to still exists. A journal whose old folder is gone is stale and is
694
+ * dropped (dropStaleMoveJournal), and the writer goes on.
695
+ *
696
+ * @param {string} journal
697
+ * @param {string} oldFolder
698
+ * @returns {boolean}
699
+ */
700
+ function moveJournalHoldsWriters(journal, oldFolder) {
701
+ if (lstatOrNull(journal) === null) return false;
702
+ if (lstatOrNull(oldFolder) !== null) return true;
703
+ dropStaleMoveJournal(journal);
704
+ return false;
705
+ }
706
+
617
707
  /** True for a Result: `{ ok: true, … }` or `{ ok: false, error: { … } }`. */
618
708
  function isResult(value) {
619
709
  return isPlainObject(value) && (value.ok === true || (value.ok === false && isPlainObject(value.error)));
@@ -624,20 +714,30 @@ function isResult(value) {
624
714
  * The lock is released on every path, a throw from `fn` included; the throw then
625
715
  * reaches the caller.
626
716
  *
627
- * D-ONE-LEARNING-LOCK: learning writers take one lock, `.decisions.lock`, through
717
+ * D-ONE-LEARNING-LOCK: curation writers take one lock, `.decisions.lock`, through
628
718
  * this wrapper — every write to the log, the ledger, their side files and the
629
719
  * rendered files happens under it. Reason: two writers under two locks can each
630
720
  * read one file, change it and rename their copy over it, and the second rename
631
721
  * silently discards the first one's change.
632
722
  *
633
- * D-NO-STRAY-TREE: a learning writer refuses with `no-learning-dir` when
634
- * `.devflow/learning/` is absent under its root, and the store never creates that
723
+ * D-NO-STRAY-TREE: a curation writer refuses with `no-curation-dir` when
724
+ * `.devflow/curation/` is absent under its root, and the store never creates that
635
725
  * directory or its parent — the lock directory is the only thing made inside it.
636
- * Reason: a writer run from the wrong directory would otherwise create a learning
726
+ * Reason: a writer run from the wrong directory would otherwise create a curation
637
727
  * tree there and write a ledger that no session ever reads.
638
728
  *
639
- * D-NO-LINKED-TREE: a learning writer refuses with `not-a-directory`, changing
640
- * nothing, when `.devflow` or `.devflow/learning` under its root is a symbolic
729
+ * An unfinished ledger move (a move journal in the machine directory, next to the move
730
+ * lock, see moveLedger in curation-move.cjs) makes every writer answer `busy` without taking the lock, so no
731
+ * write lands between two renames of the move's ledger set. The journal is checked
732
+ * again once the lock is held: a move can start, and stop part way through the set,
733
+ * while the writer waits for the lock. Only the machine-side file counts, and only
734
+ * while the old folder it belongs to exists (moveJournalHoldsWriters): a file in the
735
+ * repository's old folder is repository content and is never read. With no home
736
+ * directory no move can have started (moveLedger refuses without a machine directory),
737
+ * so there is no journal to find and the writer goes on.
738
+ *
739
+ * D-NO-LINKED-TREE: a curation writer refuses with `not-a-directory`, changing
740
+ * nothing, when `.devflow` or `.devflow/curation` under its root is a symbolic
641
741
  * link. Reason: a repository can commit either one as a link to a folder elsewhere
642
742
  * on the machine, and a writer that followed it would take its lock there and
643
743
  * rewrite, quarantine, archive, render or delete files in whatever folder the link
@@ -649,25 +749,29 @@ function isResult(value) {
649
749
  * @param {() => { ok: boolean }} fn - the locked body; it must return a Result
650
750
  * @param {{ timeoutMs?: number, staleMs?: number }} [opts]
651
751
  * @returns {{ ok: true, value?: unknown } | { ok: false, error: { kind: string, message: string } }}
652
- * fn's Result, or an error of kind `not-a-directory`, `no-learning-dir` or `busy`.
752
+ * fn's Result, or an error of kind `not-a-directory`, `no-curation-dir` or `busy`.
653
753
  */
654
754
  function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
655
- const linked = linkedLearningFolder(root);
755
+ const linked = linkedCurationFolder(root);
656
756
  if (linked !== null) return linkedFolder(opName, linked);
657
- if (!hasLearningDir(root)) return noLearningDir(opName, root);
757
+ const journal = moveJournalPathOrNull(root);
758
+ const oldFolder = path.join(root, '.devflow', LEGACY_NAMES.folder);
759
+ if (journal !== null && moveJournalHoldsWriters(journal, oldFolder)) return unfinishedMove(opName, journal);
760
+ if (!hasCurationDir(root)) return noCurationDir(opName, root);
658
761
  const lockDir = getDecisionsLockDir(root);
659
762
  let acquired;
660
763
  try {
661
764
  acquired = acquireMkdirLock(lockDir, timeoutMs, staleMs);
662
765
  } catch (err) {
663
- // The learning directory went away between the check and the mkdir.
664
- if (err && err.code === 'ENOENT') return noLearningDir(opName, root);
766
+ // The curation directory went away between the check and the mkdir.
767
+ if (err && err.code === 'ENOENT') return noCurationDir(opName, root);
665
768
  throw err;
666
769
  }
667
770
  if (!acquired) {
668
771
  return { ok: false, error: { kind: 'busy', message: `${opName}: timeout acquiring lock at ${lockDir}` } };
669
772
  }
670
773
  try {
774
+ if (journal !== null && moveJournalHoldsWriters(journal, oldFolder)) return unfinishedMove(opName, journal);
671
775
  const result = fn();
672
776
  if (!isResult(result)) {
673
777
  throw new TypeError(`${opName}: the locked body must return a Result ({ ok: true, value } or { ok: false, error })`);
@@ -678,6 +782,14 @@ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_
678
782
  }
679
783
  }
680
784
 
785
+ /** The `busy` Result of a writer that finds an unfinished ledger move's journal. */
786
+ function unfinishedMove(opName, journal) {
787
+ return {
788
+ ok: false,
789
+ error: { kind: 'busy', message: `${opName}: an unfinished ledger move is recorded at ${journal}; it finishes at the next session start or devflow init` },
790
+ };
791
+ }
792
+
681
793
  // ---------------------------------------------------------------------------
682
794
  // State and the ledger registry
683
795
  // ---------------------------------------------------------------------------
@@ -690,7 +802,7 @@ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_
690
802
  * @param {string} root - project root
691
803
  * @returns {{ ledgerRows: object[], logRows: object[], rejected: { ledger: Array<{ line: number, text: string }>, log: Array<{ line: number, text: string }> } }}
692
804
  */
693
- function readLearningState(root) {
805
+ function readCurationState(root) {
694
806
  const ledger = readJsonl(getDecisionsLedgerPath(root));
695
807
  const log = readJsonl(getDecisionsLogPath(root));
696
808
  return {
@@ -1191,7 +1303,7 @@ function ensurePreV2Backup(root, { logRows = [], ledgerRows = [] } = {}) {
1191
1303
  /** Days of inactivity after which an observation no ledger row carries leaves the log (D-ROTATE-UNREFERENCED). */
1192
1304
  const ROTATE_AGE_DAYS = 30;
1193
1305
 
1194
- /** What an older install's usage telemetry left in the learning directory: a file and a lock directory. */
1306
+ /** What an older install's usage telemetry left in the curation directory: a file and a lock directory. */
1195
1307
  const USAGE_LEFTOVERS = Object.freeze(['.decisions-usage.json', '.decisions-usage.lock']);
1196
1308
 
1197
1309
  /**
@@ -1221,7 +1333,7 @@ function lastActivityMs(row) {
1221
1333
  * anchor_id copied onto a log row can say what to keep, and a dedup by id dropped
1222
1334
  * the newer version of a row whose older version was already archived.
1223
1335
  *
1224
- * Under the learning lock it first deletes the usage leftovers. When no row is
1336
+ * Under the curation lock it first deletes the usage leftovers. When no row is
1225
1337
  * due it writes nothing else. When rows are due it backs up a v1 tree
1226
1338
  * (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
1227
1339
  * (D-QUARANTINE-MALFORMED) before it appends to the archive and rewrites the log;
@@ -1232,12 +1344,12 @@ function lastActivityMs(row) {
1232
1344
  * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1233
1345
  * @returns {{ ok: true, value: { rotated: number, appended: number } } | { ok: false, error: { kind: string, message: string } }}
1234
1346
  * rotated: rows removed from the log; appended: rows added to the archive.
1235
- * Errors are withDecisionsLock's not-a-directory, no-learning-dir and busy.
1347
+ * Errors are withDecisionsLock's not-a-directory, no-curation-dir and busy.
1236
1348
  */
1237
1349
  function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1238
1350
  return withDecisionsLock('rotate-observations', root, () => {
1239
1351
  for (const name of USAGE_LEFTOVERS) {
1240
- fs.rmSync(path.join(getLearningDir(root), name), { recursive: true, force: true });
1352
+ fs.rmSync(path.join(getCurationDir(root), name), { recursive: true, force: true });
1241
1353
  }
1242
1354
 
1243
1355
  const logPath = getDecisionsLogPath(root);
@@ -1276,7 +1388,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1276
1388
  // ---------------------------------------------------------------------------
1277
1389
 
1278
1390
  /**
1279
- * Drop the observations no entry uses — `devflow learning --clear`.
1391
+ * Drop the observations no entry uses — `devflow curation --clear`.
1280
1392
  *
1281
1393
  * D-CLEAR-UNREFERENCED: clearing removes from the log exactly the rows no ledger
1282
1394
  * row carries (D-LEDGER-REGISTRY), whatever their age or status, and keeps every
@@ -1286,7 +1398,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1286
1398
  * row that is its content authority, and refresh then refused them all — and a
1287
1399
  * malformed ledger line may be the one carrying a row clearing would drop.
1288
1400
  *
1289
- * It runs under the learning lock (D-ONE-LEARNING-LOCK). When a row is dropped it
1401
+ * It runs under the curation lock (D-ONE-LEARNING-LOCK). When a row is dropped it
1290
1402
  * backs up a v1 tree (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
1291
1403
  * (D-QUARANTINE-MALFORMED) before it rewrites the log; with nothing to drop it
1292
1404
  * writes nothing.
@@ -1295,7 +1407,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1295
1407
  * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1296
1408
  * @returns {{ ok: true, value: { cleared: number, kept: number } } | { ok: false, error: { kind: string, message: string } }}
1297
1409
  * cleared: rows removed from the log; kept: rows left in it. Error kinds:
1298
- * ledger-malformed, and withDecisionsLock's not-a-directory, no-learning-dir and busy.
1410
+ * ledger-malformed, and withDecisionsLock's not-a-directory, no-curation-dir and busy.
1299
1411
  */
1300
1412
  function clearUnreferenced(root, { now = Date.now(), timeoutMs } = {}) {
1301
1413
  return withDecisionsLock('clear', root, () => {
@@ -1340,11 +1452,11 @@ function removeEmptyDir(dir) {
1340
1452
  }
1341
1453
 
1342
1454
  /**
1343
- * Remove every learning file — `devflow learning --reset`: the log, the ledger
1455
+ * Remove every curation file — `devflow curation --reset`: the log, the ledger
1344
1456
  * and their side files, the rendered files, the tuning config, and the queue
1345
- * with its claim and owner file — and then the learning directory itself.
1457
+ * with its claim and owner file — and then the curation directory itself.
1346
1458
  *
1347
- * D-RESET-UNDER-LOCK: reset empties the learning directory under the learning
1459
+ * D-RESET-UNDER-LOCK: reset empties the curation directory under the curation
1348
1460
  * lock, sparing only the lock directory, and removes the emptied directory once
1349
1461
  * the lock is released, and only if nothing has arrived in it. Reason: the lock
1350
1462
  * is released by its path, so removing it with the directory would let this
@@ -1352,8 +1464,8 @@ function removeEmptyDir(dir) {
1352
1464
  * and what arrives once the lock is free — a captured turn, or the next writer's
1353
1465
  * lock — belongs to the next run.
1354
1466
  *
1355
- * Like every learning writer it refuses without `.devflow/learning/` and creates
1356
- * nothing (D-NO-STRAY-TREE), refuses a learning directory or a `.devflow` that is a
1467
+ * Like every curation writer it refuses without `.devflow/curation/` and creates
1468
+ * nothing (D-NO-STRAY-TREE), refuses a curation directory or a `.devflow` that is a
1357
1469
  * symbolic link and removes nothing (D-NO-LINKED-TREE), since emptying it would
1358
1470
  * empty whatever directory the link leads to, and waits at most `timeoutMs` for the
1359
1471
  * lock, breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic
@@ -1362,18 +1474,18 @@ function removeEmptyDir(dir) {
1362
1474
  * @param {string} root - project root
1363
1475
  * @param {{ timeoutMs?: number }} [opts]
1364
1476
  * @returns {{ ok: true, value: { removed: number } } | { ok: false, error: { kind: string, message: string } }}
1365
- * removed: the entries removed from the learning directory. Errors are
1366
- * withDecisionsLock's not-a-directory, no-learning-dir and busy.
1477
+ * removed: the entries removed from the curation directory. Errors are
1478
+ * withDecisionsLock's not-a-directory, no-curation-dir and busy.
1367
1479
  */
1368
- function resetLearning(root, { timeoutMs } = {}) {
1369
- const learningDir = getLearningDir(root);
1480
+ function resetCuration(root, { timeoutMs } = {}) {
1481
+ const curationDir = getCurationDir(root);
1370
1482
  const lockName = path.basename(getDecisionsLockDir(root));
1371
1483
  const reset = withDecisionsLock('reset', root, () => {
1372
- const entries = fs.readdirSync(learningDir).filter(name => name !== lockName);
1373
- for (const name of entries) fs.rmSync(path.join(learningDir, name), { recursive: true, force: true });
1484
+ const entries = fs.readdirSync(curationDir).filter(name => name !== lockName);
1485
+ for (const name of entries) fs.rmSync(path.join(curationDir, name), { recursive: true, force: true });
1374
1486
  return { ok: true, value: { removed: entries.length } };
1375
1487
  }, { timeoutMs });
1376
- if (reset.ok) removeEmptyDir(learningDir);
1488
+ if (reset.ok) removeEmptyDir(curationDir);
1377
1489
  return reset;
1378
1490
  }
1379
1491
 
@@ -1391,7 +1503,7 @@ const CLAIM_STALE_SECS = 900;
1391
1503
  /** How long a claim waits for the queue's own lock (ms): the wait of queue-append's overflow truncation. */
1392
1504
  const QUEUE_LOCK_TIMEOUT_MS = 2000;
1393
1505
 
1394
- /** Age after which the queue's own lock counts as abandoned (ms): learning-lock's threshold. */
1506
+ /** Age after which the queue's own lock counts as abandoned (ms): curation-lock's threshold. */
1395
1507
  const QUEUE_LOCK_STALE_MS = 30000;
1396
1508
 
1397
1509
  /** A claim token: 16 lowercase hex characters. */
@@ -1462,7 +1574,7 @@ function removeIfPresent(file) {
1462
1574
  * larger than CLAIM_OWNER_MAX_BYTES reads as absent (D-NO-LINKED-READ, at readJsonl).
1463
1575
  */
1464
1576
  function readClaimOwner(root) {
1465
- const text = readTextUnlinked(getLearningClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
1577
+ const text = readTextUnlinked(getCurationClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
1466
1578
  if (text === null) return null;
1467
1579
  const token = text.trim();
1468
1580
  return CLAIM_TOKEN_RE.test(token) ? token : null;
@@ -1473,7 +1585,7 @@ function readClaimOwner(root) {
1473
1585
  * queue-append's overflow truncation takes, so a truncation can never rewrite the
1474
1586
  * queue from rows already claimed. link(2) refuses an existing claim; on a
1475
1587
  * filesystem without hard links a rename stands in, which is safe because the
1476
- * caller found the claim path empty under the learning lock. A row a capture hook
1588
+ * caller found the claim path empty under the curation lock. A row a capture hook
1477
1589
  * appends meanwhile lands in the claimed batch or in the queue formed after it,
1478
1590
  * never in neither.
1479
1591
  *
@@ -1516,22 +1628,22 @@ function moveQueueToClaim(queuePath, claimPath) {
1516
1628
  }
1517
1629
 
1518
1630
  /**
1519
- * Claim the learning queue for one Curate run.
1631
+ * Claim the curation queue for one Curate run.
1520
1632
  *
1521
- * D-OWNED-CLAIM: the learning queue is claimed and released only through the
1522
- * claim-queue and release-claim ops, under the learning lock. A claim moves the
1633
+ * D-OWNED-CLAIM: the curation queue is claimed and released only through the
1634
+ * claim-queue and release-claim ops, under the curation lock. A claim moves the
1523
1635
  * queue to .pending-turns.processing by link(2), under the queue's own lock (a
1524
1636
  * rename stands in only where the filesystem has no hard links), sets the claim's
1525
1637
  * mtime to now and records a fresh random token in .pending-turns.owner. A claim
1526
1638
  * younger than CLAIM_STALE_SECS is busy to every other claimant; an older one is
1527
1639
  * taken over with a new token, and the waiting queue is left for the next claim.
1528
1640
  * Release deletes the claim only for the token that owns it, and every json-helper
1529
- * learning op refreshes an existing claim's mtime before it runs, without ever
1641
+ * curation op refreshes an existing claim's mtime before it runs, without ever
1530
1642
  * creating one. Reason: a check-then-mv claim let two runs claim at once and
1531
1643
  * clobber a batch, mv kept the queue's old mtime so a fresh claim could look stale
1532
1644
  * at once, and an unconditional final unlink deleted another run's claim.
1533
1645
  *
1534
- * Without .devflow/learning/ it answers none and creates nothing.
1646
+ * Without .devflow/curation/ it answers none and creates nothing.
1535
1647
  *
1536
1648
  * @param {string} root - project root
1537
1649
  * @param {{ now?: number, token?: string, timeoutMs?: number }} [opts]
@@ -1542,17 +1654,17 @@ function moveQueueToClaim(queuePath, claimPath) {
1542
1654
  */
1543
1655
  function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs } = {}) {
1544
1656
  assertClaimToken('claimQueue', token);
1545
- if (!hasLearningDir(root)) return { ok: true, value: { state: 'none' } };
1657
+ if (!hasCurationDir(root)) return { ok: true, value: { state: 'none' } };
1546
1658
  return withDecisionsLock('claim-queue', root, () => {
1547
- const claimPath = getLearningPendingTurnsProcessingPath(root);
1548
- const queuePath = getLearningPendingTurnsPath(root);
1659
+ const claimPath = getCurationPendingTurnsProcessingPath(root);
1660
+ const queuePath = getCurationPendingTurnsPath(root);
1549
1661
  const at = new Date(now);
1550
1662
 
1551
1663
  const claim = regularFileStat(claimPath);
1552
1664
  if (claim === false) return notRegularFile('claim-queue', claimPath);
1553
1665
  if (claim !== null) {
1554
1666
  if (now - claim.mtimeMs < CLAIM_STALE_SECS * 1000) return { ok: true, value: { state: 'busy' } };
1555
- writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
1667
+ writeFileAtomic(getCurationClaimOwnerPath(root), `${token}\n`);
1556
1668
  fs.utimesSync(claimPath, at, at);
1557
1669
  return { ok: true, value: { state: 'claimed', token, takeover: true } };
1558
1670
  }
@@ -1564,7 +1676,7 @@ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs
1564
1676
  const moved = moveQueueToClaim(queuePath, claimPath);
1565
1677
  if (moved !== 'moved') return { ok: true, value: { state: moved } };
1566
1678
  fs.utimesSync(claimPath, at, at);
1567
- writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
1679
+ writeFileAtomic(getCurationClaimOwnerPath(root), `${token}\n`);
1568
1680
  return { ok: true, value: { state: 'claimed', token, takeover: false } };
1569
1681
  }, { timeoutMs });
1570
1682
  }
@@ -1581,14 +1693,14 @@ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs
1581
1693
  * @param {string} token
1582
1694
  * @param {{ timeoutMs?: number }} [opts]
1583
1695
  * @returns {{ ok: true, value: { state: 'released'|'not-owner'|'gone' } } | { ok: false, error: { kind: string, message: string } }}
1584
- * errors include withDecisionsLock's not-a-directory, no-learning-dir and busy
1696
+ * errors include withDecisionsLock's not-a-directory, no-curation-dir and busy
1585
1697
  * @throws {TypeError} when `token` is not a claim token
1586
1698
  */
1587
1699
  function releaseClaim(root, token, { timeoutMs } = {}) {
1588
1700
  assertClaimToken('releaseClaim', token);
1589
1701
  return withDecisionsLock('release-claim', root, () => {
1590
- const claimPath = getLearningPendingTurnsProcessingPath(root);
1591
- const ownerPath = getLearningClaimOwnerPath(root);
1702
+ const claimPath = getCurationPendingTurnsProcessingPath(root);
1703
+ const ownerPath = getCurationClaimOwnerPath(root);
1592
1704
  const owned = readClaimOwner(root) === token;
1593
1705
 
1594
1706
  const claim = regularFileStat(claimPath);
@@ -1607,8 +1719,8 @@ function releaseClaim(root, token, { timeoutMs } = {}) {
1607
1719
  /**
1608
1720
  * The claim heartbeat (D-OWNED-CLAIM): set an existing claim's mtime to now. It
1609
1721
  * never creates a claim and never follows a symlink at the claim path, and it
1610
- * takes no lock — json-helper sends it before each learning op runs. A claim in a
1611
- * learning tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
1722
+ * takes no lock — json-helper sends it before each curation op runs. A claim in a
1723
+ * curation tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
1612
1724
  * `lutimes` follows a linked folder above the claim, and the op that follows
1613
1725
  * refuses that tree anyway.
1614
1726
  *
@@ -1617,8 +1729,8 @@ function releaseClaim(root, token, { timeoutMs } = {}) {
1617
1729
  * @returns {{ ok: true, value: { touched: boolean } } | { ok: false, error: { kind: 'heartbeat-failed', message: string } }}
1618
1730
  */
1619
1731
  function touchClaim(root, { now = Date.now() } = {}) {
1620
- if (linkedLearningFolder(root) !== null) return { ok: true, value: { touched: false } };
1621
- const claimPath = getLearningPendingTurnsProcessingPath(root);
1732
+ if (linkedCurationFolder(root) !== null) return { ok: true, value: { touched: false } };
1733
+ const claimPath = getCurationPendingTurnsProcessingPath(root);
1622
1734
  const at = new Date(now);
1623
1735
  try {
1624
1736
  fs.lutimesSync(claimPath, at, at);
@@ -1950,16 +2062,17 @@ function resolveVerifyRef(root) {
1950
2062
  /**
1951
2063
  * Render decisions.md, pitfalls.md and index.md from `ledgerRows` and write each
1952
2064
  * atomically, the index last. The caller holds .decisions.lock
1953
- * (D-ONE-LEARNING-LOCK), so the learning directory exists. render-decisions.cjs
2065
+ * (D-ONE-LEARNING-LOCK), so the curation directory exists. render-decisions.cjs
1954
2066
  * requires this module at load time, so it is required here, on first use. Prints
1955
2067
  * nothing.
1956
2068
  *
1957
2069
  * @param {string} root - project root
1958
2070
  * @param {object[]} ledgerRows - every ledger row
2071
+ * @param {string} [outputDir] - the folder the files belong in (default: the curation folder)
1959
2072
  */
1960
- function renderAll(root, ledgerRows) {
1961
- const { renderLearningFiles } = require('./render-decisions.cjs');
1962
- for (const file of renderLearningFiles(root, ledgerRows)) writeFileAtomic(file.path, file.content);
2073
+ function renderAll(root, ledgerRows, outputDir = getCurationDir(root)) {
2074
+ const { renderCurationFiles } = require('./render-decisions.cjs');
2075
+ for (const file of renderCurationFiles(root, ledgerRows, outputDir)) writeFileAtomic(file.path, file.content);
1963
2076
  }
1964
2077
 
1965
2078
  // ---------------------------------------------------------------------------
@@ -2118,7 +2231,7 @@ function restoreFirst(id, carriers) {
2118
2231
  * observations: the count the log row holds afterwards; reprojected: the
2119
2232
  * anchors re-projected, in anchor order. Error kinds: invalid-input (with
2120
2233
  * problems), duplicate-log-id, restore-first, cannot-reproject, and
2121
- * withDecisionsLock's not-a-directory, no-learning-dir and busy.
2234
+ * withDecisionsLock's not-a-directory, no-curation-dir and busy.
2122
2235
  * @throws {TypeError} when `mode` is not a put mode
2123
2236
  */
2124
2237
  function putObservation(root, mode, input, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
@@ -2190,16 +2303,16 @@ function putUnderLock(root, mode, input, { now, scopeMatches }) {
2190
2303
  * The data behind `list`, read-only: buildListing over the ledger and the log as
2191
2304
  * they are, every glob scope checked against the files git tracks. Malformed
2192
2305
  * lines are counted, never quarantined (D-QUARANTINE-MALFORMED). Refuses without
2193
- * .devflow/learning/ (D-NO-STRAY-TREE).
2306
+ * .devflow/curation/ (D-NO-STRAY-TREE).
2194
2307
  *
2195
2308
  * @param {string} root - project root
2196
2309
  * @param {{ scopeMatches?: (glob: string) => boolean }} [opts] - default gitScopeMatcher(root)
2197
2310
  * @returns {{ ok: true, value: { active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } } }
2198
- * | { ok: false, error: { kind: 'no-learning-dir', message: string } }}
2311
+ * | { ok: false, error: { kind: 'no-curation-dir', message: string } }}
2199
2312
  */
2200
2313
  function readListing(root, { scopeMatches } = {}) {
2201
- if (!hasLearningDir(root)) return noLearningDir('list', root);
2202
- const { ledgerRows, logRows, rejected } = readLearningState(root);
2314
+ if (!hasCurationDir(root)) return noCurationDir('list', root);
2315
+ const { ledgerRows, logRows, rejected } = readCurationState(root);
2203
2316
  return {
2204
2317
  ok: true,
2205
2318
  value: buildListing(ledgerRows, logRows, { scopeMatches: scopeMatches || gitScopeMatcher(root), rejected }),
@@ -2280,16 +2393,16 @@ function formatListing(listing) {
2280
2393
  * the log and the history as they are. When lines were skipped as malformed
2281
2394
  * (D-QUARANTINE-MALFORMED), a found entry gains `malformed: { ledger, log }` and
2282
2395
  * a not-found message says a skipped line may hold it. Refuses without
2283
- * .devflow/learning/ (D-NO-STRAY-TREE).
2396
+ * .devflow/curation/ (D-NO-STRAY-TREE).
2284
2397
  *
2285
2398
  * @param {string} root - project root
2286
2399
  * @param {string} key - an anchor id or an observation id
2287
2400
  * @returns {{ ok: true, value: { key: string, ledger: object[], log: object|null, history_versions: object[], flags: object[], malformed?: { ledger: number, log: number } } }
2288
- * | { ok: false, error: { kind: 'no-learning-dir'|'invalid-key'|'not-found', message: string } }}
2401
+ * | { ok: false, error: { kind: 'no-curation-dir'|'invalid-key'|'not-found', message: string } }}
2289
2402
  */
2290
2403
  function showByKey(root, key) {
2291
- if (!hasLearningDir(root)) return noLearningDir('show', root);
2292
- const { ledgerRows, logRows, rejected } = readLearningState(root);
2404
+ if (!hasCurationDir(root)) return noCurationDir('show', root);
2405
+ const { ledgerRows, logRows, rejected } = readCurationState(root);
2293
2406
  const shown = showEntry(key, ledgerRows, logRows, { historyVersions: id => historyVersions(root, id) });
2294
2407
  const malformed = { ledger: rejected.ledger.length, log: rejected.log.length };
2295
2408
  const skipped = malformed.ledger + malformed.log;
@@ -2320,7 +2433,7 @@ function warmScopeMatcher(ledgerRows, scopeMatches) {
2320
2433
  /**
2321
2434
  * Hand out the entries maintenance works on next, and lease them.
2322
2435
  *
2323
- * Under the learning lock it flags integrity problems and selects the due
2436
+ * Under the curation lock it flags integrity problems and selects the due
2324
2437
  * entries (D-DUE-ORDER), then stamps each entry it hands out with
2325
2438
  * `last_attempt` = now: claim-due is the one writer of the field the lease
2326
2439
  * reads. A hand-out backs up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines
@@ -2336,10 +2449,10 @@ function warmScopeMatcher(ledgerRows, scopeMatches) {
2336
2449
  * @returns {{ ok: true, value: { ref: { ref: 'origin/HEAD'|'HEAD', commit: string } | null, due: Array<{ anchor_id: string, reason: string, bytes: number }> } }
2337
2450
  * | { ok: false, error: { kind: string, message: string } }}
2338
2451
  * due is selectDue's answer; errors are withDecisionsLock's not-a-directory,
2339
- * no-learning-dir and busy
2452
+ * no-curation-dir and busy
2340
2453
  */
2341
2454
  function claimDue(root, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
2342
- if (!hasLearningDir(root)) return noLearningDir('claim-due', root);
2455
+ if (!hasCurationDir(root)) return noCurationDir('claim-due', root);
2343
2456
  const matches = scopeMatches || gitScopeMatcher(root);
2344
2457
  const ref = resolveVerifyRef(root);
2345
2458
  warmScopeMatcher(readJsonl(getDecisionsLedgerPath(root)).rows, matches);
@@ -2413,7 +2526,7 @@ function nextAnchorFromLedger(ledgerRows, type) {
2413
2526
 
2414
2527
  /**
2415
2528
  * True when a project-relative path is one the cited-number scan never reads: the
2416
- * learning tree, where every entry cites itself, or anything under an excluded
2529
+ * curation tree, where every entry cites itself, or anything under an excluded
2417
2530
  * directory segment.
2418
2531
  *
2419
2532
  * @param {string} relPath - relative to the project root, either separator style
@@ -2421,7 +2534,7 @@ function nextAnchorFromLedger(ledgerRows, type) {
2421
2534
  */
2422
2535
  function isCitedScanExcluded(relPath) {
2423
2536
  const norm = relPath.split(path.sep).join('/');
2424
- if (norm === '.devflow/learning' || norm.startsWith('.devflow/learning/')) return true;
2537
+ if (norm === '.devflow/curation' || norm.startsWith('.devflow/curation/')) return true;
2425
2538
  return norm.split('/').some(segment => CITED_SCAN_EXCLUDED_SEGMENTS.includes(segment));
2426
2539
  }
2427
2540
 
@@ -2432,7 +2545,7 @@ function isCitedScanExcluded(relPath) {
2432
2545
  * command a repository's config names in `core.fsmonitor` — code chosen by the
2433
2546
  * repository this hook runs inside. The call turns it off for itself
2434
2547
  * (`-c core.fsmonitor=false`), so the listing stays a pure read. Reason: the
2435
- * learning ops run inside any repository a session opens, and a read that ran
2548
+ * curation ops run inside any repository a session opens, and a read that ran
2436
2549
  * the repository's command would execute it with the user's privileges.
2437
2550
  *
2438
2551
  * @param {string} root - project root
@@ -2512,7 +2625,7 @@ function readScannedText(file) {
2512
2625
  * Every anchor the project's files cite as a whole word, each mapped to its first
2513
2626
  * citation: the scan behind D-E4-SKIP. It reads the files git tracks under `root`
2514
2627
  * (D-NO-FSMONITOR) or, outside a git working tree, the files a directory walk
2515
- * finds — never the learning tree, an excluded directory, a symbolic link, a
2628
+ * finds — never the curation tree, an excluded directory, a symbolic link, a
2516
2629
  * binary file or a file over the size cap. A file it cannot read is passed over.
2517
2630
  * It writes nothing and takes no lock.
2518
2631
  *
@@ -2584,10 +2697,10 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
2584
2697
  * Promote a v2 observation to a new ledger entry of `type` — the assign-anchor op.
2585
2698
  *
2586
2699
  * D-E4-SKIP: assign-anchor scans the project's tracked files once, before it takes
2587
- * the learning lock, for every anchor they cite as a whole word, and mints the
2700
+ * the curation lock, for every anchor they cite as a whole word, and mints the
2588
2701
  * first number past the type's highest anchored number that no file cites; it
2589
2702
  * reports each number it skips, skips at most E4_MAX_SKIPS of them and refuses
2590
- * when the next one is cited too. The scan reads neither the learning tree nor
2703
+ * when the next one is cited too. The scan reads neither the curation tree nor
2591
2704
  * .git or a vendored or build directory (node_modules, target, dist), nor a
2592
2705
  * symbolic link, a binary file or a file over 5 MB. Reason: a document can cite a
2593
2706
  * number the ledger has not minted yet, and minting over it silently binds that
@@ -2598,7 +2711,7 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
2598
2711
  * whatever its status (D-LEDGER-REGISTRY). The new row is its projection
2599
2712
  * (toLedgerRowV2) with the type's active status and today as both date and
2600
2713
  * last_verified; it is appended to the ledger and the files are re-rendered, all
2601
- * under the learning lock. The log is never written. Like every writer it backs
2714
+ * under the curation lock. The log is never written. Like every writer it backs
2602
2715
  * up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines the ledger's malformed
2603
2716
  * lines before it rewrites the ledger (D-QUARANTINE-MALFORMED); a refusal writes
2604
2717
  * nothing.
@@ -2612,13 +2725,13 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
2612
2725
  * | { ok: false, error: { kind: string, message: string } }}
2613
2726
  * Error kinds: not-in-log, duplicate-log-id, already-promoted, v1-observation,
2614
2727
  * type-mismatch, cited-numbers-exhausted, and withDecisionsLock's
2615
- * not-a-directory, no-learning-dir and busy.
2728
+ * not-a-directory, no-curation-dir and busy.
2616
2729
  * @throws {TypeError} for a type other than decision or pitfall, or a malformed obsId
2617
2730
  */
2618
2731
  function assignAnchor(root, type, obsId, { now = Date.now(), timeoutMs, citedAnchors } = {}) {
2619
2732
  if (anchorPrefixFor(type) === null) throw new TypeError(`assignAnchor: type must be 'decision' or 'pitfall', got '${type}'`);
2620
2733
  if (typeof obsId !== 'string' || !OBS_ID_RE.test(obsId)) throw new TypeError('assignAnchor: obsId must be an observation id');
2621
- if (!hasLearningDir(root)) return noLearningDir('assign-anchor', root);
2734
+ if (!hasCurationDir(root)) return noCurationDir('assign-anchor', root);
2622
2735
  const cited = citedAnchors || collectCitedAnchorIds(root);
2623
2736
  return withDecisionsLock('assign-anchor', root, () => assignUnderLock(root, type, obsId, { now, cited }), { timeoutMs });
2624
2737
  }
@@ -2760,7 +2873,7 @@ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
2760
2873
  * an inactive entry or a v1 one, and — when re-projecting — an entry with no
2761
2874
  * observation id or whose log row is missing, doubled, v1 or of a type it cannot
2762
2875
  * take refuses the whole batch, every refused anchor listed, nothing written. A
2763
- * batch that changes no row writes nothing. Otherwise, under the learning lock,
2876
+ * batch that changes no row writes nothing. Otherwise, under the curation lock,
2764
2877
  * it backs up a v1 tree (D-V1-BACKUP-ONCE), quarantines the ledger's malformed
2765
2878
  * lines (D-QUARANTINE-MALFORMED), then writes the ledger once and renders once.
2766
2879
  *
@@ -2771,7 +2884,7 @@ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
2771
2884
  * @returns {{ ok: true, value: { refreshed: Array<{ anchor_id: string, state: 'verified'|'reprojected'|'unchanged' }> } }
2772
2885
  * | { ok: false, error: { kind: string, message: string, problems?: Array<{ anchor_id: string, message: string }> } }}
2773
2886
  * refreshed: each anchor once, in the order given. Error kinds: refused (with
2774
- * problems), and withDecisionsLock's not-a-directory, no-learning-dir and busy.
2887
+ * problems), and withDecisionsLock's not-a-directory, no-curation-dir and busy.
2775
2888
  * @throws {TypeError} when anchorIds is empty or holds anything but anchor ids
2776
2889
  */
2777
2890
  function refreshAnchors(root, anchorIds, { verified = false, now = Date.now(), timeoutMs } = {}) {
@@ -2991,7 +3104,7 @@ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
2991
3104
  * Each also sets retired_on to today and clears the other notes; the rest of the
2992
3105
  * row stays as it is, so a v1 entry stays v1. An entry already inactive, an
2993
3106
  * anchor the ledger does not hold or holds twice, and a successor absent or
2994
- * inactive are refused. The input and the quote are checked before the learning
3107
+ * inactive are refused. The input and the quote are checked before the curation
2995
3108
  * lock is taken; under it the ledger is written once and the files are rendered,
2996
3109
  * after a v1 tree is backed up (D-V1-BACKUP-ONCE) and malformed ledger lines are
2997
3110
  * quarantined (D-QUARANTINE-MALFORMED). A refusal writes nothing.
@@ -3008,7 +3121,7 @@ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
3008
3121
  * kinds: invalid-input (with problems), quoteAtRef's, not-found,
3009
3122
  * duplicate-anchor, already-inactive, successor-not-found,
3010
3123
  * successor-duplicate-anchor, successor-inactive, and withDecisionsLock's
3011
- * not-a-directory, no-learning-dir and busy.
3124
+ * not-a-directory, no-curation-dir and busy.
3012
3125
  * @throws {TypeError} for a malformed anchorId or a status that is not inactive
3013
3126
  */
3014
3127
  function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeoutMs, verifyRef } = {}) {
@@ -3016,7 +3129,7 @@ function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeout
3016
3129
  if (!INACTIVE_STATUSES.includes(status)) {
3017
3130
  throw new TypeError(`retireAnchor: status must be one of ${INACTIVE_STATUSES.join(', ')}, got '${status}'`);
3018
3131
  }
3019
- if (!hasLearningDir(root)) return noLearningDir('retire-anchor', root);
3132
+ if (!hasCurationDir(root)) return noCurationDir('retire-anchor', root);
3020
3133
  const problems = retireInputProblems(anchorId, status, input);
3021
3134
  if (problems.length > 0) return invalidInput('retire-anchor', problems);
3022
3135
  let note;
@@ -3078,7 +3191,7 @@ function retireUnderLock(root, anchorId, status, note, { now }) {
3078
3191
  * claim-due hands it out ahead of every verified entry (D-DUE-ORDER); its content
3079
3192
  * and its date stay, and the files are re-rendered. An entry already active, an
3080
3193
  * anchor the ledger does not hold or holds twice, and a row whose type its anchor
3081
- * does not name are refused. It writes as retireAnchor does, under the learning
3194
+ * does not name are refused. It writes as retireAnchor does, under the curation
3082
3195
  * lock; a refusal writes nothing.
3083
3196
  *
3084
3197
  * @param {string} root - project root
@@ -3087,7 +3200,7 @@ function retireUnderLock(root, anchorId, status, note, { now }) {
3087
3200
  * @returns {{ ok: true, value: { anchor_id: string, status: 'Accepted'|'Active' } }
3088
3201
  * | { ok: false, error: { kind: string, message: string } }}
3089
3202
  * Error kinds: not-found, duplicate-anchor, already-active, type-mismatch, and
3090
- * withDecisionsLock's not-a-directory, no-learning-dir and busy.
3203
+ * withDecisionsLock's not-a-directory, no-curation-dir and busy.
3091
3204
  * @throws {TypeError} for a malformed anchorId
3092
3205
  */
3093
3206
  function restoreAnchor(root, anchorId, { now = Date.now(), timeoutMs } = {}) {
@@ -3148,15 +3261,16 @@ module.exports = {
3148
3261
  readJsonl,
3149
3262
  rejectedPathFor,
3150
3263
  quarantineRejected,
3264
+ appendNoFollow,
3151
3265
  readJsonlForWrite,
3152
3266
  writeExclusive,
3153
3267
  writeFileAtomic,
3154
3268
  writeJsonlAtomic,
3155
3269
  // Locking
3156
- hasLearningDir,
3270
+ hasCurationDir,
3157
3271
  withDecisionsLock,
3158
3272
  // State and the ledger registry
3159
- readLearningState,
3273
+ readCurationState,
3160
3274
  ledgerRegistry,
3161
3275
  // Validation
3162
3276
  validateObservationInput,
@@ -3171,7 +3285,7 @@ module.exports = {
3171
3285
  // Rotation, clearing and resetting
3172
3286
  rotateObservations,
3173
3287
  clearUnreferenced,
3174
- resetLearning,
3288
+ resetCuration,
3175
3289
  // The queue claim
3176
3290
  CLAIM_STALE_SECS,
3177
3291
  CLAIM_TOKEN_RE,
@@ -3180,6 +3294,16 @@ module.exports = {
3180
3294
  claimQueue,
3181
3295
  releaseClaim,
3182
3296
  touchClaim,
3297
+ // Primitives curation-move.cjs builds on
3298
+ QUEUE_LOCK_STALE_MS,
3299
+ NO_HARD_LINK_CODES,
3300
+ isPlainObject,
3301
+ readTextUnlinked,
3302
+ removeIfPresent,
3303
+ renderAll,
3304
+ lstatOrNull,
3305
+ physicalPath,
3306
+ homeDirOrNull,
3183
3307
  // Integrity, listing, due selection and show
3184
3308
  integrityFlags,
3185
3309
  buildListing,