devflow-kit 3.7.0 → 3.9.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 (205) hide show
  1. package/CHANGELOG.md +479 -1
  2. package/README.md +15 -15
  3. package/dist/agents/code.md +6 -8
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +7 -8
  6. package/dist/agents/git.md +3 -3
  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 +9 -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/curation.js +808 -0
  16. package/dist/cli/commands/gate-level-prompts.js +428 -0
  17. package/dist/cli/commands/gates.js +35 -11
  18. package/dist/cli/commands/init-seed.js +11 -8
  19. package/dist/cli/commands/init.js +244 -57
  20. package/dist/cli/commands/memory.js +60 -13
  21. package/dist/cli/commands/prompt-io.js +37 -0
  22. package/dist/cli/commands/uninstall.js +157 -17
  23. package/dist/cli.js +2 -2
  24. package/dist/commands/bug-analysis.md +25 -36
  25. package/dist/commands/code-review.md +40 -44
  26. package/dist/commands/debug.md +5 -5
  27. package/dist/commands/dynamic-build.md +26 -25
  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 +53 -82
  33. package/dist/commands/plan.md +6 -6
  34. package/dist/commands/release.md +5 -5
  35. package/dist/commands/research.md +6 -6
  36. package/dist/commands/resolve.md +39 -41
  37. package/dist/commands/self-review.md +5 -13
  38. package/dist/core/area-pause-notice.js +44 -0
  39. package/dist/core/assets.js +20 -20
  40. package/dist/core/curate-tuning-config.js +5 -6
  41. package/dist/core/curation-move-notice.js +84 -0
  42. package/dist/core/curation-move-skip.js +35 -0
  43. package/dist/core/curation-move.js +35 -0
  44. package/dist/core/curation-queue-cleanup.js +135 -0
  45. package/dist/core/curation-store.js +99 -0
  46. package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
  47. package/dist/core/echo-value.js +11 -5
  48. package/dist/core/evidence-policy.js +11 -9
  49. package/dist/core/feature-switch.js +95 -19
  50. package/dist/core/fs-atomic.js +1 -1
  51. package/dist/core/gate-level.js +10 -2
  52. package/dist/core/git.js +16 -14
  53. package/dist/core/ledger-root.js +82 -22
  54. package/dist/core/linked-path.js +4 -4
  55. package/dist/core/manifest-lock.js +238 -0
  56. package/dist/core/manifest.js +58 -14
  57. package/dist/core/migrations.js +1 -13
  58. package/dist/core/observations.js +2 -2
  59. package/dist/core/plugins.js +19 -19
  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 +159 -0
  64. package/dist/core/tracker.js +1 -1
  65. package/dist/{learning-off → curation-off}/agents/code.md +6 -8
  66. package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
  67. package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
  68. package/dist/{learning-off → curation-off}/agents/review.md +3 -3
  69. package/dist/{learning-off → curation-off}/agents/triage.md +8 -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 +37 -41
  72. package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
  73. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
  74. package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
  75. package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
  76. package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
  77. package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
  78. package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
  79. package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -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/post-resolution-summary.md +12 -11
  84. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  85. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  86. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  87. package/dist/skills/git/references/publication-gate.md +6 -7
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
  89. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  90. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
  91. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  92. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  94. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
  95. package/dist/targets/claude-code/installer.js +15 -15
  96. package/dist/targets/claude-code/language-stamp.js +5 -5
  97. package/dist/targets/claude-code/post-install.js +2 -2
  98. package/dist/targets/claude-code/tracker-servers.js +1 -1
  99. package/package.json +3 -2
  100. package/src/assets/agents/_partials/_code.mds +11 -11
  101. package/src/assets/agents/code.mds +6 -6
  102. package/src/assets/agents/curate.md +8 -8
  103. package/src/assets/agents/debug.mds +6 -6
  104. package/src/assets/agents/design.mds +18 -18
  105. package/src/assets/agents/evaluate.md +6 -12
  106. package/src/assets/agents/fix.mds +7 -6
  107. package/src/assets/agents/git.mds +3 -3
  108. package/src/assets/agents/harden.mds +7 -7
  109. package/src/assets/agents/knowledge.mds +23 -23
  110. package/src/assets/agents/refactor.mds +2 -2
  111. package/src/assets/agents/research.mds +15 -15
  112. package/src/assets/agents/review.mds +43 -43
  113. package/src/assets/agents/skim.mds +11 -11
  114. package/src/assets/agents/triage.mds +29 -22
  115. package/src/assets/agents/validate.md +30 -50
  116. package/src/assets/commands/_partials/_decisions.mds +22 -20
  117. package/src/assets/commands/_partials/_engine.mds +8 -8
  118. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  119. package/src/assets/commands/_partials/_preamble.mds +4 -4
  120. package/src/assets/commands/_partials/_settings.mds +3 -3
  121. package/src/assets/commands/_partials/_wave.mds +7 -7
  122. package/src/assets/commands/bug-analysis.mds +37 -48
  123. package/src/assets/commands/code-review.mds +53 -57
  124. package/src/assets/commands/debug.mds +14 -14
  125. package/src/assets/commands/dynamic-build.mds +38 -37
  126. package/src/assets/commands/dynamic-plan.mds +22 -22
  127. package/src/assets/commands/dynamic-profile.mds +2 -2
  128. package/src/assets/commands/dynamic-tickets.mds +8 -8
  129. package/src/assets/commands/explore.mds +20 -20
  130. package/src/assets/commands/implement.mds +77 -98
  131. package/src/assets/commands/plan.mds +27 -27
  132. package/src/assets/commands/release.mds +12 -12
  133. package/src/assets/commands/research.mds +19 -19
  134. package/src/assets/commands/resolve.mds +66 -60
  135. package/src/assets/commands/self-review.mds +20 -20
  136. package/src/assets/mds/git/_ops.mds +9 -2
  137. package/src/assets/mds/git/_ops_pr.mds +21 -11
  138. package/src/assets/mds/git/_ops_steps.mds +20 -4
  139. package/src/assets/mds/git/_pr.mds +21 -18
  140. package/src/assets/mds/git/_references.mds +6 -7
  141. package/src/assets/mds/tracker/_github.mds +1 -4
  142. package/src/assets/mds/tracker/_jira.mds +1 -4
  143. package/src/assets/mds/tracker/_linear.mds +1 -4
  144. package/src/assets/rules/quality.md +1 -0
  145. package/src/assets/scripts/ci-wait.cjs +389 -92
  146. package/src/assets/scripts/hooks/background-memory-update +81 -3
  147. package/src/assets/scripts/hooks/capture-prompt +12 -12
  148. package/src/assets/scripts/hooks/capture-question +11 -11
  149. package/src/assets/scripts/hooks/capture-turn +12 -12
  150. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  151. package/src/assets/scripts/hooks/curation-move +293 -0
  152. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  153. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  154. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  155. package/src/assets/scripts/hooks/git-marker +81 -0
  156. package/src/assets/scripts/hooks/json-helper.cjs +176 -51
  157. package/src/assets/scripts/hooks/json-parse +1 -1
  158. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  159. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
  160. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  161. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  162. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  163. package/src/assets/scripts/hooks/lib/render-decisions.cjs +42 -22
  164. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
  165. package/src/assets/scripts/hooks/memory-permission +10 -2
  166. package/src/assets/scripts/hooks/memory-worker +12 -1
  167. package/src/assets/scripts/hooks/pre-compact-memory +17 -16
  168. package/src/assets/scripts/hooks/queue-append +444 -102
  169. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  170. package/src/assets/scripts/hooks/session-start-context +198 -72
  171. package/src/assets/scripts/hooks/session-start-memory +42 -43
  172. package/src/assets/scripts/lib/project-config.cjs +12 -5
  173. package/src/assets/scripts/pr-evidence.cjs +5 -2
  174. package/src/assets/scripts/resolve-base.cjs +1043 -0
  175. package/src/assets/scripts/resolve-settings.cjs +283 -45
  176. package/src/assets/scripts/verify-evidence.cjs +307 -75
  177. package/src/assets/skills/agent-references/SKILL.md +11 -2
  178. package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
  179. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  180. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
  181. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  182. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  183. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  184. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  185. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  186. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  187. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  188. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  189. package/templates/lint-pack/.lint-pack.conf +86 -0
  190. package/templates/lint-pack/README.md +190 -0
  191. package/templates/lint-pack/lint-pack.sh +905 -0
  192. package/dist/cli/commands/learning.js +0 -554
  193. package/dist/core/learning-queue-cleanup.js +0 -27
  194. package/dist/core/learning-store.js +0 -61
  195. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  196. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  197. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  198. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  199. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  200. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  201. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  202. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  203. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  204. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  205. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -22,7 +22,7 @@
