devflow-kit 3.7.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 (184) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +11 -11
  3. package/dist/agents/code.md +2 -4
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +6 -7
  6. package/dist/agents/git.md +1 -1
  7. package/dist/agents/harden.md +1 -1
  8. package/dist/agents/refactor.md +2 -4
  9. package/dist/agents/review.md +4 -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 +34 -11
  18. package/dist/cli/commands/init-seed.js +11 -8
  19. package/dist/cli/commands/init.js +240 -54
  20. package/dist/cli/commands/memory.js +2 -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 +2 -2
  24. package/dist/commands/bug-analysis.md +24 -35
  25. package/dist/commands/code-review.md +32 -36
  26. package/dist/commands/debug.md +4 -4
  27. package/dist/commands/dynamic-build.md +25 -24
  28. package/dist/commands/dynamic-plan.md +4 -4
  29. package/dist/commands/dynamic-profile.md +4 -4
  30. package/dist/commands/dynamic-tickets.md +4 -4
  31. package/dist/commands/explore.md +5 -5
  32. package/dist/commands/implement.md +52 -81
  33. package/dist/commands/plan.md +5 -5
  34. package/dist/commands/release.md +4 -4
  35. package/dist/commands/research.md +5 -5
  36. package/dist/commands/resolve.md +28 -30
  37. package/dist/commands/self-review.md +4 -12
  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 +11 -5
  47. package/dist/core/evidence-policy.js +1 -1
  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 +10 -2
  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 +58 -14
  56. package/dist/core/migrations.js +1 -13
  57. package/dist/core/observations.js +2 -2
  58. package/dist/core/plugins.js +15 -15
  59. package/dist/core/pool.js +26 -0
  60. package/dist/core/project-paths.js +103 -34
  61. package/dist/core/queue-drain.js +2 -2
  62. package/dist/core/tracked-areas.js +35 -0
  63. package/dist/core/tracker.js +1 -1
  64. package/dist/{learning-off → curation-off}/agents/code.md +2 -4
  65. package/dist/{learning-off → curation-off}/agents/fix.md +6 -7
  66. package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
  67. package/dist/{learning-off → curation-off}/agents/review.md +3 -3
  68. package/dist/{learning-off → curation-off}/agents/triage.md +1 -1
  69. package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
  70. package/dist/{learning-off → curation-off}/commands/code-review.md +29 -33
  71. package/dist/{learning-off → curation-off}/commands/debug.md +1 -1
  72. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +22 -21
  73. package/dist/{learning-off → curation-off}/commands/explore.md +1 -1
  74. package/dist/{learning-off → curation-off}/commands/implement.md +49 -78
  75. package/dist/{learning-off → curation-off}/commands/plan.md +1 -1
  76. package/dist/{learning-off → curation-off}/commands/resolve.md +25 -27
  77. package/dist/{learning-off → curation-off}/commands/self-review.md +1 -9
  78. package/dist/hud/components/{learning-counts.js → curation-counts.js} +36 -17
  79. package/dist/hud/config.js +1 -1
  80. package/dist/hud/index.js +6 -6
  81. package/dist/hud/render.js +3 -3
  82. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  83. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
  84. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
  85. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
  86. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
  87. package/dist/targets/claude-code/installer.js +15 -15
  88. package/dist/targets/claude-code/language-stamp.js +5 -5
  89. package/dist/targets/claude-code/post-install.js +2 -2
  90. package/dist/targets/claude-code/tracker-servers.js +1 -1
  91. package/package.json +3 -2
  92. package/src/assets/agents/_partials/_code.mds +11 -11
  93. package/src/assets/agents/code.mds +2 -2
  94. package/src/assets/agents/curate.md +8 -8
  95. package/src/assets/agents/debug.mds +6 -6
  96. package/src/assets/agents/design.mds +18 -18
  97. package/src/assets/agents/fix.mds +6 -5
  98. package/src/assets/agents/git.mds +1 -1
  99. package/src/assets/agents/harden.mds +7 -7
  100. package/src/assets/agents/knowledge.mds +23 -23
  101. package/src/assets/agents/refactor.mds +2 -2
  102. package/src/assets/agents/research.mds +15 -15
  103. package/src/assets/agents/review.mds +43 -43
  104. package/src/assets/agents/skim.mds +11 -11
  105. package/src/assets/agents/triage.mds +22 -22
  106. package/src/assets/agents/validate.md +13 -26
  107. package/src/assets/commands/_partials/_decisions.mds +13 -13
  108. package/src/assets/commands/_partials/_engine.mds +8 -8
  109. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  110. package/src/assets/commands/_partials/_preamble.mds +4 -4
  111. package/src/assets/commands/_partials/_settings.mds +2 -2
  112. package/src/assets/commands/_partials/_wave.mds +7 -7
  113. package/src/assets/commands/bug-analysis.mds +37 -48
  114. package/src/assets/commands/code-review.mds +46 -50
  115. package/src/assets/commands/debug.mds +14 -14
  116. package/src/assets/commands/dynamic-build.mds +38 -37
  117. package/src/assets/commands/dynamic-plan.mds +22 -22
  118. package/src/assets/commands/dynamic-profile.mds +2 -2
  119. package/src/assets/commands/dynamic-tickets.mds +8 -8
  120. package/src/assets/commands/explore.mds +20 -20
  121. package/src/assets/commands/implement.mds +77 -98
  122. package/src/assets/commands/plan.mds +27 -27
  123. package/src/assets/commands/release.mds +12 -12
  124. package/src/assets/commands/research.mds +19 -19
  125. package/src/assets/commands/resolve.mds +56 -50
  126. package/src/assets/commands/self-review.mds +20 -20
  127. package/src/assets/mds/git/_ops.mds +3 -1
  128. package/src/assets/mds/git/_ops_pr.mds +1 -1
  129. package/src/assets/mds/git/_ops_steps.mds +10 -4
  130. package/src/assets/mds/git/_pr.mds +7 -4
  131. package/src/assets/rules/quality.md +1 -0
  132. package/src/assets/scripts/hooks/background-memory-update +2 -2
  133. package/src/assets/scripts/hooks/capture-prompt +12 -12
  134. package/src/assets/scripts/hooks/capture-question +11 -11
  135. package/src/assets/scripts/hooks/capture-turn +12 -12
  136. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  137. package/src/assets/scripts/hooks/curation-move +293 -0
  138. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  139. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  140. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  141. package/src/assets/scripts/hooks/git-marker +81 -0
  142. package/src/assets/scripts/hooks/json-helper.cjs +134 -51
  143. package/src/assets/scripts/hooks/json-parse +1 -1
  144. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  145. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +242 -118
  146. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  147. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  148. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  149. package/src/assets/scripts/hooks/lib/render-decisions.cjs +34 -22
  150. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +259 -0
  151. package/src/assets/scripts/hooks/memory-worker +1 -1
  152. package/src/assets/scripts/hooks/pre-compact-memory +6 -16
  153. package/src/assets/scripts/hooks/queue-append +228 -103
  154. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  155. package/src/assets/scripts/hooks/session-start-context +143 -69
  156. package/src/assets/scripts/hooks/session-start-memory +3 -43
  157. package/src/assets/scripts/lib/project-config.cjs +12 -5
  158. package/src/assets/scripts/resolve-base.cjs +1043 -0
  159. package/src/assets/scripts/resolve-settings.cjs +42 -18
  160. package/src/assets/skills/agent-references/SKILL.md +5 -2
  161. package/src/assets/skills/agent-references/references/commands/implement-levels.md +27 -0
  162. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  163. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +24 -0
  164. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  165. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  166. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  167. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  168. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  169. package/templates/lint-pack/.lint-pack.conf +86 -0
  170. package/templates/lint-pack/README.md +190 -0
  171. package/templates/lint-pack/lint-pack.sh +905 -0
  172. package/dist/core/learning-queue-cleanup.js +0 -27
  173. package/dist/core/learning-store.js +0 -61
  174. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  175. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  176. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  177. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  178. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  179. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  180. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  181. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  182. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  183. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  184. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @file manifest-lock.ts
