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
@@ -39,16 +39,20 @@ devflow_debug_set_cwd "$CWD"
39
39
  # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this
40
40
  # hook runs with a CWD inside .devflow/...). Empty → fall back to CWD.
41
41
  source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
42
- PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
43
- [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
44
-
45
- # The machine-wide manifest (the memory/learning switches, D-FEATURES-MACHINE-WIDE
46
- # in queue-append) is user-scope: resolve it from the inherited DEVFLOW_DIR (or
47
- # ~/.devflow) BEFORE the project-scoped assignment below shadows that value.
48
- DEVFLOW_MANIFEST="${DEVFLOW_DIR:-$HOME/.devflow}/manifest.json"
49
- DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
50
- MEMORY_DIR="$DEVFLOW_DIR/memory"
51
- LEARNING_DIR="$DEVFLOW_DIR/learning"
42
+ df_resolve_roots "$CWD" 2>/dev/null || true
43
+ PROJECT_ROOT="${DF_ROOT:-$CWD}"
44
+ # The learning queue is the repository's, not the checkout's: in a linked worktree
45
+ # it is the main worktree's queue (D-LEDGER-MAIN-WORKTREE, resolve-project-root),
46
+ # the one the Learning agent drains into the shared ledger. Memory stays per checkout.
47
+ LEDGER_ROOT="${DF_LEDGER_ROOT:-$PROJECT_ROOT}"
48
+
49
+ # The machine-wide manifest (the memory/learning switches, D-FEATURES-NARROW-ONLY
50
+ # in queue-append) lives at the machine root,
51
+ # $HOME/.devflow (D-ONE-HOME).
52
+ DEVFLOW_MANIFEST="$HOME/.devflow/manifest.json"
53
+ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
54
+ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
55
+ LEARNING_DIR="$LEDGER_ROOT/.devflow/learning"
52
56
 
53
57
  if [ -z "$PROMPT" ]; then
54
58
  dbg "EXIT: empty PROMPT"
@@ -57,8 +61,10 @@ fi
57
61
 
58
62
  source "$SCRIPT_DIR/queue-append" || { echo "capture-prompt: failed to source queue-append" >&2; exit 1; }
59
63
 
60
- # --- AC-P1: exactly ONE gate-read fork for memory + learning (machine-wide) ---
61
- queue_read_gates "$DEVFLOW_MANIFEST"
64
+ # --- AC-P1: at most ONE gate-read fork for memory + learning ---
65
+ # The machine switch, narrowed by this checkout's project.json/config.json
66
+ # `features` (D-FEATURES-NARROW-ONLY, see queue-append).
67
+ queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
62
68
  MEMORY_ENABLED="$_QG_MEMORY"
63
69
  LEARNING_ENABLED="$_QG_LEARNING"
64
70
 
@@ -58,16 +58,20 @@ if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi
58
58
  devflow_debug_set_cwd "$CWD"
59
59
 
60
60
  source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
61
- PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
62
- [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
63
-
64
- # The machine-wide manifest (the memory/learning switches, D-FEATURES-MACHINE-WIDE
65
- # in queue-append) is user-scope: resolve it from the inherited DEVFLOW_DIR (or
66
- # ~/.devflow) BEFORE the project-scoped assignment below shadows that value.
67
- DEVFLOW_MANIFEST="${DEVFLOW_DIR:-$HOME/.devflow}/manifest.json"
68
- DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
69
- MEMORY_DIR="$DEVFLOW_DIR/memory"
70
- LEARNING_DIR="$DEVFLOW_DIR/learning"
61
+ df_resolve_roots "$CWD" 2>/dev/null || true
62
+ PROJECT_ROOT="${DF_ROOT:-$CWD}"
63
+ # The learning queue is the repository's, not the checkout's: in a linked worktree
64
+ # it is the main worktree's queue (D-LEDGER-MAIN-WORKTREE, resolve-project-root),
65
+ # the one the Learning agent drains into the shared ledger. Memory stays per checkout.
66
+ LEDGER_ROOT="${DF_LEDGER_ROOT:-$PROJECT_ROOT}"
67
+
68
+ # The machine-wide manifest (the memory/learning switches, D-FEATURES-NARROW-ONLY
69
+ # in queue-append) lives at the machine root,
70
+ # $HOME/.devflow (D-ONE-HOME).
71
+ DEVFLOW_MANIFEST="$HOME/.devflow/manifest.json"
72
+ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
73
+ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
74
+ LEARNING_DIR="$LEDGER_ROOT/.devflow/learning"
71
75
 
72
76
  # --- Parse questions + answers into "question<TAB>answer" rows (one subprocess) ---
73
77
  # Defensive against: tool_response absent or a plain string (error case), missing
@@ -119,8 +123,10 @@ fi
119
123
 
120
124
  source "$SCRIPT_DIR/queue-append" || { echo "capture-question: failed to source queue-append" >&2; exit 1; }
121
125
 
122
- # --- AC-P1: exactly ONE gate-read fork for memory + learning (machine-wide) ---
123
- queue_read_gates "$DEVFLOW_MANIFEST"
126
+ # --- AC-P1: at most ONE gate-read fork for memory + learning ---
127
+ # The machine switch, narrowed by this checkout's project.json/config.json
128
+ # `features` (D-FEATURES-NARROW-ONLY, see queue-append).
129
+ queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
124
130
  MEMORY_ENABLED="$_QG_MEMORY"
125
131
  LEARNING_ENABLED="$_QG_LEARNING"
126
132
 
@@ -5,9 +5,9 @@
5
5
  # learning queue, each independently gated by its own feature flag (AC-F4). Runs
6
6
  # the decisions usage scanner (D29 grep-first) regardless of which queue is
7
7
  # gated on/off -- memory-disabled projects still run the usage scanner. The
8
- # 120s-throttle/nohup-spawn block lives in memory-worker, registered
9
- # separately in the Stop hook array AFTER this hook so append-before-spawn
10
- # ordering is preserved by array position, not by anything in this script.
8
+ # 120s-throttle/nohup-spawn block lives in memory-worker, a separate Stop
9
+ # hook that Claude Code runs in parallel with this one; the worker tolerates a
10
+ # queue this hook has not appended to yet (D-QUEUE-NO-ORPHAN-DELETE).
11
11
  # This hook never spawns a process (AC-F5).
12
12
 
13
13
  # Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced.
@@ -45,16 +45,20 @@ dbg "ASSISTANT_MSG length=${#ASSISTANT_MSG}"
45
45
  # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this
46
46
  # hook runs with a CWD inside .devflow/...). Empty → fall back to CWD.
47
47
  source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
48
- PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
49
- [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
50
-
51
- # The machine-wide manifest (the memory/learning switches, D-FEATURES-MACHINE-WIDE
52
- # in queue-append) is user-scope: resolve it from the inherited DEVFLOW_DIR (or
53
- # ~/.devflow) BEFORE the project-scoped assignment below shadows that value.
54
- DEVFLOW_MANIFEST="${DEVFLOW_DIR:-$HOME/.devflow}/manifest.json"
55
- DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
56
- MEMORY_DIR="$DEVFLOW_DIR/memory"
57
- LEARNING_DIR="$DEVFLOW_DIR/learning"
48
+ df_resolve_roots "$CWD" 2>/dev/null || true
49
+ PROJECT_ROOT="${DF_ROOT:-$CWD}"
50
+ # The learning queue is the repository's, not the checkout's: in a linked worktree
51
+ # it is the main worktree's queue (D-LEDGER-MAIN-WORKTREE, resolve-project-root),
52
+ # the one the Learning agent drains into the shared ledger. Memory stays per checkout.
53
+ LEDGER_ROOT="${DF_LEDGER_ROOT:-$PROJECT_ROOT}"
54
+
55
+ # The machine-wide manifest (the memory/learning switches, D-FEATURES-NARROW-ONLY
56
+ # in queue-append) lives at the machine root,
57
+ # $HOME/.devflow (D-ONE-HOME).
58
+ DEVFLOW_MANIFEST="$HOME/.devflow/manifest.json"
59
+ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
60
+ MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
61
+ LEARNING_DIR="$LEDGER_ROOT/.devflow/learning"
58
62
 
59
63
  # Skip if empty response
60
64
  if [ -z "$ASSISTANT_MSG" ]; then
@@ -64,8 +68,10 @@ fi
64
68
 
65
69
  source "$SCRIPT_DIR/queue-append" || { echo "capture-turn: failed to source queue-append" >&2; exit 1; }
66
70
 
67
- # --- AC-P1: exactly ONE gate-read fork for memory + learning (machine-wide) ---
68
- queue_read_gates "$DEVFLOW_MANIFEST"
71
+ # --- AC-P1: at most ONE gate-read fork for memory + learning ---
72
+ # The machine switch, narrowed by this checkout's project.json/config.json
73
+ # `features` (D-FEATURES-NARROW-ONLY, see queue-append).
74
+ queue_read_gates "$DEVFLOW_MANIFEST" "$PROJECT_ROOT"
69
75
  MEMORY_ENABLED="$_QG_MEMORY"
70
76
  LEARNING_ENABLED="$_QG_LEARNING"
71
77
 
@@ -73,11 +79,15 @@ dbg "MEMORY_ENABLED=$MEMORY_ENABLED LEARNING_ENABLED=$LEARNING_ENABLED"
73
79
 
74
80
  # --- Decisions usage scanner (independent of the memory/learning queue gates below) ---
75
81
  # D29: Grep-first reorder -- cheap in-process citation check gates the scanner call.
82
+ # The scanner writes the ledger's usage file, and it runs before ensure-devflow-init,
83
+ # so it takes the D-HOOKS-GIT-ONLY gate itself (df_is_project_root, git-marker via
84
+ # resolve-project-root; zero forks): a `.devflow/` an older devflow left in a
85
+ # non-git directory, or at HOME, is not a project's ledger.
76
86
  SCANNER="$SCRIPT_DIR/decisions-usage-scan.cjs"
77
87
  if [ -f "$SCANNER" ] && printf '%s' "$ASSISTANT_MSG" | grep -qE 'ADR-[0-9]+|PF-[0-9]+'; then
78
- if [ "$LEARNING_ENABLED" = "true" ]; then
88
+ if [ "$LEARNING_ENABLED" = "true" ] && df_is_project_root "$PROJECT_ROOT" 2>/dev/null; then
79
89
  dbg "Running decisions usage scanner"
80
- printf '%s' "$ASSISTANT_MSG" | node "$SCANNER" --cwd "$PROJECT_ROOT" 2>/dev/null || true
90
+ printf '%s' "$ASSISTANT_MSG" | node "$SCANNER" --cwd "$LEDGER_ROOT" 2>/dev/null || true
81
91
  fi
82
92
  fi
83
93
 
@@ -61,12 +61,17 @@ devflow_debug_init() {
61
61
  devflow_debug_set_cwd() {
62
62
  local cwd="$1"
63
63
  if [ -z "$cwd" ] || [ "${DEVFLOW_HOOK_DEBUG:-}" != "1" ]; then return; fi
64
- # Phase 2: switch to per-project log (only when debug is active)
65
- local slug
66
- slug=$(echo "$cwd" | sed 's|^/||' | tr '/' '-')
67
- local project_log_dir="$HOME/.devflow/logs/$slug"
68
- mkdir -p "$project_log_dir" 2>/dev/null || true
69
- chmod 700 "$project_log_dir" 2>/dev/null || true
64
+ # Phase 2: switch to per-project log (only when debug is active). The folder
65
+ # comes from log-paths' devflow_log_dir, so a folder created here goes through
66
+ # the same D-LOG-DIR-CAP prune as every other hook log folder. log-paths sits
67
+ # beside this file; a hook that already sourced it is not re-sourced (that
68
+ # would reset its cached log dir).
69
+ if ! declare -F devflow_log_dir >/dev/null 2>&1; then
70
+ source "$(dirname "${BASH_SOURCE[0]}")/log-paths" 2>/dev/null || return 0
71
+ fi
72
+ local project_log_dir
73
+ project_log_dir=$(devflow_log_dir "$cwd" 2>/dev/null) || project_log_dir=""
74
+ [ -n "$project_log_dir" ] || return 0
70
75
  _DEVFLOW_DBG_LOG="$project_log_dir/.hook-debug.log"
71
76
  _devflow_dbg_size_guard "$_DEVFLOW_DBG_LOG"
72
77
  dbg() {
@@ -2,6 +2,7 @@
2
2
  # Ensures .devflow/ and all subdirectories exist and the project root .gitignore
3
3
  # ignores .devflow/. Called from capture-prompt, capture-turn, capture-question,
4
4
  # memory-worker, and pre-compact-memory. Idempotent.
5
+ # Returns non-zero, having written nothing, outside a git project or at HOME.
5
6
  # Usage: source ensure-devflow-init "$CWD"
6
7
 
7
8
  [ -z "$1" ] && return 1
@@ -11,23 +12,49 @@
11
12
  # by a hook or directly by tests). Empty → fall back to "$1" (old behavior).
12
13
  _EDI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
14
  source "$_EDI_DIR/resolve-project-root" 2>/dev/null || true
14
- _EDI_ROOT="$(df_resolve_root "$1" 2>/dev/null || true)"
15
- [ -n "$_EDI_ROOT" ] || _EDI_ROOT="$1"
15
+ df_resolve_roots "$1" 2>/dev/null || true
16
+ _EDI_ROOT="${DF_ROOT:-$1}"
17
+ _EDI_LEDGER="${DF_LEDGER_ROOT:-$_EDI_ROOT}"
18
+
19
+ # D-HOOKS-GIT-ONLY (git-marker): scaffold nothing outside a git project, and
20
+ # nothing at a repository rooted at HOME. D-HOOKS-TOPLEVEL-ONLY (git-marker): nor
21
+ # below a checkout's toplevel — when git refuses the cwd (dubious ownership,
22
+ # GIT_CEILING_DIRECTORIES) the root above is that cwd, and a subdirectory holds no
23
+ # `.git` of its own, so it is refused rather than scaffolded. This is the one gate every capture and
24
+ # memory caller (capture-prompt, capture-turn, capture-question, memory-worker,
25
+ # pre-compact-memory) passes through before it writes, and each of them treats a
26
+ # non-zero return as "stop" (`|| exit 0`), so memory and learning stop together
27
+ # in a non-git directory. ensure-root-gitignore itself stays ungated: it is a
28
+ # pure writer its parity suite runs in plain directories (PF-059), and both of
29
+ # its callers now decide before reaching it. A helper that failed to source is a
30
+ # command not found — the refusing branch, so the failure mode writes nothing.
31
+ source "$_EDI_DIR/git-marker" 2>/dev/null || true
32
+ df_is_project_root "$_EDI_ROOT" 2>/dev/null || return 1
16
33
 
17
34
  _DEVFLOW_DIR="$_EDI_ROOT/.devflow"
18
35
 
19
- # Fast-path: if all subdirectories already exist, skip mkdir and gitignore setup
36
+ # learning/ is scaffolded only where the ledger lives. In a linked worktree whose
37
+ # main checkout holds the ledger (D-LEDGER-MAIN-WORKTREE, resolve-project-root) a
38
+ # learning/ here would be an empty directory nothing writes to; the capture hooks
39
+ # create the ledger's own learning/ at the ledger root when they append to it.
40
+ _EDI_LEARNING="$_DEVFLOW_DIR/learning"
41
+ [ "$_EDI_LEDGER" = "$_EDI_ROOT" ] || _EDI_LEARNING=""
42
+
43
+ # Fast-path: if all subdirectories already exist, skip mkdir and gitignore setup.
44
+ # The marker is the CURRENT carve-out version (D-GITIGNORE-V6), bumped with both
45
+ # writers' stamps, so a v5-stamped project misses this once and gains the
46
+ # `!.devflow/project.json` line.
20
47
  if [ -d "$_DEVFLOW_DIR/memory" ] && [ -d "$_DEVFLOW_DIR/docs" ] && \
21
- [ -d "$_DEVFLOW_DIR/learning" ] && \
48
+ { [ -z "$_EDI_LEARNING" ] || [ -d "$_EDI_LEARNING" ]; } && \
22
49
  [ -d "$_DEVFLOW_DIR/features" ] && \
23
- [ -f "$_DEVFLOW_DIR/.root-gitignore-configured-v5" ]; then
50
+ [ -f "$_DEVFLOW_DIR/.root-gitignore-configured-v6" ]; then
24
51
  return 0
25
52
  fi
26
53
 
27
54
  # Create all subdirectories
28
55
  mkdir -p \
29
56
  "$_DEVFLOW_DIR/memory" \
30
- "$_DEVFLOW_DIR/learning" \
57
+ ${_EDI_LEARNING:+"$_EDI_LEARNING"} \
31
58
  "$_DEVFLOW_DIR/features" \
32
59
  "$_DEVFLOW_DIR/docs" \
33
60
  2>/dev/null || return 1
@@ -3,7 +3,7 @@
3
3
  # ensure-proxy — SessionStart + UserPromptSubmit hook
4
4
  # Ensures the Devflow proxy relay is running when external model routing is enabled.
5
5
  # NOT git-gated (proxy is a global user-scope feature, not per-project).
6
- # NOT project-scoped: proxy state lives at $DEVFLOW_DIR/proxy.json (user-scope).
6
+ # NOT project-scoped: proxy state lives at $HOME/.devflow/proxy.json (machine-wide).
7
7
  #
8
8
  # SessionStart: probe port → if DOWN attempt spawn → inject additionalContext warning if still down
9
9
  # UserPromptSubmit: fast exit (both port-up and port-down) — silent; SessionStart handles all warnings
@@ -55,9 +55,10 @@ if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
55
55
  fi
56
56
 
57
57
  # ── Proxy state ────────────────────────────────────────────────────────────────
58
- # Proxy is user-scope (global), not project-scoped. Use $DEVFLOW_DIR (env) or default.
59
- DEVFLOW_DIR="${DEVFLOW_DIR:-$HOME/.devflow}"
60
- PROXY_STATE_FILE="$DEVFLOW_DIR/proxy.json"
58
+ # Proxy is machine-wide, not project-scoped: state lives at the machine root,
59
+ # always $HOME/.devflow (D-ONE-HOME).
60
+ MACHINE_DEVFLOW_DIR="$HOME/.devflow"
61
+ PROXY_STATE_FILE="$MACHINE_DEVFLOW_DIR/proxy.json"
61
62
 
62
63
  if [ ! -f "$PROXY_STATE_FILE" ]; then
63
64
  dbg "EXIT: no proxy state file"
@@ -93,7 +94,7 @@ if [ "${DEVFLOW_HOOK_DEBUG:-}" = "1" ]; then
93
94
  fi
94
95
 
95
96
  # ── Log setup (SessionStart only) ──────────────────────────────────────────────
96
- LOG_DIR="$DEVFLOW_DIR/logs"
97
+ LOG_DIR="$MACHINE_DEVFLOW_DIR/logs"
97
98
  mkdir -p "$LOG_DIR" 2>/dev/null || true
98
99
  # SEC-2: harden log directory to 0700 (best-effort; pre-existing dirs may be wider).
99
100
  chmod 700 "$LOG_DIR" 2>/dev/null || true
@@ -101,7 +102,7 @@ LOG_FILE="$LOG_DIR/proxy.log"
101
102
 
102
103
  # Size guard: 2MB max → truncate to 1MB tail (matches hook-log-init guard pattern).
103
104
  # Sharing hook-log-init is not feasible here: hook-log-init requires $CWD and targets
104
- # the per-project log path; ensure-proxy uses the user-scope $DEVFLOW_DIR/logs path.
105
+ # the per-project log path; ensure-proxy uses the machine-wide $MACHINE_DEVFLOW_DIR/logs path.
105
106
  # The existence guard is required: wc -c uses a shell redirect whose failure is emitted
106
107
  # by bash itself (before wc starts), bypassing 2>/dev/null.
107
108
  _LOG_MAX_BYTES=2097152
@@ -268,7 +269,7 @@ fi
268
269
  source "$SCRIPT_DIR/get-mtime" 2>/dev/null || true
269
270
  source "$SCRIPT_DIR/learning-lock" 2>/dev/null || true
270
271
 
271
- SPAWN_LOCK="$DEVFLOW_DIR/.proxy-spawn.lock"
272
+ SPAWN_LOCK="$MACHINE_DEVFLOW_DIR/.proxy-spawn.lock"
272
273
 
273
274
  if ! learning_lock_acquire "$SPAWN_LOCK" 2 2>/dev/null; then
274
275
  # Another session is racing to start the proxy — give it a moment then re-probe
@@ -324,7 +325,7 @@ log "relay spawned with pid $_RELAY_PID"
324
325
  # Best-effort pid record for `devflow proxy --status` — mirrors the CLI enable path.
325
326
  # Written unconditionally after spawn (like the CLI): a stale pid from a relay that
326
327
  # never came up is harmless, since --status liveness-checks it before display.
327
- printf '%s' "$_RELAY_PID" > "$DEVFLOW_DIR/proxy.pid" 2>/dev/null || \
328
+ printf '%s' "$_RELAY_PID" > "$MACHINE_DEVFLOW_DIR/proxy.pid" 2>/dev/null || \
328
329
  log "warn: could not write proxy.pid (non-fatal)"
329
330
 
330
331
  # Bounded wait: 80×0.1s = 8s maximum (well within 15s hook timeout)
@@ -3,13 +3,16 @@
3
3
  # rules that govern .devflow/.
4
4
  #
5
5
  # .devflow/ holds per-developer runtime state (memory, learning, docs, locks) —
6
- # local by default. THREE exceptions are shared via git:
6
+ # local by default. FOUR exceptions are shared via git:
7
7
  # 1. Feature knowledge bases under .devflow/features/: index.md and every
8
8
  # {slug}/KNOWLEDGE.md (tracked, committed by the Knowledge agent at workflow end).
9
9
  # 2. .devflow/conventions.md — naming-convention authority written by the Git
10
10
  # learn-conventions operation; shared so the team uses one naming source.
11
- # 3. .devflow/policy.json — the team-owned evidence policy, read from the default
12
- # branch by resolve-evidence-policy.cjs. Devflow never writes it.
11
+ # 3. .devflow/policy.json — the retired evidence-policy file: never parsed, but
12
+ # where project.json has no `evidence` its presence holds the repository at
13
+ # required (D-POLICY-JSON-RETIRED). Devflow never writes it.
14
+ # 4. .devflow/project.json — the team-committed settings both resolvers read
15
+ # (D-GITIGNORE-V6). Devflow never writes it.
13
16
  #
14
17
  # Everything else under .devflow/ stays local. A user opts back out of the first two
15
18
  # by re-adding `.devflow/features/` or `.devflow/conventions.md` to their own .gitignore.
@@ -25,17 +28,29 @@
25
28
  # Both reach this one writer so the rule is applied identically everywhere; this
26
29
  # decouples git-tracking of .devflow/ from any single feature toggle (avoids PF-014).
27
30
  #
28
- # Idempotent and O(1) after the first run via the .root-gitignore-configured-v5
31
+ # Idempotent and O(1) after the first run via the .root-gitignore-configured-v6
29
32
  # marker under .devflow/ (project-local). The marker is a claim, not proof, so the
30
- # fast path also requires the block's own sentinel, the policy line AND a
31
- # .claudeignore entry to be present; a .gitignore that lost any of them (a merge
32
- # resolution, a hand edit) is healed on the next run. A v4-marked project misses the
33
- # fast path, gains the policy line and is re-stamped v5.
33
+ # fast path also requires the block's own sentinel, the policy line, the project
34
+ # line AND a .claudeignore entry to be present; a .gitignore that lost any of them (a
35
+ # merge resolution, a hand edit) is healed on the next run. A v5-marked project
36
+ # misses the fast path, gains the project line just before its .claudeignore line
37
+ # and is re-stamped v6 (D-GITIGNORE-V6); a v4-marked one gains the policy and
38
+ # project lines.
34
39
  #
35
- # D-GITIGNORE-V5: the block is detected ONLY by its devflow-unique sentinel
36
- # `!.devflow/conventions.md` — NEVER by `.claudeignore` or `!.devflow/policy.json`,
37
- # which users legitimately author themselves. Those two are completion lines: each is
38
- # topped up when missing, in block order (policy, then .claudeignore). Keying presence
40
+ # D-GITIGNORE-IN-BLOCK: a line topped up into an existing block goes INSIDE the
41
+ # block, where a fresh block holds it, never at the end of the file. gitignore is
42
+ # last-match-wins, so a `!.devflow/project.json` appended after a user's own later
43
+ # `.devflow/project.json` would silently override their re-ignore. The missing
44
+ # lines are inserted as one run, in block order, right after the block sentinel
45
+ # and the block lines that precede the first missing one in a fresh block (at most
46
+ # three: the sentinel, the policy line, the project line). Every other byte of the
47
+ # file is kept.
48
+ #
49
+ # D-GITIGNORE-V5 / D-GITIGNORE-V6: the block is detected ONLY by its devflow-unique
50
+ # sentinel `!.devflow/conventions.md` — NEVER by `.claudeignore`,
51
+ # `!.devflow/policy.json` or `!.devflow/project.json`, which users legitimately author
52
+ # themselves. Those three are completion lines: each is topped up when missing, in
53
+ # block order (policy, project, then .claudeignore). Keying presence
39
54
  # off a user-authored line inverts both halves of the contract: projects that already
40
55
  # carry that line never receive the carve-out (so .devflow/ runtime files leak into
41
56
  # git), and re-appending `.claudeignore` after a user's `!.claudeignore` reverses
@@ -50,7 +65,7 @@
50
65
  [ -z "$1" ] && return 1
51
66
 
52
67
  _ERG_DEVFLOW_DIR="$1/.devflow"
53
- _ERG_MARKER="$_ERG_DEVFLOW_DIR/.root-gitignore-configured-v5"
68
+ _ERG_MARKER="$_ERG_DEVFLOW_DIR/.root-gitignore-configured-v6"
54
69
  _ERG_GITIGNORE="$1/.gitignore"
55
70
 
56
71
  # Whole-line, whitespace-tolerant matchers. `*` and `.` are escaped so the ERE
@@ -60,9 +75,10 @@ _ERG_RE_LEGACY='^[[:space:]]*\.devflow/[[:space:]]*$'
60
75
  _ERG_RE_SENTINEL='^[[:space:]]*!\.devflow/conventions\.md[[:space:]]*$'
61
76
  _ERG_RE_SENTINEL_V2='^[[:space:]]*!\.devflow/features/\*/KNOWLEDGE\.md[[:space:]]*$'
62
77
  _ERG_RE_POLICY='^[[:space:]]*!\.devflow/policy\.json[[:space:]]*$'
78
+ _ERG_RE_PROJECT='^[[:space:]]*!\.devflow/project\.json[[:space:]]*$'
63
79
  _ERG_RE_CLAUDEIGNORE='^[[:space:]]*!?\.claudeignore[[:space:]]*$'
64
80
 
65
- # Drop every legacy marker beside v5 — builtin `[ -e ]` tests, so rm forks only for a
81
+ # Drop every legacy marker beside v6 — builtin `[ -e ]` tests, so rm forks only for a
66
82
  # marker that exists. Called from the fast path and from the on-success stamp below,
67
83
  # so an older devflow's marker is dropped whichever path a run takes.
68
84
  # Always returns 0 (never fails the caller under `set -e`): the last iteration's
@@ -70,7 +86,7 @@ _ERG_RE_CLAUDEIGNORE='^[[:space:]]*!?\.claudeignore[[:space:]]*$'
70
86
  # and a function call's own exit status is NOT exempt from errexit the way an inlined
71
87
  # for-loop's is — unlike this same loop when it sat directly inside the caller's `{ }`.
72
88
  _erg_drop_legacy_markers() {
73
- for _ERG_OLD in -v4 -v3 -v2 ''; do
89
+ for _ERG_OLD in -v5 -v4 -v3 -v2 ''; do
74
90
  [ -e "$_ERG_DEVFLOW_DIR/.root-gitignore-configured$_ERG_OLD" ] \
75
91
  && rm -f "$_ERG_DEVFLOW_DIR/.root-gitignore-configured$_ERG_OLD" 2>/dev/null
76
92
  done
@@ -78,11 +94,12 @@ _erg_drop_legacy_markers() {
78
94
  }
79
95
 
80
96
  # Fast path: converged only when the marker is stamped AND the block sentinel is
81
- # present AND the policy line is present AND a .claudeignore entry exists. Every
82
- # other state falls through and recomputes.
97
+ # present AND the policy and project lines are present AND a .claudeignore entry
98
+ # exists. Every other state falls through and recomputes.
83
99
  if [ -f "$_ERG_MARKER" ] \
84
100
  && grep -qE "$_ERG_RE_SENTINEL" "$_ERG_GITIGNORE" 2>/dev/null \
85
101
  && grep -qE "$_ERG_RE_POLICY" "$_ERG_GITIGNORE" 2>/dev/null \
102
+ && grep -qE "$_ERG_RE_PROJECT" "$_ERG_GITIGNORE" 2>/dev/null \
86
103
  && grep -qE "$_ERG_RE_CLAUDEIGNORE" "$_ERG_GITIGNORE" 2>/dev/null; then
87
104
  _erg_drop_legacy_markers
88
105
  return 0
@@ -100,9 +117,10 @@ mkdir -p "$_ERG_DEVFLOW_DIR" 2>/dev/null || return 1
100
117
  printf -v _ERG_BLOCK_NO_CI '%s\n' \
101
118
  '# Devflow runtime data — local by default (memory, learning, docs, locks).' \
102
119
  '# Shared via git: feature knowledge bases under .devflow/features/ (index.md and' \
103
- '# every {slug}/KNOWLEDGE.md), .devflow/conventions.md (naming authority) and' \
104
- '# .devflow/policy.json (evidence policy). To stop sharing the first two, re-add' \
105
- '# `.devflow/features/` or `.devflow/conventions.md` to your own .gitignore.' \
120
+ '# every {slug}/KNOWLEDGE.md), .devflow/conventions.md (naming authority),' \
121
+ '# .devflow/policy.json (retired; presence only) and .devflow/project.json (team settings).' \
122
+ '# To stop sharing the first two, re-add `.devflow/features/` or' \
123
+ '# `.devflow/conventions.md` to your own .gitignore.' \
106
124
  '.devflow/*' \
107
125
  '!.devflow/features/' \
108
126
  '.devflow/features/*' \
@@ -111,33 +129,55 @@ printf -v _ERG_BLOCK_NO_CI '%s\n' \
111
129
  '.devflow/features/*/*' \
112
130
  '!.devflow/features/*/KNOWLEDGE.md' \
113
131
  '!.devflow/conventions.md' \
114
- '!.devflow/policy.json'
132
+ '!.devflow/policy.json' \
133
+ '!.devflow/project.json'
115
134
  printf -v _ERG_LINE_SENTINEL '%s\n' '!.devflow/conventions.md'
116
135
  printf -v _ERG_LINE_POLICY '%s\n' '!.devflow/policy.json'
136
+ printf -v _ERG_LINE_PROJECT '%s\n' '!.devflow/project.json'
117
137
  printf -v _ERG_LINE_CLAUDEIGNORE '%s\n' '.claudeignore'
118
138
  _ERG_BLOCK="$_ERG_BLOCK_NO_CI$_ERG_LINE_CLAUDEIGNORE"
119
139
 
120
- # Does the file already carry the policy line, or a .claudeignore entry of the user's
121
- # own (either form)? Computed BEFORE the legacy filter below, which rewrites the file.
140
+ # Does the file already carry the policy line, the project line, or a .claudeignore
141
+ # entry of the user's own (either form)? Computed BEFORE the legacy filter below,
142
+ # which rewrites the file.
122
143
  _ERG_HAS_POLICY=0
123
144
  grep -qE "$_ERG_RE_POLICY" "$_ERG_GITIGNORE" 2>/dev/null && _ERG_HAS_POLICY=1
145
+ _ERG_HAS_PROJECT=0
146
+ grep -qE "$_ERG_RE_PROJECT" "$_ERG_GITIGNORE" 2>/dev/null && _ERG_HAS_PROJECT=1
124
147
  _ERG_HAS_CI=0
125
148
  grep -qE "$_ERG_RE_CLAUDEIGNORE" "$_ERG_GITIGNORE" 2>/dev/null && _ERG_HAS_CI=1
126
149
 
127
- # The completion lines this file lacks, in block order (policy, then .claudeignore).
128
- # Mirrors missingCompletionLines in the TS twin.
150
+ # The completion lines this file lacks, in block order (policy, project, then
151
+ # .claudeignore). Mirrors missingCompletionLines in the TS twin.
129
152
  _ERG_COMPLETION=''
130
153
  if [ "$_ERG_HAS_POLICY" = 0 ]; then
131
154
  _ERG_COMPLETION="$_ERG_LINE_POLICY"
132
155
  fi
156
+ if [ "$_ERG_HAS_PROJECT" = 0 ]; then
157
+ _ERG_COMPLETION="$_ERG_COMPLETION$_ERG_LINE_PROJECT"
158
+ fi
133
159
  if [ "$_ERG_HAS_CI" = 0 ]; then
134
160
  _ERG_COMPLETION="$_ERG_COMPLETION$_ERG_LINE_CLAUDEIGNORE"
135
161
  fi
136
162
 
163
+ # The block lines a top-up run is inserted after: the sentinel, then whichever of
164
+ # the policy and project lines precede the first missing line in a fresh block.
165
+ # Mirrors blockRunBefore in the TS twin.
166
+ if [ "$_ERG_HAS_POLICY" = 0 ]; then
167
+ _ERG_RUN_RE='^[[:space:]]*(!\.devflow/conventions\.md)[[:space:]]*$'
168
+ elif [ "$_ERG_HAS_PROJECT" = 0 ]; then
169
+ _ERG_RUN_RE='^[[:space:]]*(!\.devflow/conventions\.md|!\.devflow/policy\.json)[[:space:]]*$'
170
+ else
171
+ _ERG_RUN_RE='^[[:space:]]*(!\.devflow/conventions\.md|!\.devflow/policy\.json|!\.devflow/project\.json)[[:space:]]*$'
172
+ fi
173
+
137
174
  # Classify: noop (nothing to do) | continue (extend an existing block) |
138
175
  # block (install a fresh block) | fail (a rewrite failed; leave the file alone).
176
+ # continue inserts _ERG_APPEND after the first line matching _ERG_ANCHOR_RE and the
177
+ # run of lines matching _ERG_RUN_RE right after it (empty: no run).
139
178
  _ERG_MODE=noop
140
179
  _ERG_APPEND=''
180
+ _ERG_ANCHOR_RE=''
141
181
  if grep -qE "$_ERG_RE_OPTOUT" "$_ERG_GITIGNORE" 2>/dev/null; then
142
182
  # User-authored `/.devflow/` (leading slash) — respect it; don't force the carve-out.
143
183
  # Checked FIRST so a file containing both it and a sentinel keeps the user's entry.
@@ -147,11 +187,15 @@ elif grep -qE "$_ERG_RE_SENTINEL" "$_ERG_GITIGNORE" 2>/dev/null; then
147
187
  if [ -n "$_ERG_COMPLETION" ]; then
148
188
  _ERG_MODE=continue
149
189
  _ERG_APPEND="$_ERG_COMPLETION"
190
+ _ERG_ANCHOR_RE="$_ERG_RE_SENTINEL"
150
191
  fi
151
192
  elif grep -qE "$_ERG_RE_SENTINEL_V2" "$_ERG_GITIGNORE" 2>/dev/null; then
152
- # v2 block installed — append the lines it lacks, in block order.
193
+ # v2 block installed — insert the lines it lacks, in block order, right after
194
+ # its sentinel, the last carve-out line it has.
153
195
  _ERG_MODE=continue
154
196
  _ERG_APPEND="$_ERG_LINE_SENTINEL$_ERG_COMPLETION"
197
+ _ERG_ANCHOR_RE="$_ERG_RE_SENTINEL_V2"
198
+ _ERG_RUN_RE=''
155
199
  else
156
200
  # No devflow block — install one, omitting the .claudeignore line the user owns.
157
201
  _ERG_MODE=block
@@ -180,21 +224,52 @@ else
180
224
  fi
181
225
  fi
182
226
 
183
- # The two append shapes, mirrored byte-for-byte by the TS twin's appendLines and
184
- # appendBlock. `tail -c 1` yields the empty string when the file already ends in a
185
- # newline (command substitution strips it), so a file whose last byte is not a
186
- # newline gets one before the appended text fuses onto its last line. Existing
187
- # trailing newlines are preserved verbatim — no trimming, no blank-line dedupe.
227
+ # _erg_insert_in_block <anchor-ERE> <run-ERE or ''> <text> — rewrite the file with
228
+ # <text> inserted after the first line matching <anchor-ERE> and the run of up to
229
+ # three lines matching <run-ERE> right after it (D-GITIGNORE-IN-BLOCK). Mirrors
230
+ # insertInBlock in the TS twin byte for byte: head and tail copy every other byte
231
+ # unchanged, and when the run ends on a last line with no newline, one is added
232
+ # before <text>. Each read ends in a trailing `x`, so command substitution cannot
233
+ # strip the file's own trailing newlines. The file is rewritten in place with one
234
+ # `>`, as the TS twin's writeFile does, so a symlinked .gitignore keeps its link.
235
+ # Returns non-zero, having written nothing, when the anchor line cannot be found.
236
+ _erg_insert_in_block() {
237
+ local _anchor="$1" _run="$2" _text="$3" _hit="" _n="" _next="" _line="" _i=0 _head="" _tail=""
238
+ _hit="$(grep -n -m 1 -E "$_anchor" "$_ERG_GITIGNORE" 2>/dev/null)" || return 1
239
+ _n="${_hit%%:*}"
240
+ case "$_n" in ''|*[!0-9]*) return 1 ;; esac
241
+ if [ -n "$_run" ]; then
242
+ _next="$(sed -n "$((_n + 1)),$((_n + 3))p" "$_ERG_GITIGNORE" 2>/dev/null)"
243
+ while [ "$_i" -lt 3 ] && [ -n "$_next" ]; do
244
+ _line="${_next%%$'\n'*}"
245
+ if [ "$_line" = "$_next" ]; then _next=''; else _next="${_next#*$'\n'}"; fi
246
+ [[ $_line =~ $_run ]] || break
247
+ _n=$((_n + 1))
248
+ _i=$((_i + 1))
249
+ done
250
+ fi
251
+ # The `x` is printed only when the read succeeded, so a failed read leaves none
252
+ # and nothing is written — never a truncated file.
253
+ _head="$(head -n "$_n" "$_ERG_GITIGNORE" 2>/dev/null && printf x)"
254
+ case "$_head" in *x) _head="${_head%x}" ;; *) return 1 ;; esac
255
+ _tail="$(tail -n "+$((_n + 1))" "$_ERG_GITIGNORE" 2>/dev/null && printf x)"
256
+ case "$_tail" in *x) _tail="${_tail%x}" ;; *) return 1 ;; esac
257
+ case "$_head" in *$'\n') ;; *) _head="$_head"$'\n' ;; esac
258
+ printf '%s%s%s' "$_head" "$_text" "$_tail" > "$_ERG_GITIGNORE"
259
+ }
260
+
261
+ # The fresh-block append shape, mirrored byte-for-byte by the TS twin's appendBlock.
262
+ # `tail -c 1` yields the empty string when the file already ends in a newline
263
+ # (command substitution strips it), so a file whose last byte is not a newline gets
264
+ # one before the appended text fuses onto its last line. Existing trailing newlines
265
+ # are preserved verbatim — no trimming, no blank-line dedupe.
188
266
  _ERG_OK=0