22
22
  // `features.gateLevel` default
23
23
  //
24
24
  // D-SETTINGS-LOCAL-ONLY: local git subprocesses only, and never gh, never the
25
- // network, never a git command that refreshes the index. At most four, in order:
25
+ // network, never a git command that refreshes the index. At most these, in order:
26
26
  // git rev-parse --show-toplevel always
27
27
  // git --no-literal-pathspecs -c core.fsmonitor=false ls-files -z -- <PERSONAL_TRACKING_PATHSPECS>
28
28
  // only when config.json exists;
@@ -32,6 +32,17 @@
32
32
  // git symbolic-ref --quiet refs/remotes/origin/HEAD in a repository
33
33
  // git cat-file blob refs/remotes/origin/<D>:.devflow/project.json
34
34
  // only when origin/HEAD names D
35
+ // git rev-parse --path-format=absolute --git-common-dir only when MEMORY or CURATION is
36
+ // still on and <toplevel>/.git is
37
+ // not a folder: it names the ledger root
38
+ // git ... ls-files -z -- <the pathspecs of an area> only for a switch still on: ONE call
39
+ // for both areas when their roots are
40
+ // equal, one more per area only when it
41
+ // lists something; otherwise one per root
42
+ // MEMORY=off while git tracks the memory folder at the toplevel (or does not answer for it),
43
+ // and CURATION=off while git tracks the curation folders at the ledger root (or does not
44
+ // answer for them). The answer only narrows a switch; the leaf lib hooks/lib/tracked-areas.cjs
45
+ // holds the pathspecs, the scrub of redirecting variables and the mapping of git's answer.
35
46
  // The team's evidence FLOOR is the default branch's and is
36
47
  // resolve-evidence-policy.cjs's job (it runs over the network, from commands).
37
48
  // This script reads the WORKTREE's copy for every other key, so a branch that
@@ -56,7 +67,7 @@
56
67
  // TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default>
57
68
  // TRACKER_WARN=<none|mismatch|invalid> SITE=<url|none> KEY=<KEY|none>
