devflow-kit 3.5.0 → 3.7.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 (146) hide show
  1. package/CHANGELOG.md +178 -1
  2. package/README.md +10 -6
  3. package/dist/agents/code.md +7 -11
  4. package/dist/agents/{diagnose.md → debug.md} +4 -4
  5. package/dist/agents/{code-fix.md → fix.md} +7 -8
  6. package/dist/agents/{scrutinize.md → harden.md} +6 -6
  7. package/dist/agents/{code-edit.md → refactor.md} +8 -9
  8. package/dist/agents/review.md +2 -0
  9. package/dist/cli/agents-view/render.js +1 -1
  10. package/dist/cli/commands/gates.js +276 -0
  11. package/dist/cli/commands/init-seed.js +9 -1
  12. package/dist/cli/commands/init.js +34 -11
  13. package/dist/cli/commands/learning.js +12 -12
  14. package/dist/cli/commands/legacy-hooks.js +1 -1
  15. package/dist/cli/commands/memory.js +18 -0
  16. package/dist/cli/commands/uninstall.js +111 -5
  17. package/dist/cli.js +2 -0
  18. package/dist/commands/bug-analysis.md +21 -17
  19. package/dist/commands/code-review.md +65 -66
  20. package/dist/commands/debug.md +17 -17
  21. package/dist/commands/dynamic-build.md +104 -116
  22. package/dist/commands/dynamic-plan.md +13 -11
  23. package/dist/commands/dynamic-profile.md +9 -7
  24. package/dist/commands/dynamic-tickets.md +13 -11
  25. package/dist/commands/explore.md +15 -15
  26. package/dist/commands/implement.md +80 -154
  27. package/dist/commands/plan.md +34 -66
  28. package/dist/commands/release.md +11 -7
  29. package/dist/commands/research.md +14 -10
  30. package/dist/commands/resolve.md +44 -88
  31. package/dist/commands/self-review.md +44 -44
  32. package/dist/core/agent-models.js +47 -11
  33. package/dist/core/{learning-tuning-config.js → curate-tuning-config.js} +14 -14
  34. package/dist/core/echo-value.js +42 -0
  35. package/dist/core/evidence-policy.js +17 -5
  36. package/dist/core/gate-level.js +213 -0
  37. package/dist/core/learning-queue-cleanup.js +1 -1
  38. package/dist/core/manifest.js +26 -10
  39. package/dist/core/migrations.js +63 -44
  40. package/dist/core/model-discovery.js +7 -1
  41. package/dist/core/plugins.js +14 -12
  42. package/dist/core/project-paths.js +3 -3
  43. package/dist/core/tracker.js +5 -33
  44. package/dist/hud/codex-usage.js +247 -0
  45. package/dist/hud/components/codex-quota.js +37 -0
  46. package/dist/hud/components/usage-quota.js +1 -1
  47. package/dist/hud/config.js +1 -0
  48. package/dist/hud/index.js +16 -3
  49. package/dist/hud/render.js +3 -1
  50. package/dist/learning-off/agents/code.md +7 -11
  51. package/dist/learning-off/agents/{diagnose.md → debug.md} +4 -4
  52. package/dist/learning-off/agents/{code-fix.md → fix.md} +7 -8
  53. package/dist/learning-off/agents/{scrutinize.md → harden.md} +6 -6
  54. package/dist/learning-off/agents/{code-edit.md → refactor.md} +8 -9
  55. package/dist/learning-off/agents/review.md +2 -0
  56. package/dist/learning-off/commands/bug-analysis.md +9 -9
  57. package/dist/learning-off/commands/code-review.md +54 -59
  58. package/dist/learning-off/commands/debug.md +6 -10
  59. package/dist/learning-off/commands/dynamic-build.md +98 -112
  60. package/dist/learning-off/commands/dynamic-plan.md +5 -5
  61. package/dist/learning-off/commands/dynamic-profile.md +1 -1
  62. package/dist/learning-off/commands/dynamic-tickets.md +5 -5
  63. package/dist/learning-off/commands/explore.md +4 -8
  64. package/dist/learning-off/commands/implement.md +68 -146
  65. package/dist/learning-off/commands/plan.md +23 -58
  66. package/dist/learning-off/commands/release.md +4 -2
  67. package/dist/learning-off/commands/research.md +2 -2
  68. package/dist/learning-off/commands/resolve.md +33 -81
  69. package/dist/learning-off/commands/self-review.md +32 -36
  70. package/dist/skills/git/references/pr/check-merge-readiness.md +2 -0
  71. package/dist/skills/git/references/pr/update-pr-evidence.md +4 -6
  72. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +7 -9
  73. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +7 -15
  74. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +7 -15
  75. package/dist/targets/claude-code/installer.js +117 -84
  76. package/dist/targets/claude-code/learning-install.js +15 -3
  77. package/dist/targets/claude-code/tracker-servers.js +1 -1
  78. package/package.json +1 -1
  79. package/src/assets/agents/_partials/_code.mds +7 -8
  80. package/src/assets/agents/code.mds +4 -7
  81. package/src/assets/agents/{learning.md → curate.md} +8 -10
  82. package/src/assets/agents/{diagnose.mds → debug.mds} +4 -4
  83. package/src/assets/agents/evaluate.md +4 -4
  84. package/src/assets/agents/{code-fix.mds → fix.mds} +5 -5
  85. package/src/assets/agents/{scrutinize.mds → harden.mds} +6 -6
  86. package/src/assets/agents/{simplify.md → polish.md} +4 -4
  87. package/src/assets/agents/{code-edit.mds → refactor.mds} +6 -6
  88. package/src/assets/agents/review.mds +2 -0
  89. package/src/assets/agents/synthesize.md +2 -2
  90. package/src/assets/agents/test.md +2 -2
  91. package/src/assets/commands/_partials/_compliance.mds +1 -1
  92. package/src/assets/commands/_partials/_decisions.mds +26 -6
  93. package/src/assets/commands/_partials/_engine.mds +15 -15
  94. package/src/assets/commands/_partials/_evidence_policy.mds +0 -17
  95. package/src/assets/commands/_partials/_knowledge.mds +14 -9
  96. package/src/assets/commands/_partials/_preamble.mds +4 -2
  97. package/src/assets/commands/_partials/_roster.mds +4 -4
  98. package/src/assets/commands/_partials/_settings.mds +2 -2
  99. package/src/assets/commands/_partials/_wave.mds +10 -17
  100. package/src/assets/commands/bug-analysis.mds +7 -7
  101. package/src/assets/commands/code-review.mds +51 -56
  102. package/src/assets/commands/debug.mds +2 -2
  103. package/src/assets/commands/dynamic-build.mds +67 -74
  104. package/src/assets/commands/implement.mds +65 -127
  105. package/src/assets/commands/plan.mds +21 -61
  106. package/src/assets/commands/release.mds +5 -1
  107. package/src/assets/commands/resolve.mds +28 -76
  108. package/src/assets/commands/self-review.mds +27 -27
  109. package/src/assets/mds/git/_ops_steps.mds +3 -3
  110. package/src/assets/mds/git/_pr.mds +6 -6
  111. package/src/assets/mds/tracker/_common.mds +23 -12
  112. package/src/assets/mds/tracker/_github.mds +2 -9
  113. package/src/assets/mds/tracker/_jira.mds +2 -15
  114. package/src/assets/mds/tracker/_linear.mds +2 -15
  115. package/src/assets/scripts/ci-wait.cjs +22 -4
  116. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  117. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  118. package/src/assets/scripts/hooks/background-memory-update +404 -95
  119. package/src/assets/scripts/hooks/capture-prompt +1 -1
  120. package/src/assets/scripts/hooks/capture-question +1 -1
  121. package/src/assets/scripts/hooks/capture-turn +1 -1
  122. package/src/assets/scripts/hooks/json-helper.cjs +13 -2
  123. package/src/assets/scripts/hooks/json-parse +18 -3
  124. package/src/assets/scripts/hooks/lib/learning-store.cjs +1 -1
  125. package/src/assets/scripts/hooks/lib/project-paths.cjs +4 -4
  126. package/src/assets/scripts/hooks/memory-permission +358 -0
  127. package/src/assets/scripts/hooks/preamble +1 -1
  128. package/src/assets/scripts/hooks/session-start-context +60 -47
  129. package/src/assets/scripts/hooks/session-start-memory +26 -5
  130. package/src/assets/scripts/lib/project-config.cjs +19 -4
  131. package/src/assets/scripts/resolve-settings.cjs +200 -32
  132. package/src/assets/scripts/verify-evidence.cjs +726 -9
  133. package/src/assets/skills/agent-references/SKILL.md +14 -6
  134. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  135. package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +35 -0
  136. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +15 -0
  137. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  138. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +1 -1
  139. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  140. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  141. package/src/assets/skills/apply-decisions/SKILL.md +6 -4
  142. package/src/assets/skills/docs-framework/SKILL.md +3 -3
  143. package/src/assets/skills/qa/SKILL.md +1 -1
  144. package/src/assets/skills/quality-gates/SKILL.md +7 -7
  145. package/src/assets/skills/quality-gates/references/report-template.md +7 -7
  146. /package/src/assets/skills/agent-references/references/{learning → curate}/maintain.md +0 -0