3
+ *
4
+ * A mkdir lock for `<devflowDir>/manifest.json`. It covers two writers, which read the
5
+ * manifest, decide and write it back: the toggle write of the `memory`, `curation`,
6
+ * `knowledge` and `behavior` switches (`writeMachineFeature`) and the read heal
7
+ * (`readManifest`). Without a lock, a heal that parsed the file
8
+ * before a `--disable` landed writes its stale snapshot over the disable, after the
9
+ * toggle reported success. Both writers take this lock around their read and their write.
10
+ *
11
+ * Every other writer of the manifest takes no lock: `syncManifestFeature` (the `hud`,
12
+ * `security`, `proxy` and `tracker` toggles), the `flags` and `rules` writes, the gate
13
+ * level writer and init. Such a write can still overwrite a toggle that lands between its
14
+ * read and its write. That gap is older than this lock and is tracked in issue #587.
15
+ *
16
+ * The lock is a directory beside the manifest. `mkdir` is atomic, so one caller makes
17
+ * it and every other caller sees EEXIST. A holder that died leaves the directory
18
+ * behind; a directory older than {@link MANIFEST_LOCK_STALE_MS} counts as left over
19
+ * and a waiter breaks it.
20
+ *
21
+ * Breaking is where the shared hook lock (`hooks/lib/mkdir-lock.cjs`) is weak: two
22
+ * waiters that both saw the same stale lock can both remove it, and the second
23
+ * removal can hit the lock that the first waiter took in between. This module breaks
24
+ * a lock in two steps. The waiter renames the directory to a unique name, so only one
25
+ * waiter can move it, and then checks that the directory it moved really was stale.
26
+ * A waiter that moved a fresh lock gives it back and counts the lock as busy.
27
+ *
28
+ * The give-back leaves a short time in which the live holder's directory is not at the
29
+ * path, and a third caller can make a directory there. So a holder writes a random
30
+ * token into a file inside the directory it made, and `stillHeld()` tells it whether the
31
+ * directory at the path still holds that token. A holder calls it just before its write.
32
+ * When it answers false, the holder does not write: the heal skips, and the toggle takes
33
+ * the lock again. A release removes the directory only while it still holds the holder's
34
+ * own token. A token tells two directories apart on every file system. The device and
35
+ * inode numbers of a directory do not: Linux file systems often give the freed inode
36
+ * number of a removed directory to the next one made. The check narrows the window to
37
+ * the time between the check and the write itself; a directory lock cannot close it.
38
+ */
39
+ import { promises as fs } from 'fs';
40
+ import { randomBytes } from 'crypto';
41
+ import * as path from 'path';
42
+ /** The lock directory's name, in the machine directory beside `manifest.json`. */
43
+ export const MANIFEST_LOCK_NAME = 'manifest.json.lock';
44
+ /**
45
+ * What a stale break adds to the lock's name for the directory it moves aside:
46
+ * `manifest.json.lock.stale-<pid>-<12 lowercase hex>`. A break whose removal failed leaves one.
47
+ */
48
+ export const MANIFEST_LOCK_STALE_MARK = '.stale-';
49
+ /**
50
+ * Age after which a lock directory counts as left behind by a dead holder. The
51
+ * critical sections are one read and one atomic write, so a live holder is done in
52
+ * milliseconds.
53
+ */
54
+ export const MANIFEST_LOCK_STALE_MS = 5_000;
55
+ /** The file inside the lock directory that holds the holder's token. */
56
+ export const MANIFEST_LOCK_TOKEN_NAME = 'holder';
57
+ /**
58
+ * The longest a toggle waits for the lock. It is longer than the stale age, so a lock
59
+ * left by a dead holder is always broken inside the wait.
60
+ */
61
+ export const MANIFEST_LOCK_WAIT_MS = 7_000;
62
+ const POLL_MS = 25;
63
+ /** The lock directory for `devflowDir`. */
64
+ export function getManifestLockPath(devflowDir) {
65
+ return path.join(devflowDir, MANIFEST_LOCK_NAME);
66
+ }
67
+ function errorCode(err) {
68
+ return err?.code;
69
+ }
70
+ function sleep(ms) {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+ /** `made`: this call made the directory, with its token. `exists`: another caller has it, or it was taken away at once. `unusable`: the folder cannot hold it. */
74
+ async function makeLockDir(lockDir) {
75
+ try {
76
+ await fs.mkdir(lockDir, { recursive: false });
77
+ }
78
+ catch (err) {
79
+ return errorCode(err) === 'EEXIST' ? 'exists' : 'unusable';
80
+ }
81
+ const token = randomBytes(16).toString('hex');
82
+ try {
83
+ await fs.writeFile(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), token, { flag: 'wx', mode: 0o600 });
84
+ }
85
+ catch (err) {
86
+ // ENOENT: the directory was taken away at once. Another failure: the directory is
87
+ // empty or another's, so `rmdir` removes only an empty one of ours.
88
+ if (errorCode(err) === 'ENOENT')
89
+ return 'exists';
90
+ await fs.rmdir(lockDir).catch(() => undefined);
91
+ return 'unusable';
92
+ }
93
+ return { made: token };
94
+ }
95
+ /** True when the directory at `lockDir` is a real directory (not a link) that holds `token`. */
96
+ async function holdsToken(lockDir, token) {
97
+ try {
98
+ if (!(await fs.lstat(lockDir)).isDirectory())
99
+ return false;
100
+ return (await fs.readFile(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), 'utf-8')) === token;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ async function removeQuietly(dir) {
107
+ try {
108
+ await fs.rm(dir, { recursive: true, force: true });
109
+ }
110
+ catch {
111
+ // Best effort: a leftover renamed directory holds no lock.
112
+ }
113
+ }
114
+ /**
115
+ * Break a stale lock. True when the lock path is free to take again (this call removed
116
+ * a stale lock, or it was already gone); false when it is held by a live holder or
117
+ * could not be moved.
118
+ *
119
+ * The directory is renamed to a unique name before anything is removed, so of any
120
+ * number of waiters that judged the same lock stale, one rename succeeds and the rest
121
+ * get ENOENT. The rename is also where a late waiter can hit a lock that a new holder
122
+ * has taken since it judged: the age of the moved directory tells the two apart, and
123
+ * a fresh one is put back.
124
+ */
125
+ async function breakStaleLock(lockDir, staleMs) {
126
+ try {
127
+ const seen = await fs.stat(lockDir);
128
+ if (Date.now() - seen.mtimeMs <= staleMs)
129
+ return false;
130
+ }
131
+ catch (err) {
132
+ return errorCode(err) === 'ENOENT';
133
+ }
134
+ const moved = `${lockDir}${MANIFEST_LOCK_STALE_MARK}${process.pid}-${randomBytes(6).toString('hex')}`;
135
+ try {
136
+ await fs.rename(lockDir, moved);
137
+ }
138
+ catch (err) {
139
+ // ENOENT: another waiter broke it, or the holder released it, since the age check.
140
+ return errorCode(err) === 'ENOENT';
141
+ }
142
+ let movedAge;
143
+ try {
144
+ movedAge = Date.now() - (await fs.stat(moved)).mtimeMs;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ if (movedAge > staleMs) {
150
+ await removeQuietly(moved);
151
+ return true;
152
+ }
153
+ // A live holder took the lock between the age check and the rename. Give it back,
154
+ // unless the path was taken again meanwhile; then the moved copy is removed, and its
155
+ // holder finds out through `stillHeld` that its directory is not at the path any more.
156
+ try {
157
+ await fs.lstat(lockDir);
158
+ await removeQuietly(moved);
159
+ }
160
+ catch {
161
+ try {
162
+ await fs.rename(moved, lockDir);
163
+ }
164
+ catch {
165
+ await removeQuietly(moved);
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ /** One try: make the lock, or break a stale one and make it once more. */
171
+ async function tryOnce(lockDir, staleMs) {
172
+ const first = await makeLockDir(lockDir);
173
+ if (typeof first === 'object')
174
+ return first.made;
175
+ if (first === 'unusable')
176
+ return 'unusable';
177
+ if (!(await breakStaleLock(lockDir, staleMs)))
178
+ return 'busy';
179
+ const second = await makeLockDir(lockDir);
180
+ if (typeof second === 'object')
181
+ return second.made;
182
+ return second === 'exists' ? 'busy' : 'unusable';
183
+ }
184
+ /**
185
+ * Take the manifest lock. Waits at most `timeoutMs` (polling every 25 ms), then gives
186
+ * up with `not-acquired`; with `timeoutMs: 0` it tries once. The number of tries is
187
+ * fixed by the timeout, so no wait is unbounded. A folder that cannot hold a lock (it
188
+ * does not exist, or it is read-only) also answers `not-acquired`, at once, and this
189
+ * call never creates the folder.
190
+ */
191
+ export async function acquireManifestLock(devflowDir, options = {}) {
192
+ const timeoutMs = Math.max(0, options.timeoutMs ?? MANIFEST_LOCK_WAIT_MS);
193
+ const staleMs = options.staleMs ?? MANIFEST_LOCK_STALE_MS;
194
+ const lockDir = getManifestLockPath(devflowDir);
195
+ const tries = Math.floor(timeoutMs / POLL_MS) + 1;
196
+ for (let attempt = 0; attempt < tries; attempt++) {
197
+ const outcome = await tryOnce(lockDir, staleMs);
198
+ if (outcome !== 'busy' && outcome !== 'unusable') {
199
+ return {
200
+ ok: true,
201
+ value: {
202
+ stillHeld: () => holdsToken(lockDir, outcome),
203
+ release: async () => {
204
+ if (!(await holdsToken(lockDir, outcome)))
205
+ return;
206
+ try {
207
+ await fs.rm(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), { force: true });
208
+ await fs.rmdir(lockDir);
209
+ }
210
+ catch { /* already released */ }
211
+ },
212
+ },
213
+ };
214
+ }
215
+ if (outcome === 'unusable')
216
+ break;
217
+ if (attempt < tries - 1)
218
+ await sleep(POLL_MS);
219
+ }
220
+ return { ok: false, error: 'not-acquired' };
221
+ }
222
+ /**
223
+ * Run `work` while holding the manifest lock, and release it after, whether `work`
224
+ * returns or throws (a throw goes on to the caller). `work` does not run when the lock
225
+ * is not acquired. `work` gets the holder's `stillHeld` check, to call just before it writes.
226
+ */
227
+ export async function withManifestLock(devflowDir, options, work) {
228
+ const lock = await acquireManifestLock(devflowDir, options);
229
+ if (!lock.ok)
230
+ return lock;
231
+ try {
232
+ return { ok: true, value: await work({ stillHeld: lock.value.stillHeld }) };
233
+ }
234
+ finally {
235
+ await lock.value.release();
236
+ }
237
+ }
238
+ //# sourceMappingURL=manifest-lock.js.map
@@ -5,6 +5,8 @@ import { VIEW_MODES, migrateLegacyFlagsToRecord, sanitizeFlagsRecord, } from './
5
5
  import { normalizeComplianceFeature } from './compliance.js';
6
6
  import { normalizeTrackerFeature } from './tracker.js';
7
7
  import { writeFileAtomicExclusive } from './fs-atomic.js';
8
+ import { isMachineFeatureOn } from './feature-switch.js';
9
+ import { withManifestLock } from './manifest-lock.js';
8
10
  /**
9
11
  * Parse features.flags across the three on-disk shapes; reports whether a heal is owed.
10
12
  *
@@ -77,7 +79,11 @@ export function hasRequiredManifestFields(data) {
77
79
  *
78
80
  * Self-heals the following on-disk inconsistencies:
79
81
  * - features.kb → features.knowledge rename (both absent → true, D-FEATURES-ABSENT-ON)
80
- * - features.decisions → features.learning rename (both absent → true, D-FEATURES-ABSENT-ON)
82
+ * - the curation switch (features.curation beside its pre-rename copy features.learning,
83
+ * and the oldest features.decisions) resolves by the machine rule — false in either
84
+ * current key wins, both absent → true (D-FEATURES-ABSENT-ON) — and the heal writes
85
+ * both current keys to the resolved value and drops `decisions`; a second read writes
86
+ * nothing
81
87
  * - features.flags as string[] → FlagsRecord (via migrateLegacyFlagsToRecord)
82
88
  * - features.viewMode folded into flags['view-mode'] and stripped from result
83
89
  * - features.knownFlags stripped from result (folded into FlagsRecord key-presence)
@@ -90,6 +96,12 @@ export function hasRequiredManifestFields(data) {
90
96
  *
91
97
  * D39: heal-write failure returns the migrated in-memory manifest (not null).
92
98
  * The on-disk format remains unhealed; next read triggers another attempt.
99
+ *
100
+ * The heal is a read-modify-write and takes the manifest lock (manifest-lock.ts) like
101
+ * the toggle write does. It never waits for it: when the lock is held, it skips the
102
+ * write, because the next read heals. Under the lock it reads the file again and
103
+ * writes only when those bytes are the bytes it parsed, so a toggle that landed after
104
+ * the parse is never written over with the older snapshot.
93
105
  */
94
106
  export async function readManifest(devflowDir, opts) {
95
107
  const manifestPath = path.join(devflowDir, 'manifest.json');
@@ -100,7 +112,7 @@ export async function readManifest(devflowDir, opts) {
100
112
  return null;
101
113
  const features = data.features;
102
114
  // D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-NARROW-ONLY,
103
- // src/core/feature-switch.ts): knowledge and learning are machine-wide
115
+ // src/core/feature-switch.ts): knowledge and curation are machine-wide
104
116
  // switches, and every runtime gate — queue_read_gates in the hooks,
105
117
  // isMachineFeatureOn in the CLI, the knowledge write-back prose gate — reads
106
118
  // an ABSENT key as ON. This reader must agree: init seeds from it and every
@@ -119,13 +131,15 @@ export async function readManifest(devflowDir, opts) {
119
131
  const knowledge = typeof features.knowledge === 'boolean' ? features.knowledge
120
132
  : typeof features.kb === 'boolean' ? features.kb
121
133
  : true;
122
- // Self-heal: rename features.decisions → features.learning on disk.
123
- // Coalesce: features.learning wins; fall back to features.decisions; default ON.
124
- // D-LEARNING-LEGACY-DECISIONS: isMachineFeatureOn and queue_read_gates apply
125
- // this exact precedence, so the legacy key is honoured before the heal lands.
126
- const learning = typeof features.learning === 'boolean' ? features.learning
127
- : typeof features.decisions === 'boolean' ? features.decisions
128
- : true;
134
+ // Self-heal: the curation switch is stored under two current keys, `curation`
135
+ // and its pre-rename copy `learning` (#529). A boolean false in either is off, a
136
+ // boolean true in either is on when neither is false, and the pre-rename
137
+ // features.decisions decides only when neither current key is a boolean;
138
+ // default ON. isMachineFeatureOn IS that rule (D-LEARNING-LEGACY-DECISIONS),
139
+ // and queue_read_gates and resolve-settings.cjs mirror it, so the legacy key
140
+ // is honoured before the heal lands and a disagreeing pair reads off. The heal
141
+ // (needsHeal below) writes the resolved value to both current keys once.
142
+ const curation = isMachineFeatureOn(data, 'curation');
129
143
  // Self-heal: non-string-array or absent knownFlags/knownPlugins → undefined (never partial/garbage)
130
144
  const asStringArray = (val) => Array.isArray(val) && val.every(e => typeof e === 'string')
131
145
  ? val
@@ -140,9 +154,14 @@ export async function readManifest(devflowDir, opts) {
140
154
  const { flags: parsedFlags, legacy: flagsWereLegacy } = parseManifestFlags(features, knownFlags);
141
155
  // D39: sanitize all values; block prototype pollution keys.
142
156
  const sanitizedFlags = sanitizeFlagsRecord(parsedFlags);
143
- // needsHeal when any legacy artifact is present on disk
157
+ // needsHeal when any legacy artifact is present on disk. The curation pair is a
158
+ // defect unless BOTH current keys are present and equal to the resolved value
159
+ // (a disagreeing pair, one key alone and neither key all heal); once healed,
160
+ // the pair holds and a second read writes nothing.
144
161
  const needsHeal = features.kb !== undefined ||
145
162
  features.decisions !== undefined ||
163
+ features.curation !== curation ||
164
+ features.learning !== curation ||
146
165
  flagsWereLegacy ||
147
166
  features.knownFlags !== undefined ||
148
167
  features.viewMode !== undefined ||
@@ -161,7 +180,7 @@ export async function readManifest(devflowDir, opts) {
161
180
  memory: features.memory,
162
181
  hud: typeof features.hud === 'boolean' ? features.hud : false,
163
182
  knowledge,
164
- learning,
183
+ curation,
165
184
  rules: typeof features.rules === 'boolean' ? features.rules : true,
166
185
  flags: sanitizedFlags,
167
186
  // knownFlags and viewMode are NOT carried into the result.
@@ -177,7 +196,7 @@ export async function readManifest(devflowDir, opts) {
177
196
  proxy: typeof features.proxy === 'boolean' ? features.proxy : false,
178
197
  // Self-heal: absent/malformed compliance → {enabled:false, frameworks:[]}
179
198
  compliance: normalizeComplianceFeature(features.compliance),
180
- // D-BEHAVIOR-OPT-IN: absent/malformed → false (opt-in; the inverse of knowledge/learning).
199
+ // D-BEHAVIOR-OPT-IN: absent/malformed → false (opt-in; the inverse of knowledge/curation).
181
200
  behavior: features.behavior === true,
182
201
  // Self-heal: absent/malformed/unknown tracker → {provider:'github'} (AC-3.21).
183
202
  // Deliberately NOT in the hard-null set above — see the field's doc comment.
@@ -197,7 +216,15 @@ export async function readManifest(devflowDir, opts) {
197
216
  // propagate to the caller. The migrated in-memory manifest is returned even
198
217
  // when the on-disk heal fails. The next read will retry the heal.
199
218
  try {
200
- await writeManifest(devflowDir, manifest);
219
+ // No wait: a held lock means a toggle is writing, and the next read heals.
220
+ // Under the lock the write goes ahead only while the file still holds the
221
+ // bytes parsed above and the lock directory is still this heal's own; any
222
+ // other writer's result stands, and a skipped heal is made by the next read.
223
+ await withManifestLock(devflowDir, { timeoutMs: 0 }, async (lock) => {
224
+ if ((await fs.readFile(manifestPath, 'utf-8')) === content && (await lock.stillHeld())) {
225
+ await writeManifest(devflowDir, manifest);
226
+ }
227
+ });
201
228
  }
202
229
  catch {
203
230
  // heal-write failed — return migrated in-memory manifest unchanged
@@ -219,9 +246,26 @@ export async function writeManifest(devflowDir, data) {
219
246
  await fs.mkdir(devflowDir, { recursive: true });
220
247
  const manifestPath = path.join(devflowDir, 'manifest.json');
221
248
  // D-MANIFEST-SCOPE-PINNED: every write records 'user', whatever the caller holds.
222
- const pinned = { ...data, scope: 'user' };
249
+ // The manifest mirror (#529): the pre-rename key `learning` is written equal to
250
+ // `curation`, right after it, so every typed writer keeps the copy a 3.7.0 CLI reads.
251
+ const pinned = { ...data, scope: 'user', features: withCurationMirror(data.features) };
223
252
  await writeFileAtomicExclusive(manifestPath, JSON.stringify(pinned, null, 2) + '\n');
224
253
  }
254
+ /**
255
+ * `features` with the pre-rename key `learning` set equal to `curation`, placed right
256
+ * after it. Pure. Any `learning` the input carried is replaced, never kept.
257
+ */
258
+ function withCurationMirror(features) {
259
+ const mirrored = {};
260
+ for (const [key, value] of Object.entries(features)) {
261
+ if (key === 'learning')
262
+ continue;
263
+ mirrored[key] = value;
264
+ if (key === 'curation')
265
+ mirrored.learning = value;
266
+ }
267
+ return mirrored;
268
+ }
225
269
  /**
226
270
  * Update a single feature field in the manifest. No-op when no manifest exists.
227
271
  * Reads, mutates, and writes atomically. The `updatedAt` timestamp is always refreshed.
@@ -11,6 +11,7 @@
11
11
  import { promises as fs } from 'fs';
12
12
  import * as path from 'path';
13
13
  import { writeFileAtomicExclusive } from './fs-atomic.js';
14
+ import { pooled } from './pool.js';
14
15
  import { getMemoryDir } from './project-paths.js';
15
16
  import { LEGACY_AGENT_KEYS, canonicaliseAgentKeys, parseAgentMappingEnvelope } from './agent-models.js';
16
17
  import { migrateLegacyTrackerConventions } from './tracker.js';
@@ -215,19 +216,6 @@ export function reportMigrationResult(result, logger, verbose) {
215
216
  logger.info(` ✓ ${id}`);
216
217
  }
217
218
  }
218
- /**
219
- * Process an array of items with at most `limit` concurrent Promises.
220
- * Returns PromiseSettledResult for every item in the original order.
221
- */
222
- async function pooled(items, limit, fn) {
223
- const results = [];
224
- for (let i = 0; i < items.length; i += limit) {
225
- const chunk = items.slice(i, i + limit);
226
- const chunkResults = await Promise.allSettled(chunk.map(fn));
227
- results.push(...chunkResults);
228
- }
229
- return results;
230
- }
231
219
  /**
232
220
  * Run a single global migration, returning { applied, failure, infos, warnings }.
233
221
  *
@@ -10,8 +10,8 @@
10
10
  * INACTIVE_DECISIONS_STATUSES stay in the ledger and leave the rendered files and
11
11
  * the HUD counts; an absent or unknown status counts as active. These lists
12
12
  * mirror ACTIVE_STATUSES / INACTIVE_STATUSES in
13
- * src/assets/scripts/hooks/lib/learning-store.cjs, which the hooks and the
14
- * renderer use, and the parity test in tests/decisions/learning-store.test.ts
13
+ * src/assets/scripts/hooks/lib/curation-store.cjs, which the hooks and the
14
+ * renderer use, and the parity test in tests/decisions/curation-store.test.ts
15
15
  * pins the two equal. Reason: the renderer, the HUD and this module each kept a
16
16
  * list of their own, so a status added to one counted as active in the others.
17
17
  */
@@ -481,36 +481,36 @@ export const FEATURE_OWNED_SKILLS = ['compliance'];
481
481
  */
482
482
  export const FEATURE_OWNED_RULES = ['compliance', 'behavior'];
483
483
  /**
484
- * Skills a learning-off machine does not install.
484
+ * Skills a curation-off machine does not install.
485
485
  *
486
- * D-LEARNING-VARIANT-INSTALL: the learning-off variants of the commands and
486
+ * D-LEARNING-VARIANT-INSTALL: the curation-off variants of the commands and
487
487
  * agents (D-LEARNING-VARIANTS) carry no decisions text and preload no
488
- * apply-decisions, so the skill has no reader on a machine with learning off. It
488
+ * apply-decisions, so the skill has no reader on a machine with curation off. It
489
489
  * stays OWNED and REQUIRED by its plugins in the registry — the closure guard
490
- * reasons over the learning-on variant, the superset — and the install drops it
491
- * on top of the selection: `installViaFileCopy` skips it when `learning` is false
492
- * and `convergeLearningVariants` installs or removes it when the switch flips.
490
+ * reasons over the curation-on variant, the superset — and the install drops it
491
+ * on top of the selection: `installViaFileCopy` skips it when `curation` is false
492
+ * and `convergeCurationVariants` installs or removes it when the switch flips.
493
493
  *
494
494
  * This is a machine-switch condition, not a deselection: the plan
495
- * ({@link resolveSkillInstallPlan}) is untouched, so a learning-off machine never
495
+ * ({@link resolveSkillInstallPlan}) is untouched, so a curation-off machine never
496
496
  * reports the skill as "removed because no selected plugin requires it" nor its
497
497
  * shadow as inactive for a plugin that is not selected.
498
498
  *
499
499
  * Used by:
500
- * - installer.ts installViaFileCopy: omits these from the install loop when learning is off
501
- * - learning-install.ts convergeLearningVariants: installs or removes the directory
500
+ * - installer.ts installViaFileCopy: omits these from the install loop when curation is off
501
+ * - curation-install.ts convergeCurationVariants: installs or removes the directory
502
502
  * - tests: independent literal ['apply-decisions'] (avoids the EXCLUDED-as-oracle trap)
503
503
  */
504
- export const LEARNING_GATED_SKILLS = ['apply-decisions'];
504
+ export const CURATION_GATED_SKILLS = ['apply-decisions'];
505
505
  /**
506
- * A skills map with the learning-gated skills left out when learning is off.
506
+ * A skills map with the curation-gated skills left out when curation is off.
507
507
  *
508
- * Pure: the same map instance comes back when learning is on, a copy otherwise.
508
+ * Pure: the same map instance comes back when curation is on, a copy otherwise.
509
509
  */
510
- export function omitLearningGatedSkills(skillsMap, learning) {
511
- if (learning)
510
+ export function omitCurationGatedSkills(skillsMap, curation) {
511
+ if (curation)
512
512
  return skillsMap;
513
- const gated = LEARNING_GATED_SKILLS;
513
+ const gated = CURATION_GATED_SKILLS;
514
514
  return new Map([...skillsMap].filter(([skill]) => !gated.includes(skill)));
515
515
  }
516
516
  // ── Skill-closure boundaries ──────────────────────────────────────────────────
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file pool.ts
3
+ *
4
+ * Bounded concurrency over an array. Shared by the migration runner and the
5
+ * curation move step, so neither imports the other for a general helper.
6
+ */
7
+ /**
8
+ * Process an array of items with at most `limit` concurrent Promises.
9
+ * Returns PromiseSettledResult for every item in the original order.
10
+ *
11
+ * Precondition: `limit` is a positive integer. A limit below 1 (or NaN) would never
12
+ * advance the chunk loop, so the call rejects with a RangeError before `fn` runs.
13
+ */
14
+ export async function pooled(items, limit, fn) {
15
+ if (!Number.isInteger(limit) || limit < 1) {
16
+ throw new RangeError(`pooled: limit must be a positive integer, got ${String(limit)}`);
17
+ }
18
+ const results = [];
19
+ for (let i = 0; i < items.length; i += limit) {
20
+ const chunk = items.slice(i, i + limit);
21
+ const chunkResults = await Promise.allSettled(chunk.map(fn));
22
+ results.push(...chunkResults);
23
+ }
24
+ return results;
25
+ }
26
+ //# sourceMappingURL=pool.js.map