58
69
  // REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|id,…>
59
- // MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>
70
+ // MEMORY=<on|off> CURATION=<on|off> KNOWLEDGE=<on|off> GATE_LEVEL=<full|standard|lean>
60
71
  // (one line on stdout; wrapped here for reading). Every value is a closed token
61
72
  // or a SITE/KEY that passed its shape gate, so no other byte of a config file can
62
73
  // reach stdout. Whole-file rule: a project.json or config.json that EXISTS but
@@ -92,6 +103,7 @@ const fs = require('fs');
92
103
  const path = require('path');
93
104
  const childProcess = require('child_process');
94
105
  const projectConfig = require('./lib/project-config.cjs');
106
+ const trackedAreas = require('./hooks/lib/tracked-areas.cjs');
95
107
 
96
108
  const {
97
109
  PUBLICATIONS,
@@ -114,7 +126,7 @@ const {
114
126
  /** @typedef {'off' | 'auto' | 'full'} Publication */
115
127
  /** @typedef {'full' | 'standard' | 'lean'} GateLevel */
116
128
  /** @typedef {'personal' | 'machine' | 'default'} GateLevelSource */
117
- /** @typedef {'memory' | 'learning' | 'knowledge'} FeatureSwitch */
129
+ /** @typedef {'memory' | 'curation' | 'knowledge'} FeatureSwitch */
118
130
  /** @typedef {'machine' | 'project' | 'personal'} SwitchSource */
119
131
 
120
132
  /** Who decided TRACKER. `default` is github with no layer naming a provider. */
@@ -175,7 +187,7 @@ const SETTINGS_LINE_RE = new RegExp(
175
187
  + ' REVIEW_PUBLICATION=(?<publication>' + PUBLICATIONS.join('|') + ')'
176
188
  + ' COMPLIANCE=(?<compliance>off|generic|(?:' + COMPLIANCE_IDS.join('|') + ')(?:,(?:'
177
189
  + COMPLIANCE_IDS.join('|') + ')){0,' + String(COMPLIANCE_IDS.length - 1) + '})'
178
- + ' MEMORY=(?<memory>on|off) LEARNING=(?<learning>on|off) KNOWLEDGE=(?<knowledge>on|off)'
190
+ + ' MEMORY=(?<memory>on|off) CURATION=(?<curation>on|off) KNOWLEDGE=(?<knowledge>on|off)'
179
191
  + ' GATE_LEVEL=(?<gateLevel>' + GATE_LEVELS.join('|') + ')$',
180
192
  );
181
193
 
@@ -188,15 +200,14 @@ const SETTINGS_LINE_RE = new RegExp(
188
200
  * REVIEW_PUBLICATION=off nothing is published (raised to a stub under required)
189
201
  * COMPLIANCE=generic the review lens still runs
190
202
  * KNOWLEDGE=off no knowledge write-back commits into the repository
191
- * MEMORY/LEARNING=on the machine switch is the one that turns them off;
192
- * hooks never consume this line (their gate folds an
193
- * unreadable file as narrowing nothing)
203
+ * MEMORY/CURATION=off a resolver that cannot say which repository it is in cannot
204
+ * tell whether git tracks their folders, so both stay off
194
205
  * GATE_LEVEL=full the most checking applies: a resolver that cannot read
195
206
  * its inputs never lowers the level
196
207
  */
197
208
  const SETTINGS_FAIL_CLOSED_LINE =
198
209
  'TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none '
199
- + 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off GATE_LEVEL=full';
210
+ + 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=off CURATION=off KNOWLEDGE=off GATE_LEVEL=full';
200
211
 
201
212
  /** Bound on each local git call. */
202
213
  const GIT_TIMEOUT_MS = 5000;
@@ -216,15 +227,16 @@ const BLOB_MAX_BUFFER = MAX_CONFIG_BYTES + 1;
216
227
  /** The personal file, repository-relative, in git's spelling. */
217
228
  const PERSONAL_REL = '.devflow/config.json';
218
229
  /**
219
- * The spellings a case-folding file system opens as `.devflow` and `config.json`
220
- * beyond ASCII letter case. git's `icase` folds ASCII letters only, but APFS (the
221
- * macOS default) compares names by full Unicode case folding, so it also opens
222
- * `.devflow` (U+FB02 folds to "fl"), `config.json` (U+FB01 folds to "fi") and
223
- * `config.jſon` (U+017F folds to "s") at the personal path. A probe of every code
224
- * point on APFS finds no other one that folds into these names and none that it
225
- * ignores. The code is written with escapes, so each code point is named.
230
+ * The spellings a case-folding file system opens as `config.json` beyond ASCII letter
231
+ * case (the leaf lib's DEVFLOW_SPELLINGS lists those of `.devflow`). git's `icase` folds
232
+ * ASCII letters only, but APFS (the macOS default) compares names by full Unicode case
233
+ * folding, so it also opens `config.json` (U+FB01 folds to "fi") and `config.jſon`
234
+ * (U+017F folds to "s") at the personal path, as it opens `.devflow` (U+FB02 folds to
235
+ * "fl"). A probe of every code point on APFS finds no other one that folds into these
236
+ * names and none that it ignores. The code is written with escapes, so each code point
237
+ * is named.
226
238
  */
227
- const DEVFLOW_SPELLINGS = Object.freeze(['.devflow', '.dev\u{FB02}ow']);
239
+ const { DEVFLOW_SPELLINGS } = trackedAreas;
228
240
  const CONFIG_SPELLINGS = Object.freeze(['config.json', 'con\u{FB01}g.json', 'config.j\u{17F}on', 'con\u{FB01}g.j\u{17F}on']);
229
241
  /**
230
242
  * The pathspecs of the tracking check (D-PERSONAL-UNTRACKED), each matched
@@ -257,8 +269,10 @@ const PROJECT_REL = '.devflow/project.json';
257
269
 
258
270
  /**
259
271
  * @typedef {{ on: boolean, source: SwitchSource }} SwitchState
260
- * `source` is the layer that decided: `machine` when the machine switch is off
261
- * or nothing narrowed it, else the repo layer whose literal `false` narrowed it.
272
+ * `source` is the layer that decided: `machine` when the machine switch is off,
273
+ * when git tracks the area's folder or does not answer for it (a switch narrowed
274
+ * that way carries source `machine`, so it has no file to name), or when nothing
275
+ * narrowed it; else the repo layer whose literal `false` narrowed it.
262
276
  *
263
277
  * @typedef {{
264
278
  * ok: boolean,
@@ -269,7 +283,7 @@ const PROJECT_REL = '.devflow/project.json';
269
283
  * key: string | null,
270
284
  * reviewPublication: Publication,
271
285
  * compliance: { enabled: boolean, frameworks: readonly string[] },
272
- * switches: { memory: SwitchState, learning: SwitchState, knowledge: SwitchState },
286
+ * switches: { memory: SwitchState, curation: SwitchState, knowledge: SwitchState },
273
287
  * gateLevel: GateLevel,
274
288
  * gateLevelSource: GateLevelSource | null,
275
289
  * gateLevelInvalid: boolean,
@@ -340,8 +354,8 @@ const FAIL_CLOSED_SETTINGS = Object.freeze({
340
354
  reviewPublication: 'off',
341
355
  compliance: Object.freeze({ enabled: true, frameworks: Object.freeze([]) }),
342
356
  switches: Object.freeze({
343
- memory: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
344
- learning: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
357
+ memory: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
358
+ curation: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
345
359
  knowledge: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
346
360
  }),
347
361
  gateLevel: /** @type {GateLevel} */ ('full'),
@@ -371,13 +385,28 @@ function isPlainObject(value) {
371
385
  return typeof value === 'object' && value !== null && !Array.isArray(value);
372
386
  }
373
387
 
388
+ /**
389
+ * The keys a switch is stored under, current names first (feature-switch.ts
390
+ * CURRENT_KEYS). `curation` is read under `curation` and its pre-rename copy
391
+ * `learning` (#529).
392
+ */
393
+ const CURRENT_SWITCH_KEYS = Object.freeze({
394
+ memory: Object.freeze(['memory']),
395
+ curation: Object.freeze(['curation', 'learning']),
396
+ knowledge: Object.freeze(['knowledge']),
397
+ });
398
+
374
399
  /** The pre-rename key each switch was stored under (feature-switch.ts LEGACY_KEYS). */
375
- const LEGACY_SWITCH_KEYS = Object.freeze({ learning: 'decisions', knowledge: 'kb' });
400
+ const LEGACY_SWITCH_KEYS = Object.freeze({ curation: 'decisions', knowledge: 'kb' });
376
401
 
377
402
  /**
378
- * src/core/feature-switch.ts isMachineFeatureOn exactly: only an explicit
379
- * boolean `false` is off, the legacy key is read when the current one is not a
380
- * boolean, and anything that is not a manifest-shaped object is on.
403
+ * src/core/feature-switch.ts isMachineFeatureOn exactly. Off when any current
404
+ * key is a boolean `false`; else on when any current key is a boolean `true`;
405
+ * else, when neither is a boolean, the pre-rename key decides if it is a
406
+ * boolean; else on. A non-boolean value is ignored for that key only, so a
407
+ * disagreeing pair resolves to off. Anything that is not a manifest-shaped
408
+ * object is on. The capture hooks' gate read without jq calls this one, so the
409
+ * rule is held here alone.
381
410
  *
382
411
  * @param {unknown} manifest
383
412
  * @param {FeatureSwitch} feature
@@ -387,9 +416,10 @@ function machineSwitchOn(manifest, feature) {
387
416
  if (!isPlainObject(manifest)) return true;
388
417
  const features = manifest.features;
389
418
  if (!isPlainObject(features)) return true;
419
+ const current = CURRENT_SWITCH_KEYS[feature].map(key => features[key]).filter(value => typeof value === 'boolean');
420
+ if (current.length > 0) return !current.includes(false);
390
421
  const legacy = /** @type {Record<string, string>} */ (LEGACY_SWITCH_KEYS)[feature];
391
- const value = legacy !== undefined && typeof features[feature] !== 'boolean' ? features[legacy] : features[feature];
392
- return value !== false;
422
+ return legacy === undefined || features[legacy] !== false;
393
423
  }
394
424
 
395
425
  /**
@@ -425,7 +455,7 @@ function machineCompliance(manifest) {
425
455
  * src/core/gate-level.ts machineGateLevel exactly, and total: null when no level
426
456
  * is recorded (not an object, `features` not an object, or no own `gateLevel`
427
457
  * key), `invalid` when the key is present and is not one of the three words,
428
- * else the level. The learning-capture hook folds a manifest of any shape, so
458
+ * else the level. The curation-capture hook folds a manifest of any shape, so
429
459
  * this must never throw.
430
460
  *
431
461
  * @param {unknown} manifest
@@ -625,6 +655,11 @@ function foldCompliance(project, manifest, defaultBranch) {
625
655
  * narrows: absent, malformed, duplicated and unreadable all leave the switch as
626
656
  * the machine set it. No repo layer can turn on what the machine turned off.
627
657
  *
658
+ * A switch with two current keys (`curation`: `curation` and `learning`, #529)
659
+ * is a narrow-only union: a literal `false` in either key of a file narrows it,
660
+ * and each key is classified on its own, so a duplicate or a non-boolean value
661
+ * in one key leaves the other readable.
662
+ *
628
663
  * @param {any} project
629
664
  * @param {any} personal
630
665
  * @param {unknown} manifest
@@ -635,7 +670,8 @@ function foldSwitch(project, personal, manifest, feature) {
635
670
  /** @param {any} file */
636
671
  const narrows = file => {
637
672
  const features = fieldIn(file, 'features', 'absent');
638
- return features.kind === 'valid' && features.value[feature].kind === 'valid' && features.value[feature].value === false;
673
+ if (features.kind !== 'valid') return false;
674
+ return CURRENT_SWITCH_KEYS[feature].some(key => features.value[key].kind === 'valid' && features.value[key].value === false);
639
675
  };
640
676
  if (!machineSwitchOn(manifest, feature)) return Object.freeze({ on: false, source: 'machine' });
641
677
  if (narrows(project)) return Object.freeze({ on: false, source: 'project' });
@@ -683,7 +719,9 @@ function foldGateLevel(personal, manifest) {
683
719
  * The whole-file rule's resolution (D-SETTINGS-LINE): the fail-closed values,
684
720
  * naming the layer whose file could not be read, except the compliance lens —
685
721
  * a broken file affects only the keys it owns, so the lens stays the fold of
686
- * every readable layer (D-LENS-UNION). An unreadable project.json declares
722
+ * every readable layer (D-LENS-UNION) — and the memory and curation switches,
723
+ * which stay as the machine set them (an unreadable file narrows nothing, and
724
+ * the area answers narrow them afterwards). An unreadable project.json declares
687
725
  * nothing readable of its own (repoCompliance null) but reads as a malformed
688
726
  * declaration, so its lens is at least `generic`; an unreadable config.json
689
727
  * owns no compliance at all, so the worktree project.json's ids still count.
@@ -695,8 +733,11 @@ function foldGateLevel(personal, manifest) {
695
733
  function unreadableSettings(layer, inputs) {
696
734
  const defaultBranch = inputs.defaultBranch === undefined ? ABSENT_FIELD : inputs.defaultBranch;
697
735
  const lens = foldCompliance(inputs.project, inputs.manifest, defaultBranch);
736
+ /** @param {FeatureSwitch} feature @returns {SwitchState} */
737
+ const machine = feature => Object.freeze({ on: machineSwitchOn(inputs.manifest, feature), source: 'machine' });
698
738
  return Object.freeze({
699
739
  ...FAIL_CLOSED_SETTINGS,
740
+ switches: Object.freeze({ ...FAIL_CLOSED_SETTINGS.switches, memory: machine('memory'), curation: machine('curation') }),
700
741
  // The level stays `full` with no deciding layer, but a readable manifest still shows its own value.
701
742
  gateLevelMachine: machineGateLevel(inputs.manifest),
702
743
  compliance: lens.compliance,
@@ -724,7 +765,7 @@ function foldSettings(inputs) {
724
765
  compliance: lens.compliance,
725
766
  switches: Object.freeze({
726
767
  memory: foldSwitch(project, personal, manifest, 'memory'),
727
- learning: foldSwitch(project, personal, manifest, 'learning'),
768
+ curation: foldSwitch(project, personal, manifest, 'curation'),
728
769
  knowledge: foldSwitch(project, personal, manifest, 'knowledge'),
729
770
  }),
730
771
  ...foldGateLevel(personal, manifest),
@@ -736,6 +777,19 @@ function foldSettings(inputs) {
736
777
  });
737
778
  }
738
779
 
780
+ /**
781
+ * The settings for the layers as read: the whole-file rule's resolution for the first layer
782
+ * that could not be read (project before personal), else the fold of the layers.
783
+ *
784
+ * @param {SettingsInputs} inputs
785
+ * @returns {Settings}
786
+ */
787
+ function settingsOfLayers(inputs) {
788
+ if (inputs.project.kind === 'invalid') return unreadableSettings('project', inputs);
789
+ if (inputs.personal.kind === 'invalid') return unreadableSettings('personal', inputs);
790
+ return foldSettings(inputs);
791
+ }
792
+
739
793
  // ---------------------------------------------------------------------------
740
794
  // Gathering the layers (the imperative shell — D-SETTINGS-LOCAL-ONLY)
741
795
  // ---------------------------------------------------------------------------
@@ -751,18 +805,20 @@ function defaultExec(file, args, opts) {
751
805
 
752
806
  /**
753
807
  * One bounded local git call, normalized. The argv is a fresh copy, so an exec
754
- * cannot mutate a caller's constant.
808
+ * cannot mutate a caller's constant. `baseEnv` (default: the process environment)
809
+ * is the environment the call's own derives from.
755
810
  *
756
811
  * @param {ExecFn} exec
757
812
  * @param {string} cwd
758
813
  * @param {readonly string[]} args
759
814
  * @param {number} maxBuffer
815
+ * @param {NodeJS.ProcessEnv} [baseEnv]
760
816
  * @returns {GitAnswer}
761
817
  */
762
- function runGit(exec, cwd, args, maxBuffer) {
818
+ function runGit(exec, cwd, args, maxBuffer, baseEnv) {
763
819
  const res = exec('git', [...args], {
764
820
  cwd,
765
- env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
821
+ env: Object.assign({}, baseEnv === undefined ? process.env : baseEnv, { GIT_TERMINAL_PROMPT: '0' }),
766
822
  stdio: ['ignore', 'pipe', 'pipe'],
767
823
  timeout: GIT_TIMEOUT_MS,
768
824
  maxBuffer,
@@ -827,7 +883,8 @@ function gitToplevel(exec, dir) {
827
883
  * so it is unreadable and its keys fail closed
828
884
  * `--no-literal-pathspecs` leads the argv: an inherited GIT_LITERAL_PATHSPECS=1
829
885
  * would read every pathspec as a plain path, magic included, list nothing and
830
- * read as untracked.
886
+ * read as untracked. The call runs without the variables that make git read
887
+ * another repository or index (the leaf lib's scrub), so it reads `root`'s own.
831
888
  *
832
889
  * @param {ExecFn} exec
833
890
  * @param {string} root
@@ -836,7 +893,8 @@ function gitToplevel(exec, dir) {
836
893
  */
837
894
  function personalTracking(exec, root, rootConfirmed) {
838
895
  const res = runGit(exec, root,
839
- ['--no-literal-pathspecs', '-c', 'core.fsmonitor=false', 'ls-files', '-z', '--', ...PERSONAL_TRACKING_PATHSPECS], LINE_MAX_BUFFER);
896
+ ['--no-literal-pathspecs', '-c', 'core.fsmonitor=false', 'ls-files', '-z', '--', ...PERSONAL_TRACKING_PATHSPECS], LINE_MAX_BUFFER,
897
+ trackedAreas.gitEnvWithoutRedirects(process.env));
840
898
  if (!res.answered) return 'unknown';
841
899
  if (res.stdout.length > 0) return 'tracked';
842
900
  if (res.ok) return 'untracked';
@@ -931,13 +989,180 @@ function defaultBranchCompliance(exec, root) {
931
989
  return fieldIn(projectConfig.parseProjectBytes(blob.stdout), 'compliance', 'malformed');
932
990
  }
933
991
 
992
+ // ---------------------------------------------------------------------------
993
+ // The tracked areas: memory at the toplevel, curation at the ledger root
994
+ // ---------------------------------------------------------------------------
995
+
996
+ /** What git before 2.31 prints first for `--path-format=absolute`: the flag it does not know, echoed back. */
997
+ const PATH_FORMAT_ECHO = '--path-format=absolute';
998
+
999
+ /**
1000
+ * How the `.git` entry at `root` stands, never followed: `folder`, `absent` (nothing there)
1001
+ * or `other` (a file, a link, or an entry that could not be probed).
1002
+ *
1003
+ * @param {string} root
1004
+ * @returns {'folder' | 'absent' | 'other'}
1005
+ */
1006
+ function gitEntryKind(root) {
1007
+ try {
1008
+ return fs.lstatSync(path.join(root, '.git')).isDirectory() ? 'folder' : 'other';
1009
+ } catch (err) {
1010
+ const code = err && /** @type {{ code?: string }} */ (err).code;
1011
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'other';
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Whether the main checkout `main` holds the ledger: it has a `.devflow` folder (links
1017
+ * followed), its own `.git` entry, and is not HOME (a repository rooted at HOME is the
1018
+ * machine root). The paths are compared physically, as the hooks' df_is_project_root does;
1019
+ * an unresolvable `main` holds none, and an unset or unresolvable HOME refuses nothing.
1020
+ *
1021
+ * @param {string} main
1022
+ * @returns {boolean}
1023
+ */
1024
+ function holdsLedger(main) {
1025
+ try {
1026
+ if (!fs.statSync(path.join(main, '.devflow')).isDirectory()) return false;
1027
+ fs.statSync(path.join(main, '.git'));
1028
+ const mainReal = fs.realpathSync(main);
1029
+ const home = process.env.HOME;
1030
+ if (typeof home !== 'string' || home === '') return true;
1031
+ try {
1032
+ return fs.realpathSync(home) !== mainReal;
1033
+ } catch (_) {
1034
+ return true;
1035
+ }
1036
+ } catch (_) {
1037
+ return false;
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Where the curation ledger lives for the checkout whose toplevel git named: the rule the
1043
+ * hooks apply (DF_LEDGER_ROOT of df_resolve_roots in hooks/resolve-project-root), which the
1044
+ * TypeScript twin getLedgerRoot (src/core/ledger-root.ts) shares.
1045
+ * - `<toplevel>/.git` is a folder, or there is none: the toplevel, with no git call. (A
1046
+ * main checkout is its own ledger root; with no entry the leaf lib asks no git call.)
1047
+ * - `<toplevel>/.git` is a file or a link: one `git rev-parse --path-format=absolute
1048
+ * --git-common-dir`. A path ending in `/.git` whose parent holds the ledger
1049
+ * (holdsLedger) names the parent; any other path names the toplevel. A git before 2.31
1050
+ * echoes the flag back as its first line: the toplevel, as the hooks fall back to it.
1051
+ * - any other answer, or none: `unknown` (the caller reads curation as unanswered).
1052
+ * Never throws.
1053
+ *
1054
+ * @param {string} toplevel an absolute path git named as a work tree's top level
1055
+ * @param {{ exec?: ExecFn }} [deps]
1056
+ * @returns {{ kind: 'root', root: string } | { kind: 'unknown' }}
1057
+ */
1058
+ function ledgerRootOf(toplevel, deps) {
1059
+ try {
1060
+ const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
1061
+ if (gitEntryKind(toplevel) !== 'other') return { kind: 'root', root: toplevel };
1062
+ const res = runGit(exec, toplevel, ['rev-parse', PATH_FORMAT_ECHO, '--git-common-dir'], LINE_MAX_BUFFER);
1063
+ if (!res.ok) return { kind: 'unknown' };
1064
+ const text = res.stdout.toString('utf8').replace(/\r?\n$/, '');
1065
+ if (text.split('\n', 1)[0] === PATH_FORMAT_ECHO) return { kind: 'root', root: toplevel };
1066
+ if (text === '' || /[\r\n\0]/.test(text) || !path.isAbsolute(text)) return { kind: 'unknown' };
1067
+ if (!text.endsWith('/.git')) return { kind: 'root', root: toplevel };
1068
+ const main = text.slice(0, -'/.git'.length);
1069
+ return { kind: 'root', root: main !== '' && holdsLedger(main) ? main : toplevel };
1070
+ } catch (_) {
1071
+ return { kind: 'unknown' };
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * @typedef {'tracked' | 'untracked' | 'unknown'} AreaAnswer
1077
+ * @typedef {{ memory: AreaAnswer, curation: AreaAnswer }} AreaAnswers
1078
+ */
1079
+
1080
+ /**
1081
+ * One area at one root, asked of the injected exec with the leaf lib's call (its pathspecs,
1082
+ * its scrub of redirecting variables, its bounds) and mapped by its judge. The leaf asks no
1083
+ * call, and answers `untracked`, for a root with no `.git` entry.
1084
+ *
1085
+ * @param {ExecFn} exec
1086
+ * @param {string} root
1087
+ * @param {'curation' | 'memory' | 'both'} area
1088
+ * @returns {AreaAnswer}
1089
+ */
1090
+ function askArea(exec, root, area) {
1091
+ const call = trackedAreas.trackedAreaCall(root, area);
1092
+ if (!('argv' in call)) return call.answer;
1093
+ const res = exec('git', [...call.argv], {
1094
+ cwd: call.cwd,
1095
+ env: call.env,
1096
+ stdio: ['ignore', 'pipe', 'ignore'],
1097
+ timeout: trackedAreas.TRACKED_AREAS_GIT_TIMEOUT_MS,
1098
+ maxBuffer: trackedAreas.TRACKED_AREAS_GIT_MAX_BUFFER,
1099
+ windowsHide: true,
1100
+ shell: false,
1101
+ });
1102
+ return trackedAreas.judgeTrackedAreas(res).answer;
1103
+ }
1104
+
1105
+ /**
1106
+ * The answers for the areas asked about. An area that is not asked about reads `untracked`
1107
+ * (nothing to narrow). Equal roots: ONE call over both areas; nothing listed ends it, git
1108
+ * not answering reads both `unknown`, and only a listing earns one call per area to tell
1109
+ * them apart. Different roots: one call per root. A curation root that is null (the ledger
1110
+ * root could not be named) reads `unknown`.
1111
+ *
1112
+ * @param {ExecFn} exec
1113
+ * @param {string} memoryRoot
1114
+ * @param {string | null} curationRoot
1115
+ * @param {{ memory: boolean, curation: boolean }} asked
1116
+ * @returns {AreaAnswers}
1117
+ */
1118
+ function trackedAreaAnswers(exec, memoryRoot, curationRoot, asked) {
1119
+ /** @type {AreaAnswers} */
1120
+ const answers = { memory: 'untracked', curation: 'untracked' };
1121
+ if (asked.memory && asked.curation && curationRoot === memoryRoot) {
1122
+ const union = askArea(exec, memoryRoot, 'both');
1123
+ if (union === 'untracked') return answers;
1124
+ if (union === 'unknown') return { memory: 'unknown', curation: 'unknown' };
1125
+ }
1126
+ if (asked.curation) answers.curation = curationRoot === null ? 'unknown' : askArea(exec, curationRoot, 'curation');
1127
+ if (asked.memory) answers.memory = askArea(exec, memoryRoot, 'memory');
1128
+ return answers;
1129
+ }
1130
+
1131
+ /**
1132
+ * The settings with each switch whose area is not `untracked` off. Only a switch still on
1133
+ * is narrowed, and it carries source `machine`: no file of the repository turned it off, so
1134
+ * there is none to name. Answers `settings` itself when nothing changes.
1135
+ *
1136
+ * @param {Settings} settings
1137
+ * @param {AreaAnswers} answers
1138
+ * @returns {Settings}
1139
+ */
1140
+ function narrowByAreas(settings, answers) {
1141
+ /** @param {'memory' | 'curation'} feature @returns {SwitchState} */
1142
+ const narrowed = feature => (answers[feature] === 'untracked' || !settings.switches[feature].on
1143
+ ? settings.switches[feature]
1144
+ : Object.freeze({ on: false, source: 'machine' }));
1145
+ const memory = narrowed('memory');
1146
+ const curation = narrowed('curation');
1147
+ if (memory === settings.switches.memory && curation === settings.switches.curation) return settings;
1148
+ return Object.freeze({ ...settings, switches: Object.freeze({ ...settings.switches, memory, curation }) });
1149
+ }
1150
+
934
1151
  /**
935
1152
  * Resolve the settings for `opts.dir`. Never throws: a git that cannot say
936
1153
  * whether `opts.dir` is in a repository, or any internal failure, is the
937
1154
  * fail-closed resolution, which main() maps to exit 4. A repository file that
938
- * exists but is unreadable fails every field closed but the compliance lens,
939
- * naming its layer in `unreadable` (the whole-file rule, D-SETTINGS-LINE);
940
- * main() prints that line with exit 0.
1155
+ * exists but is unreadable fails every field closed but the compliance lens and
1156
+ * the memory and curation switches, naming its layer in `unreadable` (the
1157
+ * whole-file rule, D-SETTINGS-LINE); main() prints that line with exit 0.
1158
+ *
1159
+ * Every repository return is then narrowed by the areas: memory at the toplevel,
1160
+ * curation at the ledger root, each off while git tracks its folders or does not
1161
+ * answer for them. When git answers that `opts.dir` is no repository (a lookup
1162
+ * that fails for a reason such as a redirecting variable or a refusal for
1163
+ * ownership), the leaf lib's check still runs at `opts.dir`: with no `.git`
1164
+ * entry there it asks no git call and changes nothing; with one it asks a scrubbed
1165
+ * call, and an unanswered call reads both areas as unknown.
941
1166
  *
942
1167
  * @param {ResolveSettingsOptions} opts
943
1168
  * @param {{ exec?: ExecFn }} [deps]
@@ -951,7 +1176,12 @@ function resolveSettings(opts, deps) {
951
1176
  if (toplevel.kind === 'unknown') return FAIL_CLOSED_SETTINGS;
952
1177
  if (toplevel.kind === 'none') {
953
1178
  const none = projectConfig.parseProjectBytes(null);
954
- return foldSettings({ project: none, personal: none, manifest, retiredPolicyFile: false });
1179
+ const folded = foldSettings({ project: none, personal: none, manifest, retiredPolicyFile: false });
1180
+ const start = path.resolve(opts.dir);
1181
+ return narrowByAreas(folded, trackedAreaAnswers(exec, start, start, {
1182
+ memory: folded.switches.memory.on,
1183
+ curation: folded.switches.curation.on,
1184
+ }));
955
1185
  }
956
1186
  const layers = readRepoLayers(toplevel.root, { exec, rootConfirmed: true });
957
1187
  /** @type {SettingsInputs} */
@@ -963,9 +1193,14 @@ function resolveSettings(opts, deps) {
963
1193
  defaultBranch: defaultBranchCompliance(exec, toplevel.root),
964
1194
  retiredPolicyFile: existsNoFollow(path.join(toplevel.root, '.devflow', 'policy.json')),
965
1195
  };
966
- if (layers.project.kind === 'invalid') return unreadableSettings('project', inputs);
967
- if (layers.personal.kind === 'invalid') return unreadableSettings('personal', inputs);
968
- return foldSettings(inputs);
1196
+ const folded = settingsOfLayers(inputs);
1197
+ const asked = { memory: folded.switches.memory.on, curation: folded.switches.curation.on };
1198
+ let curationRoot = null;
1199
+ if (asked.curation) {
1200
+ const ledger = ledgerRootOf(toplevel.root, { exec });
1201
+ curationRoot = ledger.kind === 'root' ? ledger.root : null;
1202
+ }
1203
+ return narrowByAreas(folded, trackedAreaAnswers(exec, toplevel.root, curationRoot, asked));
969
1204
  } catch (_) {
970
1205
  return FAIL_CLOSED_SETTINGS;
971
1206
  }
@@ -1003,7 +1238,7 @@ function formatSettingsLine(s) {
1003
1238
  + ' REVIEW_PUBLICATION=' + s.reviewPublication
1004
1239
  + ' COMPLIANCE=' + complianceToken(s.compliance)
1005
1240
  + ' MEMORY=' + onOff(s.switches.memory)
1006
- + ' LEARNING=' + onOff(s.switches.learning)
1241
+ + ' CURATION=' + onOff(s.switches.curation)
1007
1242
  + ' KNOWLEDGE=' + onOff(s.switches.knowledge)
1008
1243
  + ' GATE_LEVEL=' + s.gateLevel;
1009
1244
  }
@@ -1200,7 +1435,8 @@ if (require.main === module) {
1200
1435
  }
1201
1436
 
1202
1437
  // ---------------------------------------------------------------------------
1203
- // Exports — the CLI seam (src/core/evidence-policy.ts) and the unit tests
1438
+ // Exports — the CLI seam (src/core/evidence-policy.ts), the capture hooks' gate read
1439
+ // without jq (hooks/queue-append requires machineSwitchOn) and the unit tests
1204
1440
  // ---------------------------------------------------------------------------
1205
1441
 
1206
1442
  module.exports = Object.freeze({
@@ -1214,6 +1450,8 @@ module.exports = Object.freeze({
1214
1450
  foldGateLevel,
1215
1451
  foldSettings,
1216
1452
  readRepoLayers,
1453
+ machineSwitchOn,
1454
+ ledgerRootOf,
1217
1455
  resolveSettings,
1218
1456
  formatSettingsLine,
1219
1457
  isCoherentSettingsLine,