@@ -5,7 +5,8 @@
5
5
  # 120s throttle expires. Drains .pending-turns.jsonl → calls `claude -p` on the model
6
6
  # and effort the `agents.memory` entry of ~/.devflow/agent-models.json names, else the
7
7
  # shipped worker default, in a lean session (D-MEMORY-WORKER-LEAN) →
8
- # rewrites WORKING-MEMORY.md with a git-reconciliation stamp on line 1.
8
+ # rewrites WORKING-MEMORY.md with a git-reconciliation stamp on line 1. The run's write
9
+ # is limited to its staged file, WORKING-MEMORY.md.new (see "the spawn" below).
9
10
  #
10
11
  # Applies the LLM-vs-plumbing principle: artifact content (WORKING-MEMORY.md) is
11
12
  # authored by the LLM; this script only does plumbing (lock, queue drain, spawn).
@@ -26,6 +27,8 @@
26
27
  # are correctly separated by the lock boundary.
27
28
  # User-only: exits without an LLM run (no fabrication) and leaves the queue in place
28
29
  # for the next run (D-QUEUE-NO-ORPHAN-DELETE)
30
+ # No parser: neither jq nor node on PATH → exits with no LLM run; the queue and any .processing batch are left in place
31
+ # Parser fails: jq or node exits non-zero while it reads the queue or the batch → exits with no LLM run; the turns are kept
29
32
 
30
33
  set -e
31
34
 
@@ -64,12 +67,79 @@ source "$SCRIPT_DIR/is-hex-sha" || { echo "background-memory-update: failed to s
64
67
 
65
68
  log "Starting (CWD=$CWD)"
66
69
 
70
+ # Every run ends with one `Run:` line that says what it was: the CLI version, the argv
71
+ # form it ran, how it ended, how many calls the permission rules denied and what the
72
+ # worker removed from the staged path. The exit trap writes it, so each way out of the
73
+ # script has one. The fields start as "no CLI read, no run" and the steps below fill them
74
+ # in; each is a keyword or digits, never text from the CLI, the queue or the prompt. The
75
+ # trap also removes RESULT_FILE (the captured JSON result, set at the spawn below) and,
76
+ # once this run holds it, the worker lock.
77
+ RUN_CLI="unknown" # X.Y.Z once the version probe has read one
78
+ RUN_ARGV="none" # lean or legacy, set where the run is spawned
79
+ RUN_RESULT="skip" # updated, conflict, failed, skip or below-floor
80
+ RUN_DENIALS="n/a" # digits or unknown, set where the run is spawned
81
+ RUN_REMOVED="none" # dir or link when a folder or a link was removed from the staged path
82
+ RUN_FLOOR="" # the version floor, named on a below-floor line only
83
+ RESULT_FILE=""
84
+ LOCK_HELD="false"
85
+ # Sets OWN_PID to a value that is the worker's own PID in the worker's shell and differs in
86
+ # every subshell. It is BASHPID when that is set (bash 4 and later, no fork). Otherwise it is
87
+ # the PID that a child, which execs sh, reports as its parent: that parent is this shell, and
88
+ # `$$` would be the worker's PID in every subshell. When that read fails or is not a number,
89
+ # BASH_SUBSHELL decides: 0 is the worker's own shell (`$$`), and above 0 is a subshell, which
90
+ # gets a non-numeric value that never equals the worker's PID.
91
+ own_pid() {
92
+ if [ -n "${BASHPID:-}" ]; then
93
+ OWN_PID="$BASHPID"
94
+ return 0
95
+ fi
96
+ OWN_PID=$(exec /bin/sh -c 'echo $PPID' 2>/dev/null) || OWN_PID=""
97
+ case "$OWN_PID" in
98
+ ''|*[!0123456789]*)
99
+ if [ "${BASH_SUBSHELL:-0}" = "0" ]; then OWN_PID="$$"; else OWN_PID="subshell"; fi ;;
100
+ esac
101
+ }
102
+ own_pid
103
+ WORKER_PID="$OWN_PID"
104
+ finish_run() {
105
+ # Only the worker process itself ends the run. A subshell it forks (the watchdog, the
106
+ # claude spawn) that a signal reaches before bash resets its traps runs this trap too,
107
+ # and the PID check returns there: it writes no Run line and releases no lock.
108
+ own_pid
109
+ [ "$OWN_PID" = "$WORKER_PID" ] || return 0
110
+ local _floor=""
111
+ if [ -n "$RUN_FLOOR" ]; then _floor=" floor=${RUN_FLOOR}"; fi
112
+ log "Run: cli=${RUN_CLI} argv=${RUN_ARGV} result=${RUN_RESULT} denials=${RUN_DENIALS} removed=${RUN_REMOVED}${_floor}"
113
+ if [ -n "$RESULT_FILE" ]; then rm -f "$RESULT_FILE" 2>/dev/null || true; fi
114
+ if [ "$LOCK_HELD" = "true" ]; then rmdir "$LOCK_DIR" 2>/dev/null || true; fi
115
+ }
116
+ trap finish_run EXIT
117
+
118
+ # No JSON parser: stop before any step reads or moves the queue or the batch (#523).
119
+ if [ "${_JSON_AVAILABLE:-false}" != "true" ]; then
120
+ log "SKIP: neither jq nor node on PATH; the queue is kept for the next run; no LLM run"
121
+ exit 0
122
+ fi
123
+
67
124
  # --- Resolve paths ---
