devflow-kit 3.0.1 → 3.2.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 (166) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/agents/git.md +2 -2
  4. package/dist/cli/agents-view/index.js +1 -1
  5. package/dist/cli/agents-view/render.js +71 -17
  6. package/dist/cli/agents-view/state.js +42 -16
  7. package/dist/cli/agents-view/terminal.js +5 -5
  8. package/dist/cli/commands/agents.js +142 -51
  9. package/dist/cli/commands/ambient.js +1 -1
  10. package/dist/cli/commands/attribution-prompts.js +8 -8
  11. package/dist/cli/commands/capture.js +1 -1
  12. package/dist/cli/commands/compliance-prompts.js +8 -8
  13. package/dist/cli/commands/compliance.js +8 -7
  14. package/dist/cli/commands/flags.js +33 -31
  15. package/dist/cli/commands/hud.js +1 -1
  16. package/dist/cli/commands/init-seed.js +9 -9
  17. package/dist/cli/commands/init.js +162 -85
  18. package/dist/cli/commands/install-report.js +10 -10
  19. package/dist/cli/commands/learning.js +302 -136
  20. package/dist/cli/commands/memory.js +36 -15
  21. package/dist/cli/commands/proxy.js +23 -23
  22. package/dist/cli/commands/rules.js +6 -5
  23. package/dist/cli/commands/tracker-prompts.js +6 -6
  24. package/dist/cli/commands/tracker.js +9 -9
  25. package/dist/cli/commands/uninstall.js +183 -59
  26. package/dist/cli/flags-view/render.js +5 -5
  27. package/dist/cli/flags-view/state.js +9 -9
  28. package/dist/cli/flags-view/terminal.js +4 -4
  29. package/dist/cli/tui/cells.js +1 -1
  30. package/dist/cli/tui/terminal.js +6 -6
  31. package/dist/commands/code-review.md +0 -2
  32. package/dist/commands/debug.md +14 -11
  33. package/dist/commands/dynamic-build.md +51 -47
  34. package/dist/commands/dynamic-plan.md +27 -7
  35. package/dist/commands/dynamic-profile.md +17 -3
  36. package/dist/commands/dynamic-tickets.md +18 -4
  37. package/dist/commands/explore.md +9 -3
  38. package/dist/commands/implement.md +20 -16
  39. package/dist/commands/plan.md +13 -9
  40. package/dist/commands/release.md +23 -3
  41. package/dist/commands/research.md +9 -3
  42. package/dist/commands/resolve.md +9 -12
  43. package/dist/commands/self-review.md +0 -2
  44. package/dist/core/agent-frontmatter.js +28 -3
  45. package/dist/core/agent-models.js +204 -42
  46. package/dist/core/agent-state.js +28 -6
  47. package/dist/core/ansi.js +2 -2
  48. package/dist/core/assets.js +1 -1
  49. package/dist/core/cache.js +7 -8
  50. package/dist/core/codex-auth-inspect.js +4 -4
  51. package/dist/core/compliance-compose.js +3 -3
  52. package/dist/core/compliance.js +3 -4
  53. package/dist/core/evidence-policy.js +14 -13
  54. package/dist/core/external-models.js +1 -1
  55. package/dist/core/feature-config.js +71 -13
  56. package/dist/core/feature-switch.js +3 -3
  57. package/dist/core/flags.js +49 -25
  58. package/dist/core/fs-atomic.js +6 -7
  59. package/dist/core/learning-queue-cleanup.js +16 -81
  60. package/dist/core/learning-store.js +61 -0
  61. package/dist/core/linked-path.js +46 -0
  62. package/dist/core/manifest.js +5 -5
  63. package/dist/core/mds-variants.js +13 -13
  64. package/dist/core/model-discovery.js +8 -8
  65. package/dist/core/observations.js +17 -101
  66. package/dist/core/orphan-sweep.js +4 -4
  67. package/dist/core/plugins.js +13 -8
  68. package/dist/core/project-paths.js +9 -13
  69. package/dist/core/proxy-log.js +8 -8
  70. package/dist/core/proxy-state.js +3 -3
  71. package/dist/core/queue-drain.js +31 -0
  72. package/dist/core/reference-sweep.js +6 -6
  73. package/dist/core/teammate-mode-cleanup.js +1 -1
  74. package/dist/core/tracker.js +14 -14
  75. package/dist/hud/colors.js +2 -2
  76. package/dist/hud/components/learning-counts.js +54 -22
  77. package/dist/hud/components/version-badge.js +1 -1
  78. package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
  79. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  80. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  81. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  82. package/dist/targets/claude-code/compliance-install.js +17 -15
  83. package/dist/targets/claude-code/hooks.js +2 -2
  84. package/dist/targets/claude-code/installer.js +59 -32
  85. package/dist/targets/claude-code/legacy.js +1 -1
  86. package/dist/targets/claude-code/post-install.js +135 -45
  87. package/dist/targets/claude-code/tracker-install.js +2 -2
  88. package/package.json +1 -1
  89. package/src/assets/agents/code.md +15 -21
  90. package/src/assets/agents/design.md +4 -2
  91. package/src/assets/agents/diagnose.md +3 -1
  92. package/src/assets/agents/evaluate.md +4 -0
  93. package/src/assets/agents/git.mds +2 -2
  94. package/src/assets/agents/knowledge.md +5 -3
  95. package/src/assets/agents/learning.md +281 -196
  96. package/src/assets/agents/research.md +3 -1
  97. package/src/assets/agents/review.md +5 -3
  98. package/src/assets/agents/scrutinize.md +5 -1
  99. package/src/assets/agents/simplify.md +4 -0
  100. package/src/assets/agents/skim.md +4 -2
  101. package/src/assets/agents/synthesize.md +6 -0
  102. package/src/assets/agents/test.md +18 -10
  103. package/src/assets/agents/triage.md +11 -9
  104. package/src/assets/agents/validate.md +14 -10
  105. package/src/assets/commands/_partials/_decisions.mds +8 -3
  106. package/src/assets/commands/_partials/_docs_root.mds +3 -3
  107. package/src/assets/commands/_partials/_engine.mds +16 -32
  108. package/src/assets/commands/_partials/_knowledge.mds +0 -2
  109. package/src/assets/commands/_partials/_preamble.mds +6 -2
  110. package/src/assets/commands/_partials/_settings.mds +2 -2
  111. package/src/assets/commands/_partials/_tracker.mds +1 -1
  112. package/src/assets/commands/code-review.mds +0 -2
  113. package/src/assets/commands/debug.mds +13 -8
  114. package/src/assets/commands/dynamic-build.mds +18 -12
  115. package/src/assets/commands/dynamic-plan.mds +10 -4
  116. package/src/assets/commands/dynamic-profile.mds +1 -1
  117. package/src/assets/commands/dynamic-tickets.mds +2 -2
  118. package/src/assets/commands/explore.mds +9 -1
  119. package/src/assets/commands/implement.mds +19 -13
  120. package/src/assets/commands/plan.mds +12 -8
  121. package/src/assets/commands/release.md +23 -3
  122. package/src/assets/commands/research.mds +9 -3
  123. package/src/assets/commands/resolve.mds +9 -10
  124. package/src/assets/mds/git/_pr.mds +3 -3
  125. package/src/assets/mds/tracker/_common.mds +1 -1
  126. package/src/assets/mds/tracker/_github.mds +3 -3
  127. package/src/assets/mds/tracker/_jira.mds +3 -3
  128. package/src/assets/mds/tracker/_linear.mds +3 -3
  129. package/src/assets/mds/tracker/_mcp.mds +6 -5
  130. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
  131. package/src/assets/scripts/hooks/background-memory-update +97 -33
  132. package/src/assets/scripts/hooks/capture-prompt +4 -3
  133. package/src/assets/scripts/hooks/capture-question +4 -3
  134. package/src/assets/scripts/hooks/capture-turn +5 -20
  135. package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
  136. package/src/assets/scripts/hooks/ensure-proxy +5 -6
  137. package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
  138. package/src/assets/scripts/hooks/git-marker +71 -0
  139. package/src/assets/scripts/hooks/is-hex-sha +1 -1
  140. package/src/assets/scripts/hooks/json-helper.cjs +345 -944
  141. package/src/assets/scripts/hooks/json-parse +25 -129
  142. package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
  143. package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
  144. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
  145. package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
  146. package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
  147. package/src/assets/scripts/hooks/memory-worker +10 -0
  148. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  149. package/src/assets/scripts/hooks/preamble +9 -1
  150. package/src/assets/scripts/hooks/queue-append +55 -23
  151. package/src/assets/scripts/hooks/resolve-project-root +3 -4
  152. package/src/assets/scripts/hooks/session-start-context +146 -45
  153. package/src/assets/scripts/hooks/session-start-memory +33 -11
  154. package/src/assets/scripts/lib/project-config.cjs +2 -2
  155. package/src/assets/scripts/pr-evidence.cjs +3 -3
  156. package/src/assets/scripts/redact-secrets.cjs +20 -20
  157. package/src/assets/scripts/release-trace.cjs +1 -1
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
  159. package/src/assets/scripts/resolve-settings.cjs +3 -3
  160. package/src/assets/scripts/verify-evidence.cjs +2 -2
  161. package/src/assets/skills/apply-decisions/SKILL.md +37 -17
  162. package/src/assets/skills/docs-framework/SKILL.md +2 -2
  163. package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
  164. package/src/assets/skills/test-driven-development/SKILL.md +6 -4
  165. package/dist/core/observation-io.js +0 -50
  166. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -66,6 +66,16 @@ fi