189
267
  case "$_ERG_MODE" in
190
268
  noop)
191
269
  _ERG_OK=1
192
270
  ;;
193
271
  continue)
194
- if [ -n "$(tail -c 1 "$_ERG_GITIGNORE" 2>/dev/null)" ]; then
195
- printf '\n' >> "$_ERG_GITIGNORE"
196
- fi
197
- printf '%s' "$_ERG_APPEND" >> "$_ERG_GITIGNORE" && _ERG_OK=1
272
+ _erg_insert_in_block "$_ERG_ANCHOR_RE" "$_ERG_RUN_RE" "$_ERG_APPEND" && _ERG_OK=1
198
273
  ;;
199
274
  block)
200
275
  if [ ! -s "$_ERG_GITIGNORE" ]; then
@@ -209,7 +284,7 @@ case "$_ERG_MODE" in
209
284
  ;;
210
285
  esac
211
286
 
212
- # On success, stamp the current-format v5 marker and drop legacy markers.
287
+ # On success, stamp the current-format v6 marker and drop legacy markers.
213
288
  [ "$_ERG_OK" = 1 ] && {
214
289
  touch "$_ERG_MARKER"
215
290
  _erg_drop_legacy_markers
@@ -14,3 +14,51 @@ df_has_git_marker() {
14
14
  done
15
15
  return 1
16
16
  }
17
+
18
+ # df_is_project_root <root> — returns 0 when <root> may hold per-project devflow
19
+ # data: it is a checkout's toplevel — it holds its own `.git` entry — AND it is not
20
+ # the home directory.
21
+ #
22
+ # D-HOOKS-GIT-ONLY: the hooks that scaffold `.devflow/` and the root `.gitignore`
23
+ # carve-out — session-start-context and ensure-devflow-init, which every capture
24
+ # and memory hook reaches — run only for a git project. Outside one there is no
25
+ # repository for the carve-out to protect and no branch for memory to describe,
26
+ # and scaffolding there would leave `.devflow/` and a `.gitignore` block in `~`,
27
+ # Downloads or a folder of repos — wherever Claude Code happened to start.
28
+ # A repository rooted at HOME (a dotfiles repo) is refused too: its `.devflow` IS
29
+ # the machine root, so per-project data would land inside `~/.devflow` and its
30
+ # carve-out in the user's home `.gitignore`.
31
+ #
32
+ # D-HOOKS-TOPLEVEL-ONLY: the `.git` entry must sit AT <root>, not merely above it.
33
+ # Callers pass the root df_resolve_roots resolved, and when `git rev-parse` fails
34
+ # from inside a checkout — the repository has dubious ownership, or
35
+ # GIT_CEILING_DIRECTORIES stops git's search — that root is the raw cwd, possibly a
36
+ # subdirectory. An ancestor walk would still find the checkout's `.git` and admit
37
+ # it, and `.devflow/` plus a `.gitignore` block would land in that subdirectory.
38
+ # Requiring the marker at the root makes the gate and the resolver agree: git's
39
+ # toplevel always holds `.git` (a directory, or the file a linked worktree or
40
+ # submodule carries), a subdirectory never does. A directory that holds its own
41
+ # `.git` still passes when git cannot read it — the marker contract is the zero-fork
42
+ # one above, and writing there scatters nothing, because it is the checkout's root.
43
+ #
44
+ # The comparison is between PHYSICAL paths, because the two sides reach it by
45
+ # different routes: git reports a toplevel with every symlink resolved, while
46
+ # $HOME is whatever the login shell was handed — on macOS the temp tree is
47
+ # /var → /private/var, and a home directory can itself sit behind a symlink. A
48
+ # textual compare would miss exactly the case it exists for.
49
+ #
50
+ # Zero forks, like the marker walk above: a builtin `-e` test, then `cd -P`, which
51
+ # resolves a path physically and publishes it in $PWD — both shell builtins, so no
52
+ # `$(...)` subshell and no realpath/readlink binary is involved. The caller's directory is restored before
53
+ # returning. An unresolvable <root> is refused (fail closed: nothing is written);
54
+ # an unset or unresolvable HOME cannot equal a resolved root, so it refuses nothing.
55
+ df_is_project_root() {
56
+ local _root="$1" _back="$PWD" _root_real="" _home_real=""
57
+ [ -n "$_root" ] || return 1
58
+ [ -e "$_root/.git" ] || return 1
59
+ if cd -P "$_root" 2>/dev/null; then _root_real="$PWD"; fi
60
+ if [ -n "${HOME:-}" ] && cd -P "$HOME" 2>/dev/null; then _home_real="$PWD"; fi
61
+ cd "$_back" 2>/dev/null || true
62
+ [ -n "$_root_real" ] || return 1
63
+ [ "$_root_real" != "$_home_real" ]
64
+ }