68
125
  # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when the
69
126
  # worker is spawned with a CWD inside .devflow/...). Empty → fall back to CWD.
70
127
  source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true
128
+ # The staged-path check, the settings JSON with the worker's Edit allow and deny rules, the
129
+ # result line and the one-time notice (needs df_no_symlink_below from resolve-project-root).
130
+ source "$SCRIPT_DIR/memory-permission" || { echo "background-memory-update: failed to source memory-permission" >&2; exit 1; }
71
131
  PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)"
72
132
  [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD"
133
+ # One canonical root feeds every path below, the staged path in the prompt and in the
134
+ # permission rule included: the rule matches a path as it resolves on disk, so a root
135
+ # reached through a link is resolved here once (`cd -P`, `pwd -P`) and never again.
136
+ PROJECT_ROOT_CANONICAL=$(cd -P -- "$PROJECT_ROOT" 2>/dev/null && pwd -P) || PROJECT_ROOT_CANONICAL=""
137
+ if [ -z "$PROJECT_ROOT_CANONICAL" ]; then
138
+ log "SKIP: project root could not be resolved; no LLM run"
139
+ memory_notice_write "$PROJECT_ROOT/.devflow/memory" "$PROJECT_ROOT" path
140
+ exit 0
141
+ fi
142
+ PROJECT_ROOT="$PROJECT_ROOT_CANONICAL"
73
143
  PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
74
144
  MEMORY_DIR="$PROJECT_DEVFLOW_DIR/memory"
75
145
  QUEUE_FILE="$MEMORY_DIR/.pending-turns.jsonl"
@@ -120,11 +190,37 @@ if ! df_no_symlink_below "$PROJECT_ROOT" "$LOCK_DIR" "$QUEUE_FILE" "$PROCESSING_
120
190
  exit 0
121
191
  fi
122
192
 
193
+ # --- The worker's write is limited to its staged file ---
194
+ # The run gets an allow rule for $STAGED_FILE, a deny rule for every path under it, and a
195
+ # permission mode that denies every call no rule allows. The rules are plain text in a
196
+ # settings JSON, so the staged path is checked before the JSON is built: a character that
197
+ # has a meaning in a rule path, or a project inside a .git folder, ends the run here with
198
+ # no LLM call, and a one-time notice tells the user (memory-permission). The prompt, the
199
+ # rules and the checks below all use this one path.
200
+ if ! staged_path_safe "$STAGED_FILE"; then
201
+ if [ "$STAGED_PATH_REFUSAL" = "protected" ]; then
202
+ log "SKIP: the project lies inside a .git folder; no LLM run"
203
+ else
204
+ log "SKIP: the memory path holds characters the permission rule cannot carry; no LLM run"
205
+ fi
206
+ memory_notice_write "$MEMORY_DIR" "$PROJECT_ROOT" path
207
+ exit 0
208
+ fi
209
+ MEMORY_SETTINGS_JSON=$(memory_settings_json "$STAGED_FILE") || MEMORY_SETTINGS_JSON=""
210
+ if [ -z "$MEMORY_SETTINGS_JSON" ]; then
211
+ log "SKIP: the permission rule for the staged file could not be built; no LLM run"
212
+ exit 0
213
+ fi
214
+
123
215
  # --- CLI version probe: choose the argv form (D-MEMORY-WORKER-LEAN) ---
124
216
  # The lean argv (--effort, --safe-mode, --max-turns, ...) is verified from
125
217
  # MEMORY_MIN_CLAUDE_VERSION up. A CLI older than that, or one whose version cannot be
126
218
  # read, gets the legacy argv instead, because one flag a CLI does not know fails every
127
- # run. The probe runs here, once $CLAUDE_BIN has resolved and the link check has
219
+ # run. A CLI whose version is read and is older than MEMORY_MIN_PERMISSION_VERSION, the
220
+ # lowest version on which the worker's write was measured to be limited to its staged
221
+ # file, gets no run at all: the queue is kept and the run's `Run:` line says below-floor.
222
+ # `devflow init` states this version; src/cli/commands/memory.ts holds the same value, and
223
+ # a test holds the two equal. The probe runs here, once $CLAUDE_BIN has resolved and the link check has
128
224
  # passed, and before the lock is taken, so a slow or hung `--version` can never extend
129
225
  # the lock's hold. It is bounded by DEVFLOW_BG_VERSION_PROBE_SECS (default 10, at most
130
226
  # 60; tests override it as they do DEVFLOW_BG_WATCHDOG_SECS) with the watchdog's
@@ -134,6 +230,10 @@ fi
134
230
  # aborts the caller, so each helper below ends in an explicit return status and is
135
231
  # called inside an `if` condition.
136
232
  MEMORY_MIN_CLAUDE_VERSION="2.1.286"
233
+ # The lowest version measured, 2.1.269 (2026-10-15, with 2.1.280, 2.1.286 and 2.1.299): on
234
+ # each, the staged file was written and every other path was refused. The cells are in
235
+ # docs/reference/platform-assumptions.md.
236
+ MEMORY_MIN_PERMISSION_VERSION="2.1.269"
137
237
 
138
238
  # Sets CLAUDE_VERSION_TEXT to the first 512 bytes `claude --version` printed ("" on any
139
239
  # failure). Returns 0 when it exited 0 inside the bound, else 1. A hung probe gets
@@ -161,7 +261,10 @@ probe_claude_version() {
161
261
  ) </dev/null >/dev/null 2>&1 &
162
262
  _wd_pid=$!
163
263
  if wait "$_pid" 2>/dev/null; then _status=0; fi
164
- kill "$_wd_pid" 2>/dev/null || true
264
+ # SIGKILL, not SIGTERM: a fast probe ends while the watchdog subshell may not have run
265
+ # yet, and SIGTERM sent to a subshell that still carries the exit trap runs that trap
266
+ # there, which writes a second `Run:` line. SIGKILL runs no trap.
267
+ kill -KILL "$_wd_pid" 2>/dev/null || true
165
268
  wait "$_wd_pid" 2>/dev/null || true
166
269
  if [ "$_status" -eq 0 ]; then
167
270
  CLAUDE_VERSION_TEXT=$(head -c 512 "$_out" 2>/dev/null || true)
@@ -170,21 +273,35 @@ probe_claude_version() {
170
273
  return "$_status"
171
274
  }
172
275
 
173
- # Returns 0 when the first X.Y.Z in $1 is at or above the first X.Y.Z in $2, comparing
174
- # the three components as base-10 numbers left to right (2.10.0 is above 2.1.286, and
175
- # a leading zero is not octal). Returns 1 when it is below, or when either text holds
176
- # no X.Y.Z or a component longer than six digits. The digit set is spelled out: a
276
+ # Returns 0 and sets CLAUDE_VERSION_PARSED to the first X.Y.Z in $1, each component as a
277
+ # base-10 number without leading zeros (a leading zero is not octal). Returns 1 when $1
278
+ # holds no X.Y.Z or a component longer than six digits. This is the one definition of a
279
+ # version: the run's CLI version and both floors read it. The digit set is spelled out: a
177
280
  # range such as 0-9 collates by locale on bash 3.2.
178
- claude_version_at_least() {
179
- local _text="$1" _floor="$2" _re='([0123456789]+)\.([0123456789]+)\.([0123456789]+)'
180
- local _f1 _f2 _f3 _v1 _v2 _v3
181
- [[ "$_floor" =~ $_re ]] || return 1
182
- _f1=${BASH_REMATCH[1]}; _f2=${BASH_REMATCH[2]}; _f3=${BASH_REMATCH[3]}
183
- [[ "$_text" =~ $_re ]] || return 1
281
+ claude_version_parsed() {
282
+ local _re='([0123456789]+)\.([0123456789]+)\.([0123456789]+)'
283
+ local _v1 _v2 _v3
284
+ CLAUDE_VERSION_PARSED=""
285
+ [[ "$1" =~ $_re ]] || return 1
184
286
  _v1=${BASH_REMATCH[1]}; _v2=${BASH_REMATCH[2]}; _v3=${BASH_REMATCH[3]}
185
287
  if [ "${#_v1}" -gt 6 ] || [ "${#_v2}" -gt 6 ] || [ "${#_v3}" -gt 6 ]; then return 1; fi
186
- _v1=$((10#$_v1)); _v2=$((10#$_v2)); _v3=$((10#$_v3))
187
- _f1=$((10#$_f1)); _f2=$((10#$_f2)); _f3=$((10#$_f3))
288
+ CLAUDE_VERSION_PARSED="$((10#$_v1)).$((10#$_v2)).$((10#$_v3))"
289
+ return 0
290
+ }
291
+
292
+ # Returns 0 when the version in $1 is at or above the version in $2, comparing the three
293
+ # components as numbers left to right (2.10.0 is above 2.1.286). Returns 1 when it is
294
+ # below, or when either text is no version for claude_version_parsed. It leaves
295
+ # CLAUDE_VERSION_PARSED holding the parse of $1 when both texts parse, and empty when either
296
+ # one does not parse.
297
+ claude_version_at_least() {
298
+ local _f1 _f2 _f3 _v1 _v2 _v3 _rest
299
+ claude_version_parsed "$2" || return 1
300
+ _f1=${CLAUDE_VERSION_PARSED%%.*}; _rest=${CLAUDE_VERSION_PARSED#*.}
301
+ _f2=${_rest%%.*}; _f3=${_rest#*.}
302
+ claude_version_parsed "$1" || return 1
303
+ _v1=${CLAUDE_VERSION_PARSED%%.*}; _rest=${CLAUDE_VERSION_PARSED#*.}
304
+ _v2=${_rest%%.*}; _v3=${_rest#*.}
188
305
  if [ "$_v1" -ne "$_f1" ]; then
189
306
  if [ "$_v1" -gt "$_f1" ]; then return 0; fi
190
307
  return 1
@@ -197,13 +314,30 @@ claude_version_at_least() {
197
314
  return 1
198
315
  }
199
316
 
317
+ # The permission floor sits at or below the lean floor: a CLI the lean form runs on
318
+ # always has the permission flags too.
319
+ if ! claude_version_at_least "$MEMORY_MIN_CLAUDE_VERSION" "$MEMORY_MIN_PERMISSION_VERSION"; then
320
+ echo "[background-memory-update] FATAL: MEMORY_MIN_PERMISSION_VERSION ($MEMORY_MIN_PERMISSION_VERSION) must not exceed MEMORY_MIN_CLAUDE_VERSION ($MEMORY_MIN_CLAUDE_VERSION)" >&2
321
+ exit 1
322
+ fi
323
+
200
324
  CLAUDE_ARGV_FORM="legacy"
201
325
  if ! probe_claude_version; then
202
326
  log "claude --version probe failed or timed out — using the legacy argv"
203
- elif claude_version_at_least "$CLAUDE_VERSION_TEXT" "$MEMORY_MIN_CLAUDE_VERSION"; then
204
- CLAUDE_ARGV_FORM="lean"
327
+ elif ! claude_version_parsed "$CLAUDE_VERSION_TEXT"; then
328
+ log "claude --version could not be read — using the legacy argv"
205
329
  else
206
- log "claude --version is below ${MEMORY_MIN_CLAUDE_VERSION} or unparseable — using the legacy argv"
330
+ RUN_CLI="$CLAUDE_VERSION_PARSED"
331
+ if ! claude_version_at_least "$RUN_CLI" "$MEMORY_MIN_PERMISSION_VERSION"; then
332
+ RUN_RESULT="below-floor"
333
+ RUN_FLOOR="$MEMORY_MIN_PERMISSION_VERSION"
334
+ exit 0
335
+ fi
336
+ if claude_version_at_least "$RUN_CLI" "$MEMORY_MIN_CLAUDE_VERSION"; then
337
+ CLAUDE_ARGV_FORM="lean"
338
+ else
339
+ log "claude --version is below ${MEMORY_MIN_CLAUDE_VERSION} — using the legacy argv"
340
+ fi
207
341
  fi
208
342
 
209
343
  # --- Worker-level lock (300s stale-break — much longer than learning-lock's 30s) ---
@@ -243,34 +377,46 @@ acquire_lock() {
243
377
  return 0
244
378
  }
245
379
 
246
- cleanup_lock() {
247
- rmdir "$LOCK_DIR" 2>/dev/null || true
248
- }
249
- trap cleanup_lock EXIT
250
-
251
380
  break_stale_lock
252
381
 
382
+ # A worker that times out on the lock does not hold it, so finish_run leaves it alone.
253
383
  if ! acquire_lock; then
254
384
  log "SKIP: lock timeout after 90s — another worker is running"
255
- trap - EXIT
256
385
  exit 0
257
386
  fi
387
+ LOCK_HELD="true"
258
388
 
259
- # Clean up any staged file left by a watchdog-killed prior run.
260
- # Without this, a stale staged file with a valid stamp would be mistakenly
261
- # mv-ed to the real path on the NEXT run's CAS check (D-MEMORY-STAGED-CAS).
389
+ # Clean up whatever stands at the staged path after a watchdog-killed or odd prior run.
390
+ # Without this, a stale staged file with a valid stamp would be mistakenly mv-ed to the
391
+ # real path on the NEXT run's CAS check (D-MEMORY-STAGED-CAS).
262
392
  #
263
393
  # D-MEMORY-WORKER-LEAN: the removal is also a CHECKED precondition. The model gets the
264
394
  # Write tool alone, and Write refuses a file that exists and has not been read, which
265
395
  # is how an earlier Write-only worker failed silently: every Write errored while the
266
396
  # run exited 0. Once nothing stands at the staged path, the one Write is always a
267
- # create, so Write-only is safe. A path `rm -f` cannot clear (a directory, say) would
268
- # make every Write fail, so the run stops here instead: no LLM call, the batch and
269
- # the queue untouched (nothing has been claimed yet), the lock released by the EXIT
270
- # trap. `-e` or `-L`, so a link there counts as present.
397
+ # create, so Write-only is safe. A folder or a link there is removed first, by one
398
+ # guarded delete of exactly that path (memory_staged_remove: only
399
+ # <project>/.devflow/memory/WORKING-MEMORY.md.new, never through a link, a bounded
400
+ # listing, no pattern), then `rm -f` clears a stale regular file. Whatever still stands
401
+ # there stops the run here instead: no LLM call, the batch and the queue untouched
402
+ # (nothing has been claimed yet), the lock released by the EXIT trap, a one-time notice
403
+ # for the user, and a Run line that says `failed` and `removed=none`. A link there counts
404
+ # as present (path_occupied).
405
+ if ! memory_staged_remove "$MEMORY_DIR" "$PROJECT_ROOT" "$STAGED_FILE"; then
406
+ RUN_RESULT="failed"
407
+ log "SKIP: $STAGED_FILE is a folder or a link that was not removed (${STAGED_REMOVE_REFUSAL}); remove it to resume memory updates; no LLM run"
408
+ memory_notice_write "$MEMORY_DIR" "$PROJECT_ROOT" path
409
+ exit 0
410
+ fi
411
+ RUN_REMOVED="$STAGED_REMOVED"
412
+ if [ "$STAGED_REMOVED" != "none" ]; then
413
+ log "Removed a ${STAGED_REMOVED/dir/folder} from $STAGED_FILE before the run"
414
+ fi
271
415
  rm -f "$STAGED_FILE" 2>/dev/null || true
272
- if [ -e "$STAGED_FILE" ] || [ -L "$STAGED_FILE" ]; then
416
+ if path_occupied "$STAGED_FILE"; then
417
+ RUN_RESULT="failed"
273
418
  log "SKIP: $STAGED_FILE could not be removed — a Write to it would fail, so no LLM run; the batch and the queue are left as they are"
419
+ memory_notice_write "$MEMORY_DIR" "$PROJECT_ROOT" path
274
420
  exit 0
275
421
  fi
276
422
 
@@ -279,8 +425,6 @@ fi
279
425
  # A qa row (captured Q&A pair) counts as content-bearing here too — it carries
280
426
  # the same synthesis-worthy signal as an assistant turn (AC-F10). This check
281
427
  # and the TURNS_TEXT extraction loop below must agree on that.
282
- # When neither jq nor node is available (_JSON_AVAILABLE=false) we skip the check
283
- # and allow the run to proceed — conservative: better to attempt than to skip blindly.
284
428
  #
285
429
  # D-QUEUE-NO-ORPHAN-DELETE: the queue is never deleted here. Claude Code runs a
286
430
  # Stop event's hooks in parallel, so this worker can read the queue after
@@ -288,9 +432,13 @@ fi
288
432
  # assistant row; deleting it then loses that turn's prompt. Leaving it costs
289
433
  # nothing: the run is still skipped, the next run takes the whole turn once the
290
434
  # assistant row lands, and queue-append caps the file (200 -> newest 100 lines).
291
- if [ ! -f "$PROCESSING_FILE" ] && [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ] && [ "$_JSON_AVAILABLE" = "true" ]; then
435
+ if [ ! -f "$PROCESSING_FILE" ] && [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ]; then
436
+ # A parser that exits non-zero has not said that the queue holds no turn, so the status is
437
+ # read from the parser itself (no pipe after it) and the queue is kept as it is.
438
+ _PARSER_RC=0
292
439
  if [ "$_HAS_JQ" = "true" ]; then
293
- _HAS_CONTENT=$(jq -r 'select(.role=="assistant" or .role=="qa") | .role' "$QUEUE_FILE" 2>/dev/null | head -1 || echo "")
440
+ _HAS_CONTENT=$(jq -R -r '(fromjson | select(.role=="assistant" or .role=="qa") | .role)?' "$QUEUE_FILE" 2>/dev/null) || _PARSER_RC=$?
441
+ _HAS_CONTENT="${_HAS_CONTENT%%$'\n'*}"
294
442
  else
295
443
  # SECURITY: pass path via argv, never interpolate into node -e source (avoids shell injection
296
444
  # for repo paths containing quotes or special characters)
@@ -300,7 +448,11 @@ if [ ! -f "$PROCESSING_FILE" ] && [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ] &
300
448
  for (const l of lines) {
301
449
  try { const o = JSON.parse(l); if (o.role === "assistant" || o.role === "qa") { process.stdout.write(o.role); break; } } catch {}
302
450
  }
303
- ' "$QUEUE_FILE" 2>/dev/null || echo "")
451
+ ' "$QUEUE_FILE" 2>/dev/null) || _PARSER_RC=$?
452
+ fi
453
+ if [ "$_PARSER_RC" -ne 0 ]; then
454
+ log "SKIP: the JSON parser exited $_PARSER_RC on the queue; the queue is kept for the next run; no LLM run"
455
+ exit 0
304
456
  fi
305
457
  if [ -z "$_HAS_CONTENT" ]; then
306
458
  log "User-only queue (no assistant/qa turn) — leaving it for the next run"
@@ -308,14 +460,29 @@ if [ ! -f "$PROCESSING_FILE" ] && [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ] &
308
460
  fi
309
461
  fi
310
462
 
463
+ # Succeeds when the file is not empty and its last byte is not a line break (a row cut off
464
+ # before its break). The command substitution drops a trailing line break, so only a last
465
+ # byte of another kind is left in it.
466
+ ends_without_newline() {
467
+ [ -s "$1" ] && [ -n "$(tail -c 1 "$1" 2>/dev/null)" ]
468
+ }
469
+
311
470
  # --- Claim queue atomically (D56c contract: .pending-turns.processing recovered at SessionStart) ---
312
471
  # If a leftover .processing exists from a previous crash, merge new queue entries into it.
313
472
  if [ -f "$PROCESSING_FILE" ]; then
314
473
  log "Found leftover .processing from previous crash — merging"
315
474
  if [ -f "$QUEUE_FILE" ]; then
316
- cat "$QUEUE_FILE" >> "$PROCESSING_FILE" 2>/dev/null || true
317
- rm -f "$QUEUE_FILE" 2>/dev/null || true
318
- log "Merged new queue entries into recovery batch"
475
+ # The queue is removed only after its rows are in the batch. A batch whose last row has
476
+ # no line break first gets one, so the first queue row does not run into that row and
477
+ # become one malformed line that the parser drops. A failed append leaves the queue for
478
+ # the next run; an append that stopped part-way may repeat rows then, and a line break
479
+ # added before a failed append only leaves a blank line, which the parser ignores.
480
+ if { ! ends_without_newline "$PROCESSING_FILE" || printf '\n' >> "$PROCESSING_FILE"; } 2>/dev/null && cat "$QUEUE_FILE" >> "$PROCESSING_FILE" 2>/dev/null; then
481
+ rm -f "$QUEUE_FILE" 2>/dev/null || true
482
+ log "Merged new queue entries into recovery batch"
483
+ else
484
+ log "Could not append the queue to the recovery batch — the queue is kept for the next run"
485
+ fi
319
486
  fi
320
487
  # Cap to prevent unbounded growth
321
488
  PROC_OVERFLOW_CAP=200 # truncate when processing file exceeds this many lines
@@ -351,6 +518,11 @@ fi
351
518
  touch "$PROCESSING_FILE" 2>/dev/null || true
352
519
 
353
520
  TOTAL_LINES=$(wc -l < "$PROCESSING_FILE" | tr -d ' ')
521
+ # wc -l counts line breaks, so a last row with no break is not in its count. Count it, so a
522
+ # batch of one such row is a batch with a row, and only a batch with no byte is empty.
523
+ if ends_without_newline "$PROCESSING_FILE"; then
524
+ TOTAL_LINES=$(( TOTAL_LINES + 1 ))
525
+ fi
354
526
  log "Processing $TOTAL_LINES queued entries"
355
527
 
356
528
  if [ "$TOTAL_LINES" -eq 0 ]; then
@@ -372,24 +544,41 @@ else
372
544
  fi
373
545
 
374
546
  # Single-pass extraction: role + content as TSV (newlines collapsed)
375
- # When neither jq nor node is available, EXTRACTED stays empty and TURN_COUNT will be 0 → early exit.
547
+ # Each line is parsed alone (jq -R with fromjson, node per line): a malformed line drops only that line.
548
+ # A row cut off before its line break runs into the next row, so the two are one malformed line (#528).
549
+ # Both parsers emit a row only when its role is user, assistant or qa and its content is a
550
+ # string (or absent), so a line that is JSON but no turn is a skipped line on either backend.
551
+ # A parser that exits non-zero has read nothing it can vouch for: the batch is kept, with the
552
+ # queue merged into it, and no LLM runs. Exit 0 with no row still means "no readable turn".
553
+ _PARSER_RC=0
376
554
  if [ "$_HAS_JQ" = "true" ]; then
377
- EXTRACTED=$(printf '%s\n' "$ENTRIES" | jq -r '(.role // "") + "\t" + ((.content // "") | gsub("\n"; " "))' 2>/dev/null || echo "")
378
- elif [ "$_HAS_NODE" = "true" ]; then
555
+ EXTRACTED=$(printf '%s\n' "$ENTRIES" | jq -R -r '(fromjson | select(.role=="user" or .role=="assistant" or .role=="qa") | .role + "\t" + ((.content // "") | split("\n") | join(" ")))?' 2>/dev/null) || _PARSER_RC=$?
556
+ else
379
557
  EXTRACTED=$(printf '%s\n' "$ENTRIES" | node -e '
380
558
  const lines = require("fs").readFileSync("/dev/stdin","utf8").split("\n");
381
559
  for (const line of lines) {
382
560
  if (!line.trim()) continue;
383
561
  try {
384
562
  const obj = JSON.parse(line);
385
- const role = obj.role || "";
386
- const content = (obj.content || "").replace(/\n/g, " ");
387
- process.stdout.write(role + "\t" + content + "\n");
563
+ const role = obj === null ? undefined : obj.role;
564
+ if (role !== "user" && role !== "assistant" && role !== "qa") continue;
565
+ const content = obj.content == null || obj.content === false ? "" : obj.content;
566
+ if (typeof content !== "string") continue;
567
+ process.stdout.write(role + "\t" + content.replace(/\n/g, " ") + "\n");
388
568
  } catch {}
389
569
  }
390
- ' 2>/dev/null || echo "")
391
- else
392
- EXTRACTED=""
570
+ ' 2>/dev/null) || _PARSER_RC=$?
571
+ fi
572
+ if [ "$_PARSER_RC" -ne 0 ]; then
573
+ log "SKIP: the JSON parser exited $_PARSER_RC on the batch; the batch is kept for the next run; no LLM run"
574
+ exit 0
575
+ fi
576
+
577
+ _IN_LINES=$(printf '%s\n' "$ENTRIES" | LC_ALL=C grep -c '[^[:space:]]' || true)
578
+ _OUT_LINES=$(printf '%s\n' "$EXTRACTED" | LC_ALL=C grep -c . || true)
579
+ _SKIPPED=$(( ${_IN_LINES:-0} - ${_OUT_LINES:-0} ))
580
+ if [ "$_SKIPPED" -gt 0 ]; then
581
+ log "Skipped $_SKIPPED queue line(s) that hold no readable turn"
393
582
  fi
394
583
 
395
584
  TURNS_TEXT=""
@@ -633,7 +822,7 @@ MEMORY_EFFORT_CHARS="abcdefghijklmnopqrstuvwxyz"
633
822
  # the `x` when the file, the field or a string there is missing. Always returns 0.
634
823
  memory_agent_field() {
635
824
  local _file="$HOME/.devflow/agent-models.json"
636
- if [ "$_JSON_AVAILABLE" = "true" ] && [ -f "$_file" ]; then
825
+ if [ -f "$_file" ]; then
637
826
  json_string_field_file "$_file" "agents.memory.$1" 2>/dev/null || true
638
827
  fi
639
828
  printf x
@@ -740,15 +929,18 @@ fi
740
929
  # Job-control notifications go to stderr which is redirected to the log — suppress them.
741
930
  #
742
931
  # D-MEMORY-WORKER-LEAN — the spawn. The worker opens a headless session to make one
743
- # Write, so the session is cut to what that Write needs. Prompt on stdin, output and
744
- # errors to the log, as before. Two argv forms, chosen by the version probe above:
932
+ # Write, so the session is cut to what that Write needs, and the Write is limited to its
933
+ # staged file. The prompt goes on stdin, the JSON result to a temporary file and the
934
+ # errors to the log. Two argv forms, chosen by the version probe above:
745
935
  #
746
936
  # lean (CLI at or above MEMORY_MIN_CLAUDE_VERSION):
747
937
  # -p --model M --effort E --safe-mode --tools Write --disable-slash-commands \
748
938
  # --strict-mcp-config --mcp-config '{"mcpServers":{}}' --max-turns 3 \
749
- # --dangerously-skip-permissions --output-format text
939
+ # --permission-mode dontAsk --setting-sources user --settings JSON \
940
+ # --output-format json
750
941
  # legacy (older, unreadable or timed-out probe):
751
- # -p --model M --tools Write --dangerously-skip-permissions --output-format text
942
+ # -p --model M --tools Write --permission-mode dontAsk --setting-sources user \
943
+ # --settings JSON --output-format json
752
944
  #
753
945
  # What each flag is for:
754
946
  # --model, --effort the resolved agents.memory values above. --effort is the only
@@ -760,8 +952,7 @@ fi
760
952
  # Auth, model selection and built-in tools work as usual, and
761
953
  # settings `env` (the provider URL and key) still applies under
762
954
  # it (measured on 2026-10-09), so a routed or keyed install works.
763
- # --tools Write Write is the only tool. `--allowedTools` only pre-approves, it
764
- # leaves every tool, listing and MCP server loaded.
955
+ # --tools Write Write is the only tool.
765
956
  # --disable-slash-commands, --strict-mcp-config with an empty --mcp-config
766
957
  # no slash-command or skill listing, no MCP server and none of the
767
958
  # instructions they add to the context.
@@ -773,59 +964,123 @@ fi
773
964
  # pattern). `--max-turns` is not listed in `claude --help` there
774
965
  # but is registered (`--max-turns abc` errors "must be a number"),
775
966
  # which is why it was exercised rather than read off the help.
776
- # --dangerously-skip-permissions headless: nothing could answer a prompt.
777
- # --output-format text
967
+ # --permission-mode dontAsk
968
+ # a call no allow rule covers is denied and never asked, so a
969
+ # headless run cannot wait on a prompt and the denial is the limit.
970
+ # --setting-sources user
971
+ # only the user's own settings load, never the project's or the
972
+ # local ones. The user's settings keep their `env` and their own
973
+ # allow rules, so a user allow rule that covers other paths covers
974
+ # the worker too. The user's ask and deny rules on Edit also apply
975
+ # to the run.
976
+ # --settings JSON an allow rule Edit(//<the canonical staged path>), a deny rule
977
+ # Edit(//<the canonical staged path>/*) for every path under it, and
978
+ # no hooks. Under dontAsk a path that no allow rule covers is refused;
979
+ # a folder or a link at the staged path is removed before the run
980
+ # and after it (memory_staged_remove). Only Edit rules cover the
981
+ # Write tool: a Write(path) rule is never checked, so it would deny
982
+ # every Write.
983
+ # The leading // makes the rule path absolute. The JSON is built by
984
+ # memory_settings_json, after the path check above, and its comment
985
+ # holds what was measured.
986
+ # --output-format json
987
+ # the result as one JSON object: its subtype, error flag, turn
988
+ # count and the list of denied calls. The hook reduces it to one
989
+ # fixed log line (memory_result_summary) and deletes the file; the
990
+ # denied calls' input, which can hold the memory body, is never
991
+ # read or logged.
778
992
  # DEVFLOW_BG_UPDATER=1 the recursion guard stays on both forms, though --safe-mode
779
993
  # also disables hooks. CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and
780
994
  # CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 keep the instruction files and
781
995
  # auto-memory out on both forms, including the legacy one.
782
996
  #
997
+ # The run starts in the project root, so a session that began in a subfolder runs from
998
+ # the same place. The worker enters the root before the fork; a root it cannot enter ends
999
+ # the run with its own log line and starts no claude, so the spawn's exit status is what
1000
+ # claude returned and nothing else. The spawn is a subshell that ends in `exec`, so the
1001
+ # claude process keeps the subshell's PID, which is its process group's leader (set -m
1002
+ # below), and the watchdog can signal the whole group.
1003
+ #
783
1004
  # Why Write-only is safe: the staged path is empty before every run (the removal and
784
- # the checked precondition after the lock above), so the one Write is always a create.
785
- # Write refuses an existing file that has not been read, and that is the whole history
786
- # of this flag set:
787
- # a472a490 passed `--tools "Write"` with `--allowedTools` for two memory files;
788
- # e701cecc widened the tools to `Read,Write,Bash` after counting the silent
789
- # read-before-write failures (the run exited 0 while every Write errored);
790
- # baf02265 removed both flags, with no reason in its message;
791
- # 6186645f raised the worker's model and kept `--allowedTools 'Read,Write'`.
1005
+ # the checked precondition after the lock above, then the check just before the spawn),
1006
+ # so the one Write is always a create. Write refuses an existing file that has not been
1007
+ # read, and a refused Write can leave the run's exit status at 0. That is why the staged
1008
+ # path is checked twice, and why a denied Write is counted from the JSON result and
1009
+ # logged, not left to look like a success.
792
1010
  #
793
- # The version floor is 2.1.286, the lowest CLI on which the whole lean argv was run
794
- # and its flags, the settings `env` under --safe-mode and the effort override were
795
- # checked (2.1.286 and 2.1.292 to 2.1.294 behave identically; 2026-10-09, recorded in
1011
+ # The lean floor is 2.1.286, the lowest CLI on which the whole lean argv was run and its
1012
+ # flags, the settings `env` under --safe-mode and the effort override were checked
1013
+ # (2.1.286 and 2.1.292 to 2.1.294 behave identically; 2026-10-09, recorded in
796
1014
  # docs/reference/platform-assumptions.md). The legacy form carries no --effort,
797
1015
  # --safe-mode, --max-turns, --disable-slash-commands, --strict-mcp-config or
798
1016
  # --mcp-config, none of which is verified on older CLIs, so on it effort is not
799
- # applied, MCP servers stay loaded and there is no turn cap.
1017
+ # applied, MCP servers stay loaded and there is no turn cap. A CLI below
1018
+ # MEMORY_MIN_PERMISSION_VERSION gets no run at all: the limit was measured from that
1019
+ # version up.
800
1020
  #
801
1021
  # The turns input has no byte cap: the queue holds every turn whole by design, and the
802
1022
  # prompt is bounded by the newest-20-rows window above and the 64 KB existing-memory
803
1023
  # cap, not by cutting a turn.
804
1024
  unset CLAUDE_CODE_EFFORT_LEVEL
805
- set -m
1025
+
1026
+ # The JSON result goes to a temporary file the exit trap also removes. mktemp creates it
1027
+ # readable by the user alone; if it cannot be made, the result goes to /dev/null and the
1028
+ # run goes on without a count.
1029
+ RESULT_FILE=$(mktemp "${TMPDIR:-/tmp}/devflow-memory-result.XXXXXX" 2>/dev/null) || RESULT_FILE=""
1030
+ if [ -z "$RESULT_FILE" ]; then
1031
+ log "Result not captured: no temporary file could be created"
1032
+ fi
1033
+
1034
+ # Re-check the staged path just before the spawn: the lock is held and the path was
1035
+ # cleared, but the prompt took time to build. A file or a link there now, or a link
1036
+ # anywhere on its way, was put there since, so the run does not start; the batch stays
1037
+ # in .processing for recovery.
1038
+ if path_occupied "$STAGED_FILE" || ! df_no_symlink_below "$PROJECT_ROOT" "$STAGED_FILE"; then
1039
+ RUN_RESULT="failed"
1040
+ log "SKIP: the staged path changed before the run; leaving .processing for recovery"
1041
+ exit 0
1042
+ fi
1043
+ # Every check on the path has passed, so a path notice from an earlier run is stale.
1044
+ memory_notice_clear "$MEMORY_DIR" "$PROJECT_ROOT" path
1045
+
1046
+ # From here a way out other than the end of the script is a failed run.
1047
+ RUN_ARGV="$CLAUDE_ARGV_FORM"
1048
+ RUN_RESULT="failed"
1049
+ RUN_DENIALS="unknown"
1050
+
1051
+ if ! cd "$PROJECT_ROOT" 2>/dev/null; then
1052
+ log "FAIL: could not enter the project root"
1053
+ exit 0
1054
+ fi
1055
+
1056
+ # One argv for both forms, in the order the flags are passed: the model, the Write tool and
1057
+ # the four permission flags that limit the worker's write are stated here once; the lean
1058
+ # form adds its own flags. (`+=` needs bash 3.1; the array is never empty, so its
1059
+ # expansion below is safe on 3.2.)
1060
+ CLAUDE_ARGV=(
1061
+ -p
1062
+ --model "$MEMORY_MODEL"
1063
+ )
806
1064
  if [ "$CLAUDE_ARGV_FORM" = "lean" ]; then
807
- DEVFLOW_BG_UPDATER=1 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 "$CLAUDE_BIN" -p \
808
- --model "$MEMORY_MODEL" \
809
- --effort "$MEMORY_EFFORT" \
810
- --safe-mode \
811
- --tools Write \
812
- --disable-slash-commands \
813
- --strict-mcp-config \
814
- --mcp-config '{"mcpServers":{}}' \
815
- --max-turns 3 \
816
- --dangerously-skip-permissions \
817
- --output-format text \
818
- <<< "$PROMPT" \
819
- >> "$LOG_FILE" 2>&1 &
820
- else
821
- DEVFLOW_BG_UPDATER=1 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 "$CLAUDE_BIN" -p \
822
- --model "$MEMORY_MODEL" \
823
- --tools Write \
824
- --dangerously-skip-permissions \
825
- --output-format text \
826
- <<< "$PROMPT" \
827
- >> "$LOG_FILE" 2>&1 &
1065
+ CLAUDE_ARGV+=(--effort "$MEMORY_EFFORT" --safe-mode)
1066
+ fi
1067
+ CLAUDE_ARGV+=(--tools Write)
1068
+ if [ "$CLAUDE_ARGV_FORM" = "lean" ]; then
1069
+ CLAUDE_ARGV+=(--disable-slash-commands --strict-mcp-config --mcp-config '{"mcpServers":{}}' --max-turns 3)
828
1070
  fi
1071
+ CLAUDE_ARGV+=(
1072
+ --permission-mode dontAsk
1073
+ --setting-sources user
1074
+ --settings "$MEMORY_SETTINGS_JSON"
1075
+ --output-format json
1076
+ )
1077
+
1078
+ set -m
1079
+ (
1080
+ DEVFLOW_BG_UPDATER=1 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 exec "$CLAUDE_BIN" "${CLAUDE_ARGV[@]}" \
1081
+ <<< "$PROMPT" \
1082
+ > "${RESULT_FILE:-/dev/null}" 2>> "$LOG_FILE"
1083
+ ) &
829
1084
  CLAUDE_PID=$!
830
1085
  set +m
831
1086
 
@@ -848,10 +1103,52 @@ else
848
1103
  CLAUDE_EXIT=$?
849
1104
  fi
850
1105
 
851
- # Cancel watchdog (avoids stray kill hitting a recycled PID after this process exits)
852
- kill "$WD_PID" 2>/dev/null || true
1106
+ # Cancel watchdog (avoids stray kill hitting a recycled PID after this process exits).
1107
+ # SIGKILL so no trap can run in the watchdog subshell, whatever the bash version.
1108
+ kill -KILL "$WD_PID" 2>/dev/null || true
853
1109
  wait "$WD_PID" 2>/dev/null || true
854
1110
 
1111
+ # The JSON result, reduced to one fixed log line and deleted on every path out of here
1112
+ # (the exit trap removes it too). Only counts and flags are read from it: the denied
1113
+ # calls' input and the result text never reach the log.
1114
+ MEMORY_RESULT_DENIALS="unknown"
1115
+ if [ -n "$RESULT_FILE" ]; then
1116
+ memory_result_summary "$RESULT_FILE"
1117
+ log "$MEMORY_RESULT_LINE"
1118
+ rm -f "$RESULT_FILE" 2>/dev/null || true
1119
+ RESULT_FILE=""
1120
+ RUN_DENIALS="$MEMORY_RESULT_DENIALS"
1121
+ fi
1122
+ # 0 and an unreported count are both "no denial to report"; any other count is a refusal.
1123
+ RESULT_DENIED=""
1124
+ case "$MEMORY_RESULT_DENIALS" in
1125
+ unknown|0) ;;
1126
+ *) RESULT_DENIED="yes" ;;
1127
+ esac
1128
+
1129
+ # A folder or a link at the staged path now was put there during the run: the path was
1130
+ # empty at the spawn. It is removed the way it is before a run (memory_staged_remove), and
1131
+ # the run ends as failed: the checks below find no staged file, and the batch stays for
1132
+ # recovery. If the removal is refused, the folder stays, the log names the reason and the
1133
+ # next run stops at the same guard. The tests are builtins, so a normal run forks nothing.
1134
+ if [ -L "$STAGED_FILE" ] || [ -d "$STAGED_FILE" ]; then
1135
+ if memory_staged_remove "$MEMORY_DIR" "$PROJECT_ROOT" "$STAGED_FILE"; then
1136
+ RUN_REMOVED="$STAGED_REMOVED"
1137
+ log "FAIL: a ${STAGED_REMOVED/dir/folder} stood at $STAGED_FILE after the run and was removed; the run is failed"
1138
+ else
1139
+ log "FAIL: a folder or a link stands at $STAGED_FILE after the run and was not removed (${STAGED_REMOVE_REFUSAL}); remove it to resume memory updates"
1140
+ fi
1141
+ fi
1142
+
1143
+ # Logs a run whose Write the permission rules refused, with its closing text <$1>, and
1144
+ # counts the run toward the `denied` notice (two denied runs with no successful update
1145
+ # between them).
1146
+ record_denied_run() {
1147
+ log "FAIL: Write denied by permission rule (${MEMORY_RESULT_DENIALS})$1"
1148
+ memory_denied_seen "$MEMORY_DIR" "$PROJECT_ROOT"
1149
+ return 0
1150
+ }
1151
+
855
1152
  if [ "$CLAUDE_EXIT" -gt 128 ]; then
856
1153
  log "FAIL: claude -p killed by watchdog after ${WATCHDOG_SECS}s"
857
1154
  # Leave .processing for crash recovery (session-start-memory's D56c cold path).
@@ -861,7 +1158,13 @@ if [ "$CLAUDE_EXIT" -gt 128 ]; then
861
1158
  # keeping this plumbing script simple.
862
1159
  exit 0
863
1160
  elif [ "$CLAUDE_EXIT" -ne 0 ]; then
864
- log "FAIL: claude -p exited with code $CLAUDE_EXIT"
1161
+ if [ -n "$RESULT_DENIED" ]; then
1162
+ # A refused Write can end the run non-zero too (the lean form's turn cap), so the
1163
+ # count is named here as well as the exit code.
1164
+ record_denied_run ", exit $CLAUDE_EXIT"
1165
+ else
1166
+ log "FAIL: claude -p exited with code $CLAUDE_EXIT"
1167
+ fi
865
1168
  # Leave .processing for crash recovery (see same rate-bound note above)
866
1169
  exit 0
867
1170
  fi
@@ -939,6 +1242,7 @@ verify_and_swap() {
939
1242
 
940
1243
  OUTCOME="failed"
941
1244
  verify_and_swap
1245
+ RUN_RESULT="$OUTCOME"
942
1246
 
943
1247
  # Each stamp below is a `touch`, which follows a symbolic link, and its path was
944
1248
  # checked before the LLM run, which can take minutes. So, like the swap's target,
@@ -946,6 +1250,8 @@ verify_and_swap
946
1250
  # during the run, and the stamp is skipped and logged (D-HOOKS-NO-SYMLINK, git-marker).
947
1251
  if [ "$OUTCOME" = "updated" ]; then
948
1252
  rm -f "$PROCESSING_FILE" 2>/dev/null || true
1253
+ # A run that swapped the memory in ends any count of runs the write was refused in.
1254
+ memory_notice_clear "$MEMORY_DIR" "$PROJECT_ROOT" denied
949
1255
  if df_no_symlink_below "$PROJECT_ROOT" "$OK_FILE"; then
950
1256
  touch "$OK_FILE" 2>/dev/null || true
951
1257
  log "SUCCESS: queue drained, .last-refresh-ok touched"
@@ -961,6 +1267,9 @@ elif [ "$OUTCOME" = "conflict" ]; then
961
1267
  log "Heartbeat skipped: a symbolic link appeared on the path to $PROCESSING_FILE during the run"
962
1268
  fi
963
1269
  log "CONFLICT: queue retained in .processing, .last-refresh-ok untouched"
1270
+ elif [ -n "$RESULT_DENIED" ]; then
1271
+ # Leave .processing for session-start-memory's D56c cold path; do NOT touch .last-refresh-ok
1272
+ record_denied_run " — leaving .processing for recovery"
964
1273
  else
965
1274
  # Leave .processing for session-start-memory's D56c cold path; do NOT touch .last-refresh-ok
966
1275
  log "FAIL: verification failed — leaving .processing for recovery"