66
66
  # Auto-create .devflow/ and ensure .gitignore entries (idempotent after first run)
67
67
  source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0
68
68
 
69
+ # D-HOOKS-NO-SYMLINK (git-marker): every write below lands in the memory folder,
70
+ # so a linked folder, which would take the backup and the working memory into the
71
+ # folder it names, ends the hook here. The backup's own target is checked where the
72
+ # backup is renamed onto it.
73
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$MEMORY_DIR"; then
74
+ log "SKIP: a symbolic link sits on the path to $MEMORY_DIR; no backup or working memory written"
75
+ dbg "EXIT: symbolic link on the memory folder path"
76
+ exit 0
77
+ fi
78
+
69
79
  BACKUP_FILE="$MEMORY_DIR/backup.json"
70
80
 
71
81
  # Capture git state
@@ -102,25 +112,60 @@ if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
102
112
  dbg "GIT_BRANCH=$GIT_BRANCH HEAD=$GIT_HEAD_SHA"
103
113
  fi
104
114
 
105
- # Snapshot current WORKING-MEMORY.md (preserves session context through compaction)
115
+ # Snapshot current WORKING-MEMORY.md (preserves session context through compaction).
116
+ # The snapshot goes into the backup the next session start injects, so a working
117
+ # memory a symbolic link leads to is treated as absent (D-HOOKS-NO-SYMLINK).
106
118
  MEMORY_SNAPSHOT=""
107
- if [ -f "$MEMORY_DIR/WORKING-MEMORY.md" ]; then
119
+ if df_file_below "$PROJECT_ROOT" "$MEMORY_DIR/WORKING-MEMORY.md"; then
108
120
  MEMORY_SNAPSHOT=$(head -c 65536 "$MEMORY_DIR/WORKING-MEMORY.md")
109
121
  dbg "MEMORY_SNAPSHOT_LENGTH=${#MEMORY_SNAPSHOT}"
110
122
  fi
111
123
 
112
124
  # Write backup JSON
