devflow-kit 2.5.0 → 3.0.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 (158) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +44 -19
  3. package/dist/agents/git.md +13 -15
  4. package/dist/cli/commands/ambient.js +160 -145
  5. package/dist/cli/commands/capture.js +29 -55
  6. package/dist/cli/commands/compliance.js +32 -61
  7. package/dist/cli/commands/context.js +17 -32
  8. package/dist/cli/commands/debug.js +65 -26
  9. package/dist/cli/commands/flags.js +3 -3
  10. package/dist/cli/commands/hud.js +34 -10
  11. package/dist/cli/commands/init-seed.js +40 -4
  12. package/dist/cli/commands/init.js +249 -271
  13. package/dist/cli/commands/install-report.js +10 -15
  14. package/dist/cli/commands/knowledge/index.js +1 -1
  15. package/dist/cli/commands/knowledge/toggle.js +11 -3
  16. package/dist/cli/commands/learning.js +52 -37
  17. package/dist/cli/commands/legacy-hooks.js +11 -14
  18. package/dist/cli/commands/memory.js +67 -78
  19. package/dist/cli/commands/proxy.js +23 -41
  20. package/dist/cli/commands/security.js +5 -13
  21. package/dist/cli/commands/skills.js +21 -3
  22. package/dist/cli/commands/tracker.js +100 -228
  23. package/dist/cli/commands/uninstall.js +343 -138
  24. package/dist/commands/bug-analysis.md +38 -12
  25. package/dist/commands/code-review.md +70 -21
  26. package/dist/commands/debug.md +37 -7
  27. package/dist/commands/dynamic-build.md +66 -17
  28. package/dist/commands/dynamic-plan.md +19 -8
  29. package/dist/commands/dynamic-profile.md +24 -10
  30. package/dist/commands/dynamic-tickets.md +22 -11
  31. package/dist/commands/explore.md +37 -7
  32. package/dist/commands/implement.md +96 -32
  33. package/dist/commands/plan.md +62 -19
  34. package/dist/commands/release.md +2 -2
  35. package/dist/commands/research.md +34 -8
  36. package/dist/commands/resolve.md +65 -17
  37. package/dist/commands/self-review.md +45 -9
  38. package/dist/core/compliance-compose.js +27 -27
  39. package/dist/core/evidence-policy.js +240 -24
  40. package/dist/core/feature-config.js +94 -25
  41. package/dist/core/feature-switch.js +1 -1
  42. package/dist/core/flags.js +30 -2
  43. package/dist/core/fs-atomic.js +27 -0
  44. package/dist/core/hook-log-dirs.js +104 -0
  45. package/dist/core/learning-tuning-config.js +5 -3
  46. package/dist/core/ledger-root.js +102 -0
  47. package/dist/core/manifest.js +6 -4
  48. package/dist/core/mds-variants.js +34 -97
  49. package/dist/core/migrations.js +49 -23
  50. package/dist/core/plugins.js +5 -4
  51. package/dist/core/project-paths.js +0 -17
  52. package/dist/core/same-location.js +25 -0
  53. package/dist/core/tracker.js +226 -139
  54. package/dist/hud/components/config-counts.js +15 -4
  55. package/dist/hud/components/learning-counts.js +14 -0
  56. package/dist/hud/config.js +2 -1
  57. package/dist/hud/cost-history.js +2 -4
  58. package/dist/hud/git.js +52 -7
  59. package/dist/hud/index.js +7 -9
  60. package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
  61. package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
  62. package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
  63. package/dist/skills/git/references/tracker/_mcp.md +1 -1
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
  65. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
  66. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
  67. package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
  68. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
  70. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
  71. package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
  74. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
  76. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
  77. package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
  80. package/dist/targets/claude-code/claude-paths.js +59 -57
  81. package/dist/targets/claude-code/compliance-install.js +49 -65
  82. package/dist/targets/claude-code/hooks.js +108 -3
  83. package/dist/targets/claude-code/installer.js +30 -57
  84. package/dist/targets/claude-code/post-install.js +232 -139
  85. package/dist/targets/claude-code/tracker-install.js +38 -65
  86. package/package.json +5 -4
  87. package/src/assets/agents/code.md +4 -3
  88. package/src/assets/agents/design.md +1 -0
  89. package/src/assets/agents/git.mds +55 -57
  90. package/src/assets/agents/knowledge.md +2 -2
  91. package/src/assets/agents/review.md +3 -1
  92. package/src/assets/agents/tracker.md +37 -30
  93. package/src/assets/commands/_partials/_compliance.mds +19 -1
  94. package/src/assets/commands/_partials/_decisions.mds +15 -3
  95. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  96. package/src/assets/commands/_partials/_engine.mds +2 -2
  97. package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
  98. package/src/assets/commands/_partials/_factory.mds +1 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  100. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  101. package/src/assets/commands/_partials/_preamble.mds +1 -1
  102. package/src/assets/commands/_partials/_publication.mds +6 -2
  103. package/src/assets/commands/_partials/_settings.mds +28 -0
  104. package/src/assets/commands/_partials/_ticket_template.mds +3 -3
  105. package/src/assets/commands/_partials/_tracker.mds +4 -4
  106. package/src/assets/commands/_partials/_wave.mds +4 -4
  107. package/src/assets/commands/bug-analysis.mds +19 -17
  108. package/src/assets/commands/code-review.mds +39 -33
  109. package/src/assets/commands/debug.mds +4 -5
  110. package/src/assets/commands/dynamic-build.mds +75 -53
  111. package/src/assets/commands/dynamic-plan.mds +20 -15
  112. package/src/assets/commands/dynamic-profile.mds +24 -11
  113. package/src/assets/commands/dynamic-tickets.mds +25 -20
  114. package/src/assets/commands/explore.mds +4 -5
  115. package/src/assets/commands/implement.mds +58 -45
  116. package/src/assets/commands/plan.mds +34 -29
  117. package/src/assets/commands/release.md +2 -2
  118. package/src/assets/commands/research.mds +11 -9
  119. package/src/assets/commands/resolve.mds +41 -39
  120. package/src/assets/commands/self-review.mds +24 -25
  121. package/src/assets/mds/git/_pr.mds +61 -61
  122. package/src/assets/mds/git/_references.mds +19 -19
  123. package/src/assets/mds/tracker/_common.mds +8 -8
  124. package/src/assets/mds/tracker/_github.mds +71 -71
  125. package/src/assets/mds/tracker/_jira.mds +74 -74
  126. package/src/assets/mds/tracker/_linear.mds +75 -75
  127. package/src/assets/mds/tracker/_mcp.mds +23 -17
  128. package/src/assets/scripts/hooks/background-memory-update +35 -19
  129. package/src/assets/scripts/hooks/capture-prompt +18 -12
  130. package/src/assets/scripts/hooks/capture-question +18 -12
  131. package/src/assets/scripts/hooks/capture-turn +27 -17
  132. package/src/assets/scripts/hooks/debug-trace +11 -6
  133. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  134. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  135. package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
  136. package/src/assets/scripts/hooks/git-marker +48 -0
  137. package/src/assets/scripts/hooks/json-helper.cjs +6 -1
  138. package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
  139. package/src/assets/scripts/hooks/log-paths +80 -0
  140. package/src/assets/scripts/hooks/memory-worker +17 -15
  141. package/src/assets/scripts/hooks/pre-compact-memory +41 -16
  142. package/src/assets/scripts/hooks/queue-append +104 -30
  143. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  144. package/src/assets/scripts/hooks/session-start-context +289 -122
  145. package/src/assets/scripts/hooks/session-start-memory +35 -16
  146. package/src/assets/scripts/lib/project-config.cjs +633 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
  148. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  149. package/src/assets/scripts/verify-evidence.cjs +1 -1
  150. package/src/assets/skills/compliance/SKILL.md +2 -2
  151. package/src/assets/skills/docs-framework/SKILL.md +6 -7
  152. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  153. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  154. package/src/assets/skills/git/references/github-api.md +9 -9
  155. package/src/assets/skills/git/references/patterns.md +1 -1
  156. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  157. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  158. package/src/targets/claude-code/templates/managed-settings.json +25 -9