113
- json_backup_construct \
114
- --arg ts "$TIMESTAMP" \
115
- --arg branch "$GIT_BRANCH" \
116
- --arg status "$GIT_STATUS" \
117
- --arg log "$GIT_LOG" \
118
- --arg diff "$GIT_DIFF_STAT" \
119
- --arg memory "$MEMORY_SNAPSHOT" \
120
- > "$BACKUP_FILE"
121
-
122
- log "Wrote backup: $BACKUP_FILE"
123
- dbg "Wrote backup: $BACKUP_FILE"
125
+ # D-BACKUP-RENAME: the backup is written whole into a copy beside it, then
126
+ # renamed over it; it is never truncated and refilled in place. Reason:
127
+ # session-start-memory reads it twice, and a read landing between a truncate
128
+ # and the write that refills it would meet a partial file; a rename hands every
129
+ # read either the previous backup or the new one. SEC-2: the copy is created
130
+ # under umask 077, and only where nothing stands at its name: builtin tests come
131
+ # first, so an entry already there, a link to a FIFO or a device included, is
132
+ # never opened (noclobber alone would open one, as no regular file stands there),
133
+ # and noclobber then makes the create itself exclusive. The rename gives the
134
+ # backup the copy's inode and mode, so the backup, which holds the working
135
+ # memory, is 0600 whatever the caller's umask (as in queue-append). A write or
136
+ # rename that fails removes whatever holds the copy's name and keeps the
137
+ # previous backup; the bootstrap below still runs.
138
+ #
139
+ # The rename's target is checked first (D-HOOKS-NO-SYMLINK, git-marker): `mv`
140
+ # moves the copy into a folder a link at backup.json names, so a link there, to
141
+ # anything, skips the backup with nothing created, logged once, and the
142
+ # bootstrap below still runs.
143
+ #
144
+ # A run killed after creating its copy and before renaming it (by the hook's
145
+ # timeout, say) leaves the copy under its own PID, a name no later run writes,
146
+ # so each run first removes the copies nothing has written to for an hour. A
147
+ # write ends within the hook's 10 s timeout, so no live run's copy is that old
148
+ # and a concurrent run's copy is left alone.
149
+ find "$MEMORY_DIR" -maxdepth 1 -type f -name "${BACKUP_FILE##*/}.tmp.*" -mmin +60 -delete 2>/dev/null || true
150
+ BACKUP_TMP="$BACKUP_FILE.tmp.$$"
151
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$BACKUP_FILE"; then
152
+ log "Backup not written: a symbolic link sits on the path to $BACKUP_FILE"
153
+ dbg "Backup not written: symbolic link at $BACKUP_FILE"
154
+ elif [ ! -e "$BACKUP_TMP" ] && [ ! -L "$BACKUP_TMP" ] && (umask 077 && set -o noclobber && json_backup_construct \
155
+ --arg ts "$TIMESTAMP" \
156
+ --arg branch "$GIT_BRANCH" \
157
+ --arg status "$GIT_STATUS" \
158
+ --arg log "$GIT_LOG" \
159
+ --arg diff "$GIT_DIFF_STAT" \
160
+ --arg memory "$MEMORY_SNAPSHOT" \
161
+ > "$BACKUP_TMP") && mv "$BACKUP_TMP" "$BACKUP_FILE"; then
162
+ log "Wrote backup: $BACKUP_FILE"
163
+ dbg "Wrote backup: $BACKUP_FILE"
164
+ else
165
+ rm -f "$BACKUP_TMP" 2>/dev/null || true
166
+ log "Backup not written; the previous one is kept: $BACKUP_FILE"
167
+ dbg "Backup not written: $BACKUP_FILE"
168
+ fi
124
169
 
125
170
  # Bootstrap minimal WORKING-MEMORY.md if absent; skip on an unborn branch or a
126
171
  # malformed SHA. is_hex_sha 40 40: exactly 40 lowercase hex chars required. A detached
@@ -131,9 +176,16 @@ dbg "Wrote backup: $BACKUP_FILE"
131
176
  # avoids REL-5: O_EXCL-style atomic create via noclobber so the existence test and the
132
177
  # create are one operation — if the worker's CAS mv lands in the window, noclobber fails
133
178
  # (file already exists) and we skip the bootstrap rather than truncating fresh memory.
179
+ # D-HOOKS-NO-SYMLINK (git-marker): the create is made only where nothing stands, tested
180
+ # with builtins first, since noclobber alone opens a link to a FIFO or a device (no
181
+ # regular file stands there) and would wait on the FIFO or write the text into the
182
+ # device. A link there is left as it was and logged; the memory folder above it was
183
+ # checked at the top.
134
184
  MEMORY_FILE="$MEMORY_DIR/WORKING-MEMORY.md"
135
185
  if [ -n "$GIT_BRANCH" ] && is_hex_sha "$GIT_HEAD_SHA" 40 40; then
136
- if (set -o noclobber; : > "$MEMORY_FILE") 2>/dev/null; then
186
+ if [ -L "$MEMORY_FILE" ]; then
187
+ log "Working memory not bootstrapped: $MEMORY_FILE is a symbolic link"
188
+ elif [ ! -e "$MEMORY_FILE" ] && (set -o noclobber; : > "$MEMORY_FILE") 2>/dev/null; then
137
189
  {
138
190
  echo "<!-- memory-head: $GIT_HEAD_SHA branch: $GIT_BRANCH -->"
139
191
  echo ""
@@ -62,7 +62,15 @@ if [ -z "$HEAD" ]; then
62
62
  dbg "EXIT: empty prompt"
63
63
  elif [[ "$HEAD" == "Implement the following plan:"* ]]; then
64
64
  dbg "PLAN_HANDOFF detected — injecting devflow:implement directive"
65
- json_prompt_output "The user's prompt is a plan handoff (it begins with \`Implement the following plan:\`). In one short sentence, tell the user you're invoking \`devflow:implement\`. Then immediately invoke it with the Skill tool, passing the full plan (everything after the handoff prefix) as the skill input so it can be executed. Do not pause to ask whether to proceed."
65
+ # D-ARGS-ONCE: the directive passes the Skill call NO arguments. The handoff
66
+ # prompt IS the plan, so it is already in the conversation; handing it to the
67
+ # skill as an argument would put a second copy in the main-thread context for
68
+ # nothing. The skill's empty-input path takes the plan from the conversation.
69
+ # No plan body or plan path is parsed here: detection stays the literal prefix.
70
+ # This string is double-quoted bash: it must hold no dollar sign and no
71
+ # unescaped backtick, and it must equal HANDOFF_TEMPLATE in
72
+ # tests/fixtures/ambient-templates.ts byte for byte.
73
+ json_prompt_output "The user's prompt is a plan handoff (it begins with \`Implement the following plan:\`). In one short sentence, tell the user you're invoking \`devflow:implement\`. Then immediately invoke it with the Skill tool and no arguments: the plan is already in this conversation, so the skill needs no input. Do not pause to ask whether to proceed."
66
74
  elif [[ "$HEAD" == "/"* ]]; then
67
75
  dbg "EXIT: slash command — no reminder"
68
76
  else
@@ -8,21 +8,28 @@
8
8
  #
9
9
  # Source-order requirement: source json-parse (for _HAS_JQ) and learning-lock (for
10
10
  # learning_lock_acquire/learning_lock_release, which itself requires get-mtime) BEFORE
11
- # calling queue_append_row or queue_append_both. Sourcing this file itself only
11
+ # calling queue_append_row or queue_append_both, and define log/dbg. This file
12
+ # sources its sibling git-marker itself, for df_no_symlink_below. Sourcing it only
12
13
  # defines functions -- no side effects until they are called.
13
14
  #
14
- # queue_append_row <queue_file> <role> <content> <ts>
15
- # Appends one JSONL row {role, content, ts} to <queue_file>. Creates the file
16
- # with mode 0600 (umask 077) if absent. After appending, truncates from 200 to
17
- # the newest 100 lines under a lock (learning_lock_acquire on "<queue_file>.lock",
18
- # 2s timeout). The append itself is intentionally lock-free (accepted-class
19
- # race shared with the pre-existing memory design -- see the design doc's
20
- # "Append-vs-claim race" note).
15
+ # queue_append_row <root> <queue_file> <role> <content> <ts>
16
+ # Appends one JSONL row {role, content, ts} to <queue_file>, which lies below
17
+ # <root>: the project root for the memory queue, the ledger root for the learning
18
+ # queue. When the queue, or a folder between <root> and it, is a symbolic link,
19
+ # nothing is written: the skip is logged once and the call returns 0, as a
20
+ # skipped capture does (D-HOOKS-NO-SYMLINK, git-marker). Otherwise it
21
+ # creates the queue's folder if absent, and the file with mode 0600 (umask 077).
22
+ # After appending, truncates from 200 to the newest 100 lines under a lock
23
+ # (learning_lock_acquire on "<queue_file>.lock", 2s timeout), keeping mode 0600.
24
+ # The append itself is intentionally lock-free (accepted-class race shared with
25
+ # the pre-existing memory design -- see the design doc's "Append-vs-claim race"
26
+ # note).
21
27
  #
22
- # queue_append_both <memory_queue> <learning_queue> <memory_enabled> <learning_enabled> <role> <content> <ts>
23
- # Calls queue_append_row for each queue whose *_enabled flag is "true". Each
24
- # queue is gated independently -- callers compute memory_enabled/learning_enabled
25
- # themselves (see queue_read_gates below for the at-most-one-fork read of both).
28
+ # queue_append_both <memory_root> <memory_queue> <learning_root> <learning_queue> <memory_enabled> <learning_enabled> <role> <content> <ts>
29
+ # Calls queue_append_row for each queue whose *_enabled flag is "true", with the
30
+ # root that queue lies below. Each queue is gated independently -- callers
31
+ # compute memory_enabled/learning_enabled themselves (see queue_read_gates below
32
+ # for the at-most-one-fork read of both).
26
33
  #
27
34
  # queue_read_gates <manifest_path> [<root>]
28
35
  # Reads BOTH switches, `memory` and `learning`, in at most ONE subprocess fork
@@ -42,7 +49,7 @@
42
49
  # `devflow memory|learning --enable/--disable` write. Only an
43
50
  # explicit boolean `false` switches a feature off -- an absent,
44
51
  # unreadable or malformed manifest, a missing key, or a non-boolean
45
- # value leaves it ON (fail-open, ADR-028), the rule
52
+ # value leaves it ON (fail-open), the rule
46
53
  # isMachineFeatureOn() applies in the CLI.
47
54
  # project <root>/.devflow/project.json (team-committed) and
48
55
  # personal <root>/.devflow/config.json (this worktree's own), read ONLY under
@@ -60,7 +67,7 @@
60
67
  #
61
68
  # D-LEARNING-LEGACY-DECISIONS: in the MANIFEST only, learning reads
62
69
  # `features.learning` when it is a boolean, else the pre-rename
63
- # `features.decisions` (ADR-011) -- readManifest's migration precedence
70
+ # `features.decisions` -- readManifest's migration precedence
64
71
  # exactly, so a legacy `decisions: false` switches learning off before any
65
72
  # command has healed the file. Only a boolean `false` in whichever key decides
66
73
  # is off, as above.
@@ -72,8 +79,20 @@
72
79
  # root (D-ONE-HOME); memory-worker hands its resolved path to the background
73
80
  # worker it spawns.
74
81
 
82
+ # queue_append_row checks its queue with df_no_symlink_below, so this helper sources
83
+ # git-marker itself rather than trusting every caller to have done so first. One that
84
+ # failed to source is a command not found: the refusing branch, so nothing is written.
85
+ source "${BASH_SOURCE[0]%/*}/git-marker" 2>/dev/null || true
86
+
75
87
  queue_append_row() {
76
- local _qar_file="$1" _qar_role="$2" _qar_content="$3" _qar_ts="$4"
88
+ local _qar_root="$1" _qar_file="$2" _qar_role="$3" _qar_content="$4" _qar_ts="$5"
89
+
90
+ # D-HOOKS-NO-SYMLINK (git-marker): a skipped append is a skipped capture.
91
+ if ! df_no_symlink_below "$_qar_root" "$_qar_file"; then
92
+ log "Queue skipped: a symbolic link sits on the path to $_qar_file; nothing written"
93
+ return 0
94
+ fi
95
+ mkdir -p "${_qar_file%/*}" 2>/dev/null || true
77
96
 
78
97
  if [ ! -f "$_qar_file" ]; then
79
98
  (umask 077 && touch "$_qar_file") 2>/dev/null || true
@@ -97,9 +116,21 @@ queue_append_row() {
97
116
  _qar_lines=$(wc -l < "$_qar_file" | tr -d ' ')
98
117
  if [ "$_qar_lines" -gt 200 ]; then
99
118
  local _qar_tmp="${_qar_file}.tmp.$$"
100
- tail -100 "$_qar_file" > "$_qar_tmp" && mv "$_qar_tmp" "$_qar_file" || rm -f "$_qar_tmp"
101
- log "Queue overflow: truncated from $_qar_lines to 100 lines ($(basename "$_qar_file"))"
102
- dbg "Queue overflow: truncated from $_qar_lines to 100 lines ($_qar_file)"
119
+ # SEC-2: the trimmed copy is created under umask 077, so the renamed
120
+ # queue keeps mode 0600. mv replaces the inode, so the queue takes the
121
+ # copy's mode, and a chmod after the mv would leave a window. The copy is
122
+ # created only where nothing stands at its name: builtin tests come first,
123
+ # so an entry already there, a link to a FIFO or a device included, is
124
+ # never opened (noclobber alone would open one, as no regular file stands
125
+ # there), and noclobber then makes the create itself exclusive. Such an
126
+ # entry is removed instead, and the trim waits for a later append.
127
+ if [ ! -e "$_qar_tmp" ] && [ ! -L "$_qar_tmp" ] && (umask 077 && set -o noclobber && tail -100 "$_qar_file" > "$_qar_tmp") && mv "$_qar_tmp" "$_qar_file"; then
128
+ log "Queue overflow: truncated from $_qar_lines to 100 lines ($(basename "$_qar_file"))"
129
+ dbg "Queue overflow: truncated from $_qar_lines to 100 lines ($_qar_file)"
130
+ else
131
+ rm -f "$_qar_tmp" 2>/dev/null || true
132
+ log "Queue overflow: not truncated, its copy could not be written ($(basename "$_qar_file"))"
133
+ fi
103
134
  fi
104
135
  learning_lock_release "$_qar_lock"
105
136
  fi
@@ -108,15 +139,16 @@ queue_append_row() {
108
139
  }
109
140
 
110
141
  queue_append_both() {
111
- local _qab_memory_queue="$1" _qab_learning_queue="$2"
112
- local _qab_memory_enabled="$3" _qab_learning_enabled="$4"
113
- local _qab_role="$5" _qab_content="$6" _qab_ts="$7"
142
+ local _qab_memory_root="$1" _qab_memory_queue="$2"
143
+ local _qab_learning_root="$3" _qab_learning_queue="$4"
144
+ local _qab_memory_enabled="$5" _qab_learning_enabled="$6"
145
+ local _qab_role="$7" _qab_content="$8" _qab_ts="$9"
114
146
 
115
147
  if [ "$_qab_memory_enabled" = "true" ]; then
116
- queue_append_row "$_qab_memory_queue" "$_qab_role" "$_qab_content" "$_qab_ts"
148
+ queue_append_row "$_qab_memory_root" "$_qab_memory_queue" "$_qab_role" "$_qab_content" "$_qab_ts"
117
149
  fi
118
150
  if [ "$_qab_learning_enabled" = "true" ]; then
119
- queue_append_row "$_qab_learning_queue" "$_qab_role" "$_qab_content" "$_qab_ts"
151
+ queue_append_row "$_qab_learning_root" "$_qab_learning_queue" "$_qab_role" "$_qab_content" "$_qab_ts"
120
152
  fi
121
153
  }
122
154
 
@@ -56,10 +56,9 @@ _df_nongit_root() {
56
56
  # DF_LEDGER_ROOT — where the learning ledger and its queue live.
57
57
  #
58
58
  # D-LEDGER-MAIN-WORKTREE: a linked worktree (`git worktree add`, `claude
59
- # --worktree`) has its own toplevel, so a ledger anchored there restarts at
60
- # ADR-001 — main's decisions are invisible in it, and the IDs it mints collide
61
- # with main's (in a repo whose collision guard refuses, the Learning agent stops
62
- # for good). The ledger is one per REPOSITORY, not per checkout: DF_LEDGER_ROOT
59
+ # --worktree`) has its own toplevel, so a ledger anchored there restarts its
60
+ # numbering at 001 — main's decisions are invisible in it, and the IDs it mints collide
61
+ # with main's. The ledger is one per REPOSITORY, not per checkout: DF_LEDGER_ROOT
63
62
  # is the main worktree — the parent of `--git-common-dir` — when that common dir
64
63
  # is a `<dir>/.git` directory AND `<dir>/.devflow` already exists; otherwise it is
65
64
  # DF_ROOT. The existence test keeps a main checkout that never ran devflow from
@@ -8,7 +8,8 @@
8
8
  # not the home directory (D-HOOKS-GIT-ONLY), and they read the learning ledger of
9
9
  # the main worktree when this is a linked one (D-LEDGER-MAIN-WORKTREE).
10
10
  #
11
- # Section 1: Project decisions TL;DR (decisions.md / pitfalls.md header lines).
11
+ # Section 1: Project decisions TL;DR (decisions.md / pitfalls.md header lines),
12
+ # then the path of the decisions index when it lists entries.
12
13
  # Section 2: Learning maintenance directive — when captured turns are pending in
13
14
  # the learning queue (or a crashed run left a stale .processing batch), instructs
14
15
  # the main model to spawn the background Learning agent with the resolved model.
@@ -57,6 +58,11 @@ fi
57
58
  devflow_debug_set_cwd "$CWD"
58
59
  dbg "CWD=$CWD"
59
60
 
61
+ # Normal logging, set up before the carve-out below: its skips, and those of the
62
+ # ensure-root-gitignore it sources, are written with log(), and with memory and
63
+ # learning both off this hook is the only one that reaches the carve-out.
64
+ source "$SCRIPT_DIR/hook-log-init" "session-start-context"
65
+
60
66
  # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this
61
67
  # hook runs with a CWD inside .devflow/...). One git call yields both roots
62
68
  # (resolve-project-root): PROJECT_ROOT is this checkout's toplevel, LEDGER_ROOT the
@@ -85,10 +91,18 @@ fi
85
91
  # Ensure the project root .gitignore ignores .devflow/ wholesale. This runs on every
86
92
  # git-project session regardless of feature toggles, so memory-off projects
87
93
  # (learning/knowledge only) still get .devflow/ ignored — this is the
88
- # memory-independent path that fixes the gitignore/memory coupling (PF-014). Single
94
+ # memory-independent path that fixes the gitignore/memory coupling. Single
89
95
  # source of truth: ensure-root-gitignore. Soft-fail: a gitignore write must never
90
- # block context injection. Marker keeps it O(1).
91
- [ -n "$PROJECT_OK" ] && [ -d "$PROJECT_ROOT" ] && [ -f "$SCRIPT_DIR/ensure-root-gitignore" ] && source "$SCRIPT_DIR/ensure-root-gitignore" "$PROJECT_ROOT" || true
96
+ # block context injection. Marker keeps it O(1). A `.devflow` that is a symbolic
97
+ # link is skipped, since the marker would land in the folder the link names, and
98
+ # the skip is logged once (D-HOOKS-NO-SYMLINK, git-marker).
99
+ if [ -n "$PROJECT_OK" ] && [ -d "$PROJECT_ROOT" ] && [ -f "$SCRIPT_DIR/ensure-root-gitignore" ]; then
100
+ if df_no_symlink_below "$PROJECT_ROOT" "$PROJECT_ROOT/.devflow"; then
101
+ source "$SCRIPT_DIR/ensure-root-gitignore" "$PROJECT_ROOT" || true
102
+ else
103
+ log "Skipped: $PROJECT_ROOT/.devflow is a symbolic link; nothing written under it"
104
+ fi
105
+ fi
92
106
 
93
107
  CONTEXT=""
94
108
 
@@ -101,7 +115,8 @@ TRACKER_DEVFLOW_DIR="$HOME/.devflow"
101
115
  #
102
116
  # Section 2 embeds $LEDGER_ROOT; Section 3 embeds $PROJECT_ROOT and
103
117
  # $TRACKER_DEVFLOW_DIR — each inside a double-quoted `prompt: "..."` string the
104
- # model reads out of additionalContext. The provider and model TOKENS in those
118
+ # model reads out of additionalContext. Section 1's index line embeds
119
+ # $LEDGER_ROOT too, as the path the model reads the decisions index from. The provider and model TOKENS in those
105
120
  # directives are admitted by positive allowlists, and so are these: the gate is the
106
121
  # POSITIVE shape ^[A-Za-z0-9/._+-]+$, expressed as "rejects if any character falls
107
122
  # outside it".
@@ -134,16 +149,16 @@ TRACKER_DEVFLOW_DIR="$HOME/.devflow"
134
149
  # admitted set is locale-dependent, and no rule here rests on it.
135
150
  #
136
151
  # Checked ONCE, here, where every value is resolved and above every section that
137
- # interpolates them, so no sink can embed a value no gate saw (PF-023 — the
152
+ # interpolates them, so no sink can embed a value no gate saw (the
138
153
  # invariant belongs at the convergence point all callers pass through, not in
139
154
  # whichever section someone remembered). `case` is a shell builtin, so the GitHub
140
155
  # path still forks zero times [DR-10]. The empty arm is explicit: an unset root
141
156
  # must not read as "no forbidden character, therefore safe".
142
157
  #
143
158
  # ONE gate per VALUE, applied to the root each section embeds, not one over their
144
- # concatenation. Section 2 interpolates $LEDGER_ROOT alone — the main worktree's
145
- # root in a linked worktree (D-LEDGER-MAIN-WORKTREE), so it is the value checked
146
- # for that section, not the checkout's own; only Section 3 interpolates
159
+ # concatenation. Sections 1 and 2 interpolate $LEDGER_ROOT alone — the main
160
+ # worktree's root in a linked worktree (D-LEDGER-MAIN-WORKTREE), so it is the value
161
+ # checked for them, not the checkout's own; only Section 3 interpolates
147
162
  # $PROJECT_ROOT and $TRACKER_DEVFLOW_DIR. Gating values jointly made a rejected
148
163
  # ~/.devflow shape suppress the Learning directive as well — a value Section 2
149
164
  # never embeds, silently disabling the whole learning pipeline for any machine
@@ -178,9 +193,6 @@ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
178
193
  LEDGER_DEVFLOW_DIR="$LEDGER_ROOT/.devflow"
179
194
  LEARNING_DIR="$LEDGER_DEVFLOW_DIR/learning"
180
195
 
181
- # Normal logging
182
- source "$SCRIPT_DIR/hook-log-init" "session-start-context"
183
-
184
196
  # --- Learning gate: the machine switch, narrowed by this checkout ---
185
197
  # D-FEATURES-NARROW-ONLY (see queue-append): ~/.devflow/manifest.json's
186
198
  # features.learning, narrowed by PROJECT_ROOT's project.json / config.json, is
@@ -197,24 +209,52 @@ LEARNING_ENABLED="$_QG_LEARNING"
197
209
 
198
210
  # --- Section 1: Project Decisions TL;DR ---
199
211
  if [ "$LEARNING_ENABLED" = "true" ]; then
200
- # Heal older installs that have .devflow/ but not .devflow/learning/
212
+ # Heal older installs that have .devflow/ but not .devflow/learning/, never
213
+ # through a symbolic link (D-HOOKS-NO-SYMLINK, git-marker).
201
214
  if [ -d "$LEDGER_DEVFLOW_DIR" ] && [ ! -d "$LEARNING_DIR" ]; then
202
- mkdir -p "$LEARNING_DIR" 2>/dev/null || true
215
+ if df_no_symlink_below "$LEDGER_ROOT" "$LEARNING_DIR"; then
216
+ mkdir -p "$LEARNING_DIR" 2>/dev/null || true
217
+ else
218
+ log "Skipped: a symbolic link sits on the path to $LEARNING_DIR; nothing written"
219
+ fi
203
220
  fi
204
221
  if [ -d "$LEARNING_DIR" ]; then
222
+ # D-HOOKS-NO-SYMLINK (git-marker): this section and the next read the learning
223
+ # files into the context, or name them to the model and the Learning agent, so
224
+ # each is read only where no symbolic link sits on its path; a file a link leads
225
+ # to is treated as absent, and its refusal logged once.
205
226
  DECISIONS_TLDR=""
206
227
  for kf in "$LEARNING_DIR"/decisions.md "$LEARNING_DIR"/pitfalls.md; do
207
- if [ -f "$kf" ]; then
228
+ if df_file_below "$LEDGER_ROOT" "$kf"; then
208
229
  TLDR_LINE=$(sed -n '1s/<!-- TL;DR: \(.*\) -->/\1/p' "$kf")
209
230
  if [ -n "$TLDR_LINE" ]; then
210
231
  DECISIONS_TLDR="${DECISIONS_TLDR}${TLDR_LINE}\n"
211
232
  fi
212
233
  fi
213
234
  done
214
- if [ -n "$DECISIONS_TLDR" ]; then
215
- dbg "Decisions TL;DR found"
216
- DECISIONS_SECTION="--- PROJECT DECISIONS (TL;DR) ---
235
+ # The index line names the decisions index, so the main model can pass it on
236
+ # as DECISIONS_CONTEXT. It interpolates $LEDGER_ROOT, so it needs that root's
237
+ # shape gate, and it is left out when the index lists no entry: an empty
238
+ # corpus renders `(none)`. It is left out too when a link leads to the index,
239
+ # since the model would read the file the link names and pass that on.
240
+ # Builtins only: no fork, save the log line of a refused index.
241
+ _SC_INDEX="$LEARNING_DIR/index.md"; DECISIONS_INDEX_LINE=""
242
+ if [ -n "$DIRECTIVE_LEDGER_SAFE" ] && df_file_below "$LEDGER_ROOT" "$_SC_INDEX" && [ -s "$_SC_INDEX" ]; then
243
+ _SC_IDX1=""; IFS= read -r _SC_IDX1 < "$_SC_INDEX" || true
244
+ [ "$_SC_IDX1" != "(none)" ] && DECISIONS_INDEX_LINE="Index: $_SC_INDEX"
245
+ fi
246
+ if [ -n "$DECISIONS_TLDR" ] || [ -n "$DECISIONS_INDEX_LINE" ]; then
247
+ dbg "Decisions TL;DR or index found"
248
+ DECISIONS_SECTION="--- PROJECT DECISIONS (TL;DR) ---"
249
+ if [ -n "$DECISIONS_TLDR" ]; then
250
+ DECISIONS_SECTION="${DECISIONS_SECTION}
217
251
  $(printf '%b' "$DECISIONS_TLDR")"
252
+ fi
253
+ # The index line goes last.
254
+ if [ -n "$DECISIONS_INDEX_LINE" ]; then
255
+ DECISIONS_SECTION="${DECISIONS_SECTION}
256
+ ${DECISIONS_INDEX_LINE}"
257
+ fi
218
258
  if [ -n "$CONTEXT" ]; then
219
259
  CONTEXT="${CONTEXT}
220
260
 
@@ -227,18 +267,23 @@ ${DECISIONS_SECTION}"
227
267
  fi
228
268
 
229
269
  # --- Section 2: Learning maintenance directive ---
230
- # Emitted when captured turns are waiting: queue non-empty, or a leftover
231
- # .processing batch whose owner crashed (stale mtime, 900s threshold — same
232
- # family the Learning agent itself uses to discriminate live from crashed). A
233
- # FRESH .processing means a live Learning agent already owns the batch, so the
234
- # directive is suppressed even if new turns queued since its claim.
270
+ # Emitted when captured turns are waiting: the queue is non-empty, or a
271
+ # .processing claim has had no heartbeat for PROCESSING_STALE_SECS and its batch
272
+ # needs a new owner. A FRESH .processing means a live Learning agent holds the
273
+ # claim, so the directive is suppressed even if new turns queued since. This
274
+ # check is advisory and decides only whether to spawn: the claim itself is taken
275
+ # by json-helper's claim-queue op under the learning lock, which applies the same
276
+ # threshold (D-OWNED-CLAIM, lib/learning-store.cjs), so a spawn that loses a race
277
+ # exits on `busy`.
235
278
  if [ "$LEARNING_ENABLED" = "true" ]; then
236
279
  QUEUE_FILE="$LEARNING_DIR/.pending-turns.jsonl"
237
280
  PROCESSING_FILE="$LEARNING_DIR/.pending-turns.processing"
238
281
  PROCESSING_STALE_SECS=900
239
282
 
283
+ # The queue and the batch are what the directive sends the Learning agent to, so
284
+ # one a symbolic link leads to counts as absent (D-HOOKS-NO-SYMLINK, Section 1).
240
285
  LEARNING_WORK=""
241
- if [ -f "$PROCESSING_FILE" ]; then
286
+ if df_file_below "$LEDGER_ROOT" "$PROCESSING_FILE"; then
242
287
  source "$SCRIPT_DIR/get-mtime" 2>/dev/null || true
243
288
  _SC_PROC_MTIME=$(get_mtime "$PROCESSING_FILE" 2>/dev/null || true)
244
289
  _SC_NOW=$(date +%s)
@@ -247,7 +292,7 @@ if [ "$LEARNING_ENABLED" = "true" ]; then
247
292
  else
248
293
  dbg "learning directive suppressed: fresh .processing (live agent owns the batch)"
249
294
  fi
250
- elif [ -s "$QUEUE_FILE" ]; then
295
+ elif df_file_below "$LEDGER_ROOT" "$QUEUE_FILE" && [ -s "$QUEUE_FILE" ]; then
251
296
  LEARNING_WORK="queue"
252
297
  fi
253
298
 
@@ -271,28 +316,83 @@ ${LEARNING_PAUSED_SECTION}"
271
316
  fi
272
317
 
273
318
  if [ -n "$LEARNING_WORK" ]; then
274
- # Model resolution: project learning.json → global ~/.devflow/learning.json → opus
319
+ # D-LEARNING-MODEL-PRECEDENCE — the model of the spawn. The first layer that
320
+ # supplies a model decides:
321
+ # 1. a valid project learning.json "model" -> model="<value>"
322
+ # 2. agents.learning.model in $HOME/.devflow/agent-models.json,
323
+ # the `devflow agents` Learning mapping -> no model=
324
+ # 3. a valid $HOME/.devflow/learning.json "model" -> model="<value>"
325
+ # 4. none -> no model=
326
+ # With no model= the spawn takes the model of the installed Learning agent's
327
+ # frontmatter, which is where a `devflow agents` mapping is applied, so the
328
+ # mapping reaches the spawn. There is no fallback tier in this hook: the
329
+ # shipped tier lives in that frontmatter alone.
330
+ #
331
+ # Only layers 1 and 3 interpolate, and only after the opus|sonnet|haiku
332
+ # allowlist (defense in depth: learning.json is user/config-controlled, and a
333
+ # value with newlines or quotes must never inject text into the SessionStart
334
+ # context). Layer 2 only decides precedence and the value is never read into
335
+ # the directive. It counts only when agents.learning.model is a JSON STRING
336
+ # that is a model name by the rule readAgentMapping (src/core/agent-models.ts)
337
+ # keeps a model by, MODEL_NAME_RE in src/core/agent-frontmatter.ts:
338
+ # ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. Anything readAgentMapping drops, a number,
339
+ # boolean, object, array, empty or out-of-charset string, is a mapping that
340
+ # supplies no model: the installed frontmatter keeps the shipped tier, so the
341
+ # layer reads as absent and the lookup falls through to the global file rather
342
+ # than silently ignoring it for a model nothing will run. The type is read by
343
+ # json_string_field_file, which keeps the number 42 apart from the string "42"
344
+ # on both the jq and the node backend. An entry with an effort alone supplies
345
+ # no model either, so it does not displace a global learning.json. An invalid
346
+ # layer-1 or layer-3 value counts as absent and falls through. A missing,
347
+ # unreadable or malformed agent-models.json reads as empty (the typed read
348
+ # discards its errors), so the mapping layer is absent. A dormant external
349
+ # mapping model, any name of that charset, still decides: its model is inactive
350
+ # while the proxy is off and the shipped tier runs, but this hook does not know
351
+ # the proxy state.
352
+ # A project learning.json a symbolic link leads to is absent (D-HOOKS-NO-SYMLINK).
353
+ # The machine root is $HOME/.devflow and nothing relocates it.
275
354
  LEARNING_MODEL=""
276
- if [ -f "$LEARNING_DIR/learning.json" ]; then
277
- LEARNING_MODEL=$(json_field_file "$LEARNING_DIR/learning.json" "model" "")
355
+ LEARNING_MODEL_LAYER=""
356
+ if df_file_below "$LEDGER_ROOT" "$LEARNING_DIR/learning.json"; then
357
+ _SC_LM=$(json_field_file "$LEARNING_DIR/learning.json" "model" "")
358
+ case "$_SC_LM" in
359
+ opus|sonnet|haiku) LEARNING_MODEL="$_SC_LM"; LEARNING_MODEL_LAYER="project" ;;
360
+ esac
361
+ fi
362
+ if [ -z "$LEARNING_MODEL_LAYER" ] && [ -f "$HOME/.devflow/agent-models.json" ]; then
363
+ # The sentinel keeps a trailing newline the substitution would strip, so the
364
+ # name is tested exactly as stored. The first character is a letter or digit,
365
+ # the rest add . _ -, and the whole is 64 characters at most. The sets are
366
+ # spelled out because a range such as A-Za-z matches accented letters in a
367
+ # UTF-8 locale on bash 3.2.
368
+ _SC_LM=$(json_string_field_file "$HOME/.devflow/agent-models.json" "agents.learning.model"; printf x)
369
+ _SC_LM=${_SC_LM%x}
370
+ _SC_NAME_HEAD='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
371
+ _SC_NAME_TAIL="${_SC_NAME_HEAD}._-"
372
+ case "$_SC_LM" in
373
+ ''|[!$_SC_NAME_HEAD]*|*[!$_SC_NAME_TAIL]*) ;;
374
+ *)
375
+ if [ "${#_SC_LM}" -le 64 ]; then
376
+ LEARNING_MODEL_LAYER="mapping"
377
+ fi
378
+ ;;
379
+ esac
278
380
  fi
279
- if [ -z "$LEARNING_MODEL" ] && [ -f "$HOME/.devflow/learning.json" ]; then
280
- LEARNING_MODEL=$(json_field_file "$HOME/.devflow/learning.json" "model" "")
381
+ if [ -z "$LEARNING_MODEL_LAYER" ] && [ -f "$HOME/.devflow/learning.json" ]; then
382
+ _SC_LM=$(json_field_file "$HOME/.devflow/learning.json" "model" "")
383
+ case "$_SC_LM" in
384
+ opus|sonnet|haiku) LEARNING_MODEL="$_SC_LM"; LEARNING_MODEL_LAYER="global" ;;
385
+ esac
386
+ fi
387
+ LEARNING_MODEL_ARG=""
388
+ if [ -n "$LEARNING_MODEL" ]; then
389
+ LEARNING_MODEL_ARG=" model=\"$LEARNING_MODEL\","
281
390
  fi