@@ -199,11 +199,16 @@ function isCollisionScanExcluded(relPath) {
199
199
  * project root is not a git working tree or the `git` binary is unavailable;
200
200
  * callers fall back to `listFsWalkFiles`.
201
201
  *
202
+ * D-NO-FSMONITOR: `ls-files` reads the index, and reading the index runs the
203
+ * command a repository's config names in `core.fsmonitor` — code chosen by the
204
+ * repository this hook runs inside. The call turns it off for itself
205
+ * (`-c core.fsmonitor=false`), so the listing stays a pure read.
206
+ *
202
207
  * @param {string} projectRoot
203
208
  * @returns {string[]} project-relative paths
204
209
  */
205
210
  function listGitTrackedFiles(projectRoot) {
206
- const out = execFileSync('git', ['ls-files', '-z'], {
211
+ const out = execFileSync('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-z'], {
207
212
  cwd: projectRoot,
208
213
  stdio: ['ignore', 'pipe', 'ignore'],
209
214
  });
@@ -174,23 +174,6 @@ function getHandoffPath(projectRoot, branchSlug) {
174
174
  return path.join(projectRoot, '.devflow', 'docs', `handoff-${branchSlug}.md`);
175
175
  }
176
176
 
177
- // ---------------------------------------------------------------------------
178
- // Gitignore entries
179
- // ---------------------------------------------------------------------------
180
-
181
- /**
182
- * The canonical list of generic gitignore entries Devflow adds to a project's
183
- * root .gitignore for LOCAL-scope installs. Currently just `.claude/`.
184
- *
185
- * `.devflow/` is intentionally NOT here: it is managed by ensureDevflowGitignore
186
- * (TS) / ensure-root-gitignore (hook), which write the feature-knowledge carve-out
187
- * for ALL scopes. Adding a bare `.devflow/` here would append a wholesale-ignore
188
- * line after the carve-out and re-bury it (last match wins in .gitignore).
189
- */
190
- function getGitignoreEntries() {
191
- return ['.claude/'];
192
- }
193
-
194
177
  module.exports = {
195
178
  // Core directories
196
179
  getMemoryDir,
@@ -225,6 +208,4 @@ module.exports = {
225
208
  getDesignDir,
226
209
  getResearchDir,
227
210
  getHandoffPath,
228
- // Gitignore entries
229
- getGitignoreEntries,
230
211
  };
@@ -2,11 +2,88 @@
2
2
  # Shared log path computation for Devflow hooks.
3
3
  # Source this file to get devflow_log_dir function.
4
4
 
5
+ # D-LOG-DIR-CAP (hook side). Every hook logs under ~/.devflow/logs/<cwd-slug>/,
6
+ # one folder per working directory, and `devflow init` prunes them to the
7
+ # MAX_HOOK_LOG_DIRS most recent (src/core/hook-log-dirs.ts). A machine that never
8
+ # re-inits would still grow without bound, so devflow_log_dir also prunes — but
9
+ # ONLY when it creates a new folder, which is rare: the common path (the folder
10
+ # already exists) pays nothing. The pass is bounded (one `ls`, at most
11
+ # _DF_LOG_DIRS_SCANNED_MAX entries read, _DF_LOG_DIRS_PRUNED_PER_CALL removed in
12
+ # one `rm`), bash 3.2 compatible, and never spawns node. Only directories are
13
+ # counted or removed: root files (proxy.log) and symbolic links are left alone.
14
+ #
15
+ # Recency: folders are ranked by their own mtime (`ls -t`), which an append to a
16
+ # log inside does not move, so a candidate is spared when any of its first
17
+ # _DF_LOG_FILES_READ_PER_DIR entries is newer than the oldest folder the cap
18
+ # keeps (`-nt`, a shell builtin). A spared folder can leave the count briefly
19
+ # above the cap; init's exact prune settles it.
20
+ #
21
+ # _DF_MAX_HOOK_LOG_DIRS must equal MAX_HOOK_LOG_DIRS (tests/hook-log-paths.test.ts).
22
+ _DF_MAX_HOOK_LOG_DIRS=200
23
+ _DF_LOG_DIRS_PRUNED_PER_CALL=50
24
+ _DF_LOG_DIRS_SCANNED_MAX=100000
25
+ _DF_LOG_FILES_READ_PER_DIR=64
26
+
5
27
  # Cache the computed log dir path to avoid spawning 4 subprocesses (sed+tr+mkdir+chmod)
6
28
  # on every call within the same hook process.
7
29
  _LOG_DIR_CACHED=""
8
30
  _LOG_DIR_CACHED_CWD=""
9
31
 
32
+ # _df_log_dir_is_recent <dir> <cutoff>
33
+ # True when one of the first _DF_LOG_FILES_READ_PER_DIR entries in <dir> is newer
34
+ # than <cutoff>. Globbing must be on.
35
+ _df_log_dir_is_recent() {
36
+ local _dir="$1" _cutoff="$2" _f _read=0
37
+ for _f in "$_dir"/.[!.]* "$_dir"/..?* "$_dir"/*; do
38
+ [ "$_read" -lt "$_DF_LOG_FILES_READ_PER_DIR" ] || break
39
+ [ -e "$_f" ] || [ -L "$_f" ] || continue
40
+ _read=$((_read + 1))
41
+ if [ "$_f" -nt "$_cutoff" ]; then return 0; fi
42
+ done
43
+ return 1
44
+ }
45
+
46
+ # _df_prune_log_dirs <logs-root> <just-created-dir>
47
+ # Remove the oldest folders beyond _DF_MAX_HOOK_LOG_DIRS, at most
48
+ # _DF_LOG_DIRS_PRUNED_PER_CALL of them. Never fails the caller.
49
+ _df_prune_log_dirs() {
50
+ local _logs="$1" _new="$2"
51
+ local _listing _name _n=0 _i _removed=0 _cutoff _d _glob_off=0
52
+ local -a _dirs _batch
53
+ _listing=$(ls -1At "$_logs" 2>/dev/null) || return 0
54
+
55
+ case $- in *f*) _glob_off=1 ;; esac
56
+ local IFS='
57
+ '
58
+ set -f
59
+ for _name in $_listing; do
60
+ [ "$_n" -lt "$_DF_LOG_DIRS_SCANNED_MAX" ] || break
61
+ case "$_name" in */*) continue ;; esac
62
+ [ -L "$_logs/$_name" ] && continue
63
+ [ -d "$_logs/$_name" ] || continue
64
+ _dirs[_n]="$_logs/$_name"
65
+ _n=$((_n + 1))
66
+ done
67
+ set +f
68
+
69
+ if [ "$_n" -gt "$_DF_MAX_HOOK_LOG_DIRS" ]; then
70
+ _cutoff="${_dirs[_DF_MAX_HOOK_LOG_DIRS - 1]}"
71
+ _i=$((_n - 1))
72
+ while [ "$_i" -ge "$_DF_MAX_HOOK_LOG_DIRS" ] && [ "$_removed" -lt "$_DF_LOG_DIRS_PRUNED_PER_CALL" ]; do
73
+ _d="${_dirs[_i]}"
74
+ if [ "$_d" != "$_new" ] && ! _df_log_dir_is_recent "$_d" "$_cutoff"; then
75
+ _batch[_removed]="$_d"
76
+ _removed=$((_removed + 1))
77
+ fi
78
+ _i=$((_i - 1))
79
+ done
80
+ if [ "$_removed" -gt 0 ]; then rm -rf -- "${_batch[@]}" 2>/dev/null || true; fi
81
+ fi
82
+
83
+ if [ "$_glob_off" = 1 ]; then set -f; fi
84
+ return 0
85
+ }
86
+
10
87
  devflow_log_dir() {
11
88
  local cwd="$1"
12
89
  if [ "$cwd" = "$_LOG_DIR_CACHED_CWD" ] && [ -n "$_LOG_DIR_CACHED" ]; then
@@ -16,8 +93,11 @@ devflow_log_dir() {
16
93
  local slug
17
94
  slug=$(echo "$cwd" | sed 's|^/||' | tr '/' '-')
18
95
  local dir="$HOME/.devflow/logs/$slug"
96
+ local created=0
97
+ [ -d "$dir" ] || created=1
19
98
  mkdir -p "$dir"
20
99
  chmod 700 "$dir"
100
+ if [ "$created" = 1 ]; then _df_prune_log_dirs "$HOME/.devflow/logs" "$dir" || true; fi
21
101
  _LOG_DIR_CACHED="$dir"
22
102
  _LOG_DIR_CACHED_CWD="$cwd"
23
103
  echo "$dir"
@@ -2,9 +2,11 @@
2
2
 
3
3
  # Memory pipeline: memory-worker (Stop Hook)
4
4
  # Owns the 120s-throttle + nohup-spawn logic for background-memory-update.
5
- # Registered AFTER capture-turn in the Stop hook array so append-before-spawn
6
- # ordering is preserved by array position. This hook does NOT append to any
7
- # queue itself -- capture-turn already did that earlier in the same Stop event.
5
+ # Claude Code runs a Stop event's hooks in parallel, so this hook may fire
6
+ # before capture-turn has appended this turn's assistant row. That is safe:
7
+ # background-memory-update leaves a user-only queue in place and skips the LLM
8
+ # run (D-QUEUE-NO-ORPHAN-DELETE), and the next run takes the whole turn. This
9
+ # hook does NOT append to any queue itself.
8
10
 
9
11
  # Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced.
10
12
  dbg() { :; }
@@ -32,18 +34,18 @@ source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
32
34
  PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
33
35
  [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
34
36
 
35
- # The machine-wide manifest (the memory switch, D-FEATURES-MACHINE-WIDE in
36
- # queue-append) is user-scope: resolve it from the inherited DEVFLOW_DIR (or
37
- # ~/.devflow) BEFORE the project-scoped assignment below shadows that value.
38
- DEVFLOW_MANIFEST="${DEVFLOW_DIR:-$HOME/.devflow}/manifest.json"
39
- DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
40
- MEMORY_DIR="$DEVFLOW_DIR/memory"
37
+ # The machine-wide manifest (the memory switch, D-FEATURES-NARROW-ONLY in
38
+ # queue-append) lives at the machine root,
39
+ # $HOME/.devflow (D-ONE-HOME).
40
+ DEVFLOW_MANIFEST="$HOME/.devflow/manifest.json"
41
+ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
42
+ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
41
43
 
42
- # Memory gate: the machine-wide switch gates this hook entirely, read by the
43
- # helper every memory/learning gate shares (D-FEATURES-MACHINE-WIDE, see
44
- # queue-append). The per-repo config is never consulted.
44
+ # Memory gate: the machine switch, narrowed by this checkout's project.json /
45
+ # config.json `features.memory`, gates this hook entirely -- read by the helper
46
+ # every memory/learning gate shares (D-FEATURES-NARROW-ONLY, see queue-append).
45
47
  source "$SCRIPT_DIR/queue-append" || { echo "memory-worker: failed to source queue-append" >&2; exit 1; }
46
- queue_read_gates "$DEVFLOW_MANIFEST"
48
+ queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
47
49
  MEMORY_ENABLED="$_QG_MEMORY"
48
50
 
49
51
  dbg "MEMORY_ENABLED=$MEMORY_ENABLED"
@@ -99,8 +101,8 @@ touch "$TRIGGER_FILE" 2>/dev/null || true
99
101
 
100
102
  # Spawn detached worker — nohup + disown so it survives the Stop hook process exit.
101
103
  # The manifest path is handed over explicitly: the worker re-checks the memory
102
- # switch after spawn, and by now DEVFLOW_DIR names the project .devflow (and is
103
- # what the worker would inherit, were it exported), not the devflow-global root.
104
+ # switch after spawn against the same manifest this hook gated on (and the same
105
+ # checkout's repository files, from the project root it resolves itself).
104
106
  nohup "$UPDATER" "$CWD" "$DEVFLOW_MANIFEST" </dev/null >>/dev/null 2>&1 & disown
105
107
 
106
108
  log "Spawned background-memory-update worker (CWD=$CWD)"
@@ -43,23 +43,23 @@ source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
43
43
  PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
44
44
  [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
45
45
 
46
- # The machine-wide manifest (the memory switch, D-FEATURES-MACHINE-WIDE in
47
- # queue-append) is user-scope: resolve it from the inherited DEVFLOW_DIR (or
48
- # ~/.devflow) BEFORE the project-scoped assignment below shadows that value.
49
- DEVFLOW_MANIFEST="${DEVFLOW_DIR:-$HOME/.devflow}/manifest.json"
50
- DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
51
- MEMORY_DIR="$DEVFLOW_DIR/memory"
46
+ # The machine-wide manifest (the memory switch, D-FEATURES-NARROW-ONLY in
47
+ # queue-append) lives at the machine root,
48
+ # $HOME/.devflow (D-ONE-HOME).
49
+ DEVFLOW_MANIFEST="$HOME/.devflow/manifest.json"
50
+ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
51
+ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
52
52
 
53
53
  # Normal logging
54
54
  source "$SCRIPT_DIR/hook-log-init" "pre-compact-memory"
55
55
 
56
- # Memory gate: the machine-wide switch, read by the helper every memory/learning
57
- # gate shares (D-FEATURES-MACHINE-WIDE, see queue-append). The per-repo config
58
- # is never consulted.
56
+ # Memory gate: the machine switch, narrowed by this checkout's project.json /
57
+ # config.json `features.memory`, read by the helper every memory/learning gate
58
+ # shares (D-FEATURES-NARROW-ONLY, see queue-append).
59
59
  source "$SCRIPT_DIR/queue-append" || { echo "pre-compact-memory: failed to source queue-append" >&2; exit 1; }
60
- queue_read_gates "$DEVFLOW_MANIFEST"
60
+ queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
61
61
  if [ "$_QG_MEMORY" != "true" ]; then
62
- dbg "EXIT: memory disabled machine-wide"
62
+ dbg "EXIT: memory disabled"
63
63
  exit 0
64
64
  fi
65
65
 
@@ -79,9 +79,26 @@ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
79
79
  if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
80
80
  GIT_HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "")
81
81
  GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
82
- GIT_STATUS=$(git status --porcelain 2>/dev/null | head -30 || echo "")
82
+ # D-DETACHED-HEAD: a checkout of a commit rather than a branch (`git checkout
83
+ # <sha>`, bisect, a rebase stop, `git worktree add --detach`, a CI checkout)
84
+ # prints no branch name. It is labelled `(detached)`, echoing git's own
85
+ # `(HEAD detached at …)` display and holding no space, so the stamp's
86
+ # one-token `branch:` field still parses; the backup names it and the bootstrap
87
+ # below still stamps memory, keyed on the commit it describes. (A branch really
88
+ # named `(detached)` is legal git and would read the same — accepted: nothing
89
+ # decides on the label, it only names.) An unborn branch has no HEAD commit,
90
+ # keeps the empty label, and is still skipped.
91
+ if [ -z "$GIT_BRANCH" ] && is_hex_sha "$GIT_HEAD_SHA" 40 40; then
92
+ GIT_BRANCH="(detached)"
93
+ fi
94
+ # D-NO-FSMONITOR: `status` and `diff` read the index, and reading the index
95
+ # runs the command the repository's config names in `core.fsmonitor` — code
96
+ # chosen by the repository this hook runs inside. Each index read turns it
97
+ # off for itself (`-c core.fsmonitor=false`); rev-parse, branch and log never
98
+ # read the index.
99
+ GIT_STATUS=$(git -c core.fsmonitor=false status --porcelain 2>/dev/null | head -30 || echo "")
83
100
  GIT_LOG=$(git log --oneline -10 2>/dev/null || echo "")
84
- GIT_DIFF_STAT=$(git diff --stat HEAD 2>/dev/null || echo "")
101
+ GIT_DIFF_STAT=$(git -c core.fsmonitor=false diff --stat HEAD 2>/dev/null || echo "")
85
102
  dbg "GIT_BRANCH=$GIT_BRANCH HEAD=$GIT_HEAD_SHA"
86
103
  fi
87
104
 
@@ -105,8 +122,12 @@ json_backup_construct \
105
122
  log "Wrote backup: $BACKUP_FILE"
106
123
  dbg "Wrote backup: $BACKUP_FILE"
107
124
 
108
- # Bootstrap minimal WORKING-MEMORY.md if absent; skip on detached HEAD, unborn branch,
109
- # or malformed SHA. is_hex_sha 40 40: exactly 40 lowercase hex chars required.
125
+ # Bootstrap minimal WORKING-MEMORY.md if absent; skip on an unborn branch or a
126
+ # malformed SHA. is_hex_sha 40 40: exactly 40 lowercase hex chars required. A detached
127
+ # HEAD bootstraps with a `branch: (detached)` stamp (D-DETACHED-HEAD, above): the
128
+ # stamp's memory-head SHA is what drift detection keys on, and the Context line names
129
+ # the commit by its short SHA, so the first compaction there no longer leaves the
130
+ # session with nothing to restore.
110
131
  # avoids REL-5: O_EXCL-style atomic create via noclobber so the existence test and the
111
132
  # create are one operation — if the worker's CAS mv lands in the window, noclobber fails
112
133
  # (file already exists) and we skip the bootstrap rather than truncating fresh memory.
@@ -126,7 +147,11 @@ if [ -n "$GIT_BRANCH" ] && is_hex_sha "$GIT_HEAD_SHA" 40 40; then
126
147
  echo "- (none recorded)"
127
148
  echo ""
128
149
  echo "## Context"
129
- echo "- Branch: $GIT_BRANCH"
150
+ if [ "$GIT_BRANCH" = "(detached)" ]; then
151
+ echo "- Branch: (detached) @ ${GIT_HEAD_SHA:0:7}"
152
+ else
153
+ echo "- Branch: $GIT_BRANCH"
154
+ fi
130
155
  echo "$GIT_LOG" | head -3 | while IFS= read -r line; do
131
156
  [ -n "$line" ] && echo "- $line"
132
157
  done
@@ -24,39 +24,53 @@
24
24
  # queue is gated independently -- callers compute memory_enabled/learning_enabled
25
25
  # themselves (see queue_read_gates below for the at-most-one-fork read of both).
26
26
  #
27
- # queue_read_gates <manifest_path>
28
- # Reads BOTH `features.memory` and `features.learning` from the devflow-global
29
- # ~/.devflow/manifest.json in at most ONE subprocess fork (AC-P1 -- at most one
30
- # gate-read fork per capture hook, not two; none when D-GATES-FAST-PATH
31
- # settles it). Sets _QG_MEMORY and _QG_LEARNING ("true"/"false") in the
32
- # caller's scope. The two values are newline-separated
27
+ # queue_read_gates <manifest_path> [<root>]
28
+ # Reads BOTH switches, `memory` and `learning`, in at most ONE subprocess fork
29
+ # (AC-P1 -- at most one gate-read fork per capture hook, not two; none when
30
+ # the fast paths settle it). Sets _QG_MEMORY and _QG_LEARNING ("true"/"false")
31
+ # in the caller's scope. The two values are newline-separated
33
32
  # rather than using a control-character delimiter: they are always the literal
34
33
  # strings "true"/"false", never arbitrary content, so a plain newline split is
35
34
  # unambiguous and easy to review (no invisible bytes hiding in the source).
36
35
  #
37
- # D-FEATURES-MACHINE-WIDE (src/core/feature-switch.ts): memory and learning are
38
- # switched for the WHOLE MACHINE by the manifest alone -- `devflow init` and
39
- # `devflow memory|learning --enable/--disable` both write it -- so the answer is
40
- # the same in every repo and every non-git cwd. The per-repo
41
- # .devflow/config.json is never read here: its old memory/learning keys are
42
- # retired, and reading them is what let a feature keep running in every repo
43
- # but the one `init --no-<feature>` ran in (#378). Only an explicit boolean
44
- # `false` switches a feature off -- an absent, unreadable or malformed manifest,
45
- # a missing key, or a non-boolean value leaves it ON (fail-open, ADR-028), the
46
- # same rule isMachineFeatureOn() applies in the CLI.
36
+ # D-FEATURES-NARROW-ONLY (src/core/feature-switch.ts): a switch is on iff the
37
+ # machine switch is on AND neither repository file sets `features.<name>` to
38
+ # the literal `false` -- the rule resolve-settings.cjs folds, held to the same
39
+ # answers by the shared table tests/fixtures/settings-switch-table.ts (TP-49).
40
+ # A repository can switch memory or learning off for itself, never back on.
41
+ # machine <manifest_path>, the ~/.devflow/manifest.json `devflow init` and
42
+ # `devflow memory|learning --enable/--disable` write. Only an
43
+ # explicit boolean `false` switches a feature off -- an absent,
44
+ # unreadable or malformed manifest, a missing key, or a non-boolean
45
+ # value leaves it ON (fail-open, ADR-028), the rule
46
+ # isMachineFeatureOn() applies in the CLI.
47
+ # project <root>/.devflow/project.json (team-committed) and
48
+ # personal <root>/.devflow/config.json (this worktree's own), read ONLY under
49
+ # `features.memory` / `features.learning`. The retired top-level
50
+ # keys (`memory`, `learning`, `decisions`) and `features.decisions`
51
+ # narrow nothing (#378: reading them let a feature run in every repo
52
+ # but one). An invalid file -- unparseable, over 4096 bytes, a BOM,
53
+ # a symlink, a duplicated key -- narrows nothing.
54
+ # <root> is the checkout's toplevel (DF_ROOT, which every caller holds as
55
+ # $PROJECT_ROOT), for learning too: project.json is committed per branch and
56
+ # config.json lives per worktree, so both are read where the session runs, as
57
+ # resolve-settings.cjs reads them -- never at DF_LEDGER_ROOT, which only says
58
+ # where the queue lands. Without <root> (or with an empty one) no repository
59
+ # file is read.
47
60
  #
48
- # D-LEARNING-LEGACY-DECISIONS: learning reads `features.learning` when it is a
49
- # boolean, else the pre-rename `features.decisions` (ADR-011) -- readManifest's
50
- # migration precedence exactly, so a legacy `decisions: false` switches
51
- # learning off before any command has healed the file. Only a boolean `false`
52
- # in whichever key decides is off, as above.
61
+ # D-LEARNING-LEGACY-DECISIONS: in the MANIFEST only, learning reads
62
+ # `features.learning` when it is a boolean, else the pre-rename
63
+ # `features.decisions` (ADR-011) -- readManifest's migration precedence
64
+ # exactly, so a legacy `decisions: false` switches learning off before any
65
+ # command has healed the file. Only a boolean `false` in whichever key decides
66
+ # is off, as above.
53
67
  #
54
68
  # Every hook that gates on memory or learning reads it through here: the
55
69
  # capture hooks, session-start-context (Sections 1-2), memory-worker,
56
70
  # session-start-memory, pre-compact-memory and background-memory-update. The
57
- # callers resolve <manifest_path> from ${DEVFLOW_DIR:-$HOME/.devflow} BEFORE
58
- # they shadow DEVFLOW_DIR with the project-scoped .devflow (memory-worker hands
59
- # its resolved path to the background worker it spawns).
71
+ # callers pass <manifest_path> as $HOME/.devflow/manifest.json, the machine
72
+ # root (D-ONE-HOME); memory-worker hands its resolved path to the background
73
+ # worker it spawns.
60
74
 
61
75
  queue_append_row() {
62
76
  local _qar_file="$1" _qar_role="$2" _qar_content="$3" _qar_ts="$4"
@@ -106,8 +120,34 @@ queue_append_both() {
106
120
  fi
107
121
  }
108
122
 
123
+ # _qg_repo_file_can_narrow <file> -- succeeds when <file> COULD set
124
+ # features.memory or features.learning to false, so only the parser can decide
125
+ # it; fails when it certainly cannot. Builtins only: no fork.
126
+ #
127
+ # D-GATES-FAST-PATH (repository files): the read is bounded -- `-n 4097`, which
128
+ # bash 3.2 supports -- and `read -d ''` succeeds only when it stops short of the
129
+ # end of the file: at a NUL byte (never valid in JSON) or at 4097 characters,
130
+ # which is more than MAX_CONFIG_BYTES (4096) bytes. Either way the parser would
131
+ # call the file invalid, and an invalid file narrows nothing. `-f` follows a
132
+ # symlink and refuses a directory or FIFO, so nothing here can block; the parser
133
+ # itself refuses the symlink. A `\u` escape is the only JSON spelling of a
134
+ # letter other than the letter itself, so without one a narrowing file shows
135
+ # `"features"`, then `{`, then `"memory"` or `"learning"`, then `false`,
136
+ # literally and in that order. `.` spans newlines (POSIX ERE without
137
+ # REG_NEWLINE), so the match can only over-report -- an over-report costs one
138
+ # fork and the parser's answer, never a wrong one. No `"decisions"` and no
139
+ # top-level key before `features` is ever matched.
140
+ _qg_repo_file_can_narrow() {
141
+ local _qgf_text=""
142
+ local _qgf_re='"features"[[:space:]]*:[[:space:]]*[{].*"(memory|learning)"[[:space:]]*:[[:space:]]*false'
143
+ [ -f "$1" ] || return 1
144
+ if IFS= read -r -d '' -n 4097 _qgf_text 2>/dev/null < "$1"; then return 1; fi
145
+ case "$_qgf_text" in *'\u'*) return 0 ;; esac
146
+ [[ $_qgf_text =~ $_qgf_re ]]
147
+ }
148
+
109
149
  queue_read_gates() {
110
- local _qg_manifest="${1:-}" _qg_text="" _qg_parse="false"
150
+ local _qg_manifest="${1:-}" _qg_root="${2:-}" _qg_text="" _qg_parse="false" _qg_repo="false" _qg_fields=""
111
151
  _QG_MEMORY="true"
112
152
  _QG_LEARNING="true"
113
153
 
@@ -129,8 +169,38 @@ queue_read_gates() {
129
169
  if [[ $_qg_text =~ $_qg_re ]]; then _qg_parse="true"; fi
130
170
  fi
131
171
 
132
- if [ "$_qg_parse" = "true" ]; then
133
- local _qg_fields
172
+ if [ -n "$_qg_root" ]; then
173
+ if _qg_repo_file_can_narrow "$_qg_root/.devflow/project.json" \
174
+ || _qg_repo_file_can_narrow "$_qg_root/.devflow/config.json"; then
175
+ _qg_repo="true"
176
+ fi
177
+ fi
178
+
179
+ if [ "$_qg_repo" = "true" ]; then
180
+ # The ONE fork when a repository file can narrow: node on the resolver's own
181
+ # readRepoLayers + foldSettings (resolve-settings.cjs, a sibling of this
182
+ # hooks/ directory), so every file rule -- symlink, size, BOM, fatal UTF-8,
183
+ # duplicate keys -- is the parser's, not a shell copy of it. It folds the
184
+ # manifest too, read only when the fast path above flagged it (an unflagged
185
+ # manifest is both-on), so a false in all three layers still costs one fork.
186
+ # jq cannot see duplicate keys, so this path is node whatever _HAS_JQ says.
187
+ local _qg_scripts="${BASH_SOURCE[0]%/*}/.." _qg_manifest_arg=""
188
+ [ "$_qg_parse" = "true" ] && _qg_manifest_arg="$_qg_manifest"
189
+ _qg_fields=$(node -e "
190
+ const S = require(require('path').resolve(process.argv[1], 'resolve-settings.cjs'));
191
+ let m;
192
+ if (process.argv[2] !== '') {
193
+ try { m = JSON.parse(require('fs').readFileSync(process.argv[2], 'utf8')); } catch (_) { m = undefined; }
194
+ }
195
+ const w = S.foldSettings(Object.assign({ manifest: m }, S.readRepoLayers(process.argv[3]))).switches;
196
+ process.stdout.write(String(w.memory.on) + String.fromCharCode(10) + String(w.learning.on));
197
+ " -- "$_qg_scripts" "$_qg_manifest_arg" "$_qg_root" 2>/dev/null) || _qg_fields=""
198
+ fi
199
+
200
+ # The manifest-only parse: when no repository file can narrow, or when the
201
+ # fork above failed (the manifest's answer still stands; the repository's is
202
+ # lost, which fails open like every other unreadable layer).
203
+ if [ -z "$_qg_fields" ] && [ "$_qg_parse" = "true" ]; then
134
204
  if [ "$_HAS_JQ" = "true" ]; then
135
205
  # `try` covers a manifest whose top level or `features` is not an object; a
136
206
  # parse failure empties the output, which reads as "not switched off". The
@@ -150,11 +220,15 @@ queue_read_gates() {
150
220
  process.stdout.write(String(on('memory')) + String.fromCharCode(10) + String(on(learnKey)));
151
221
  " -- "$_qg_manifest" 2>/dev/null) || _qg_fields=""
152
222
  fi
153
- if [ -n "$_qg_fields" ]; then
223
+ fi
224
+
225
+ # Only the four well-formed answers are taken; anything else is fail-open.
226
+ case "$_qg_fields" in
227
+ true$'\n'true|true$'\n'false|false$'\n'true|false$'\n'false)
154
228
  _QG_MEMORY="${_qg_fields%%$'\n'*}"
155
229
  _QG_LEARNING="${_qg_fields#*$'\n'}"
156
- fi
157
- fi
230
+ ;;
231
+ esac
158
232
 
159
233
  # Explicit return: this function's meaning is "populate _QG_* outputs," not a
160
234
  # success/failure signal, so its exit status must never leak the truthiness of
@@ -9,13 +9,19 @@
9
9
  # side anchors identically.
10
10
  #
11
11
  # Sourced by: the memory/learning/session hooks and ensure-devflow-init.
12
- # Sourced helper: uses `return`-free pure function; _-prefixed locals (never
12
+ # Sourced helper: uses `return`-free pure functions; _-prefixed locals (never
13
13
  # clobbers caller vars). Safe under `set -e` (git failure is guarded with || true).
14
14
  #
15
15
  # Usage:
16
16
  # source resolve-project-root
17
- # PROJECT_ROOT="$(df_resolve_root "$CWD")"
17
+ # PROJECT_ROOT="$(df_resolve_root "$CWD")" # one root
18
+ # df_resolve_roots "$CWD" # sets DF_ROOT and DF_LEDGER_ROOT
18
19
  #
20
+ # df_resolve_roots needs df_is_project_root, so this helper sources its sibling
21
+ # git-marker itself rather than trusting every caller to have done so first.
22
+
23
+ source "${BASH_SOURCE[0]%/*}/git-marker" 2>/dev/null || true
24
+
19
25
  # df_resolve_root <cwd> prints the project root for <cwd>:
20
26
  # 1. git top-level — git walks up to the real repo root even from a
21
27
  # .devflow/-nested subdir, so the nested case is fixed for git repos.
@@ -27,11 +33,99 @@ df_resolve_root() {
27
33
  # the caller (e.g. a non-git path). Empty output then routes to the fallback.
28
34
  _root="$(git -C "$_cwd" rev-parse --show-toplevel 2>/dev/null || true)"
29
35
  if [ -z "$_root" ]; then
30
- case "$_cwd" in
31
- */.devflow/*) _root="${_cwd%%/.devflow/*}" ;;
32
- */.devflow) _root="${_cwd%/.devflow}" ;;
33
- *) _root="$_cwd" ;;
34
- esac
36
+ _df_nongit_root "$_cwd"
37
+ _root="$_DF_NONGIT_ROOT"
35
38
  fi
36
39
  printf '%s\n' "$_root"
37
40
  }
41
+
42
+ # _df_nongit_root <cwd> sets _DF_NONGIT_ROOT to the non-git fallback root: <cwd>
43
+ # with its first /.devflow/ segment onward (or a trailing /.devflow) stripped.
44
+ # A variable rather than stdout, so a caller pays no `$(...)` subshell for it.
45
+ _df_nongit_root() {
46
+ case "$1" in
47
+ */.devflow/*) _DF_NONGIT_ROOT="${1%%/.devflow/*}" ;;
48
+ */.devflow) _DF_NONGIT_ROOT="${1%/.devflow}" ;;
49
+ *) _DF_NONGIT_ROOT="$1" ;;
50
+ esac
51
+ }
52
+
53
+ # df_resolve_roots <cwd> sets TWO roots in the caller's scope, from ONE git call:
54
+ # DF_ROOT — the checkout's toplevel: where per-checkout data lives
55
+ # (memory, the .gitignore carve-out, the knowledge bases).
56
+ # DF_LEDGER_ROOT — where the learning ledger and its queue live.
57
+ #
58
+ # D-LEDGER-MAIN-WORKTREE: a linked worktree (`git worktree add`, `claude
59
+ # --worktree`) has its own toplevel, so a ledger anchored there restarts at
60
+ # ADR-001 — main's decisions are invisible in it, and the IDs it mints collide
61
+ # with main's (in a repo whose collision guard refuses, the Learning agent stops
62
+ # for good). The ledger is one per REPOSITORY, not per checkout: DF_LEDGER_ROOT
63
+ # is the main worktree — the parent of `--git-common-dir` — when that common dir
64
+ # is a `<dir>/.git` directory AND `<dir>/.devflow` already exists; otherwise it is
65
+ # DF_ROOT. The existence test keeps a main checkout that never ran devflow from
66
+ # being scaffolded by a worktree session. A main worktree at HOME (a dotfiles
67
+ # repository) is refused as well (df_is_project_root, D-HOOKS-GIT-ONLY): its
68
+ # `.devflow` is the machine root, which always exists, so the existence test alone
69
+ # would put the ledger inside `~/.devflow`. A common dir that is not `/.git`
70
+ # (a bare repository, `--separate-git-dir`, a submodule's modules/ dir) has no
71
+ # main checkout to name, so it stays per checkout. Memory stays at DF_ROOT on
72
+ # purpose: working memory describes the branch in front of you. A worktree that
73
+ # already grew its own ledger before this rule keeps it — local data is left in
74
+ # place, and the worktree simply stops appending to it.
75
+ #
76
+ # The one call asks for both answers at once:
77
+ # git rev-parse --path-format=absolute --show-toplevel --git-common-dir
78
+ # and its output is accepted only when it is EXACTLY two lines, each an absolute
79
+ # path. `--path-format` arrived in git 2.31; an older git echoes the unknown
80
+ # flag back as a line of its own (three lines, the first not absolute), and that
81
+ # falls back to df_resolve_root, so the ledger stays at the toplevel exactly as
82
+ # before this rule. A failed call prints nothing: `--show-toplevel` itself failed
83
+ # (not a work tree), so asking git again could only fail the same way, and the
84
+ # non-git fallback applies directly. Inside a checkout git refuses (dubious
85
+ # ownership, GIT_CEILING_DIRECTORIES) that fallback is the raw cwd, which may be a
86
+ # subdirectory; df_is_project_root refuses any root without its own `.git`
87
+ # (D-HOOKS-TOPLEVEL-ONLY), so no hook scaffolds there. Parsing is parameter expansion and `case`
88
+ # only: one git call on the success path and on the non-git path alike.
89
+ df_resolve_roots() {
90
+ local _cwd="$1" _out="" _top="" _common="" _main=""
91
+ DF_ROOT=""
92
+ DF_LEDGER_ROOT=""
93
+ _out="$(git -C "$_cwd" rev-parse --path-format=absolute --show-toplevel --git-common-dir 2>/dev/null || true)"
94
+ _top="${_out%%$'\n'*}"
95
+ if [ "$_top" != "$_out" ]; then
96
+ _common="${_out#*$'\n'}"
97
+ fi
98
+ case "$_top" in /*) ;; *) _top="" ;; esac
99
+ case "$_common" in
100
+ *$'\n'*) _common="" ;;
101
+ /*) ;;
102
+ *) _common="" ;;
103
+ esac
104
+ if [ -z "$_out" ]; then
105
+ _df_nongit_root "$_cwd"
106
+ DF_ROOT="$_DF_NONGIT_ROOT"
107
+ [ -n "$DF_ROOT" ] || DF_ROOT="$_cwd"
108
+ DF_LEDGER_ROOT="$DF_ROOT"
109
+ return 0
110
+ fi
111
+ if [ -z "$_top" ] || [ -z "$_common" ]; then
112
+ DF_ROOT="$(df_resolve_root "$_cwd")"
113
+ [ -n "$DF_ROOT" ] || DF_ROOT="$_cwd"
114
+ DF_LEDGER_ROOT="$DF_ROOT"
115
+ return 0
116
+ fi
117
+ DF_ROOT="$_top"
118
+ DF_LEDGER_ROOT="$_top"
119
+ case "$_common" in
120
+ */.git)
121
+ _main="${_common%/.git}"
122
+ # df_is_project_root forks nothing (builtin `cd -P` walks). Unavailable —
123
+ # git-marker failed to source — it is a command not found, which keeps the
124
+ # ledger in this checkout: the conservative answer.
125
+ if [ -n "$_main" ] && [ -d "$_main/.devflow" ] && df_is_project_root "$_main" 2>/dev/null; then
126
+ DF_LEDGER_ROOT="$_main"
127
+ fi
128
+ ;;
129
+ esac
130
+ return 0
131
+ }