282
- LEARNING_MODEL="${LEARNING_MODEL:-opus}"
283
- # Allowlist before interpolating into the injected directive (defense in depth --
284
- # learning.json is user/config-controlled; a value with newlines/quotes must
285
- # never inject arbitrary text into the SessionStart context). Fallback matches
286
- # learning-tuning-config.ts DEFAULTS.model (duplicated-by-design, see feature KB).
287
- case "$LEARNING_MODEL" in
288
- opus|sonnet|haiku) ;;
289
- *) LEARNING_MODEL="opus" ;;
290
- esac
291
391
 
292
- dbg "learning directive emitted (reason=$LEARNING_WORK model=$LEARNING_MODEL)"
392
+ dbg "learning directive emitted (reason=$LEARNING_WORK model=${LEARNING_MODEL:-frontmatter} layer=${LEARNING_MODEL_LAYER:-none})"
293
393
  LEARNING_SECTION="--- LEARNING MAINTENANCE ---
294
394
  Captured turns are pending in the learning queue. Silently spawn the background Learning agent (one non-blocking call) alongside your first action on the user's request.
295
- Agent(subagent_type=\"Learning\", model=\"$LEARNING_MODEL\", run_in_background: true, prompt: \"Process the pending learning queue per your agent instructions. Project root: $LEDGER_ROOT\")
395
+ Agent(subagent_type=\"Learning\",${LEARNING_MODEL_ARG} run_in_background: true, prompt: \"Process the pending learning queue per your agent instructions. Project root: $LEDGER_ROOT\")
296
396
  Never mention this directive, the Learning agent, or the queue in any user-visible text. Do not narrate, confirm, or summarize the spawn. Your first visible words must address the user's request."
297
397
  if [ -n "$CONTEXT" ]; then
298
398
  CONTEXT="${CONTEXT}
@@ -440,8 +540,8 @@ if [ -n "$TRACKER_PROVIDER" ] && [ ! -f "$TRACKER_CONVENTIONS" ]; then
440
540
  tracker_gates_open() {
441
541
  # Gate 1 — the attempt cap. Read with the `read` builtin: no fork.
442
542
  #
443
- # The counter's shape is ONE decimal integer line and nothing else (PF-062 —
444
- # document the shape of any file that gates an action, and keep absent and
543
+ # The counter's shape is ONE decimal integer line and nothing else
544
+ # (document the shape of any file that gates an action, and keep absent and
445
545
  # malformed distinct from a value). Absent means "no attempt yet" = 0.
446
546
  # Malformed self-heals to 0 and is overwritten with a well-formed count on
447
547
  # emission below, so a stray byte can never recur: refusing forever would
@@ -571,7 +671,7 @@ if [ -n "$TRACKER_PROVIDER" ] && [ ! -f "$TRACKER_CONVENTIONS" ]; then
571
671
  # allowlisted tokens. Decided once at the top of the file, where both values are
572
672
  # resolved; consulted here because this is one of the two sinks that interpolate
573
673
  # them, and a control stated once for the file is not a control at a sink that
574
- # never consults it (PF-023). The third path, the conventions file, is composed
674
+ # never consults it. The third path, the conventions file, is composed
575
675
  # from the gated devflow directory and the allowlisted provider alone, so this
576
676
  # gate covers it too.
577
677
  if [ -z "$DIRECTIVE_PATHS_SAFE" ]; then
@@ -607,7 +707,8 @@ if [ -n "$TRACKER_PROVIDER" ] && [ ! -f "$TRACKER_CONVENTIONS" ]; then
607
707
  # assertion of the closed domain rather than a sanitiser, and it is the single
608
708
  # place the tier is validated, so a later config read cannot be wired in
609
709
  # without passing through it. The literal must equal the Tracker agent's
610
- # frontmatter `model:` (pinned against loadShippedDefaults in shell-hooks).
710
+ # frontmatter `model:` (pinned against loadShippedAgentDefaults()['tracker'].model
711
+ # in shell-hooks-tracker).
611
712
  TRACKER_MODEL="sonnet"
612
713
  case "$TRACKER_MODEL" in
613
714
  opus|sonnet|haiku) ;;