devflow-kit 3.1.0 → 3.3.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 (138) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/dist/cli/agents-view/render.js +69 -15
  4. package/dist/cli/agents-view/state.js +40 -14
  5. package/dist/cli/commands/agents.js +135 -45
  6. package/dist/cli/commands/init.js +128 -53
  7. package/dist/cli/commands/learning.js +61 -13
  8. package/dist/cli/commands/memory.js +35 -14
  9. package/dist/cli/commands/uninstall.js +163 -39
  10. package/dist/commands/code-review.md +1 -3
  11. package/dist/commands/debug.md +15 -12
  12. package/dist/commands/dynamic-build.md +172 -135
  13. package/dist/commands/dynamic-plan.md +9 -3
  14. package/dist/commands/explore.md +10 -4
  15. package/dist/commands/implement.md +149 -145
  16. package/dist/commands/plan.md +13 -9
  17. package/dist/commands/release.md +8 -2
  18. package/dist/commands/research.md +8 -2
  19. package/dist/commands/resolve.md +28 -19
  20. package/dist/commands/self-review.md +16 -13
  21. package/dist/core/agent-frontmatter.js +25 -0
  22. package/dist/core/agent-models.js +201 -36
  23. package/dist/core/agent-state.js +27 -5
  24. package/dist/core/assets.js +1 -1
  25. package/dist/core/feature-config.js +68 -10
  26. package/dist/core/flags.js +24 -0
  27. package/dist/core/learning-queue-cleanup.js +10 -11
  28. package/dist/core/learning-tuning-config.js +8 -0
  29. package/dist/core/linked-path.js +46 -0
  30. package/dist/core/plugins.js +16 -5
  31. package/dist/core/queue-drain.js +31 -0
  32. package/dist/hud/components/learning-counts.js +54 -8
  33. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  34. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +1 -1
  35. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  36. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +1 -1
  37. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  38. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +1 -1
  39. package/dist/targets/claude-code/installer.js +36 -9
  40. package/dist/targets/claude-code/post-install.js +128 -38
  41. package/package.json +1 -1
  42. package/src/assets/agents/code.md +85 -35
  43. package/src/assets/agents/design.md +12 -0
  44. package/src/assets/agents/diagnose.md +18 -11
  45. package/src/assets/agents/evaluate.md +17 -24
  46. package/src/assets/agents/knowledge.md +7 -3
  47. package/src/assets/agents/learning.md +4 -6
  48. package/src/assets/agents/research.md +21 -0
  49. package/src/assets/agents/review.md +12 -0
  50. package/src/assets/agents/scrutinize.md +37 -9
  51. package/src/assets/agents/simplify.md +24 -0
  52. package/src/assets/agents/skim.md +6 -2
  53. package/src/assets/agents/synthesize.md +18 -0
  54. package/src/assets/agents/test.md +19 -11
  55. package/src/assets/agents/triage.md +8 -0
  56. package/src/assets/agents/validate.md +20 -11
  57. package/src/assets/commands/_partials/_engine.mds +36 -55
  58. package/src/assets/commands/_partials/_knowledge.mds +1 -3
  59. package/src/assets/commands/_partials/_plan_contract.mds +1 -1
  60. package/src/assets/commands/_partials/_tracker.mds +1 -1
  61. package/src/assets/commands/_partials/_wave.mds +8 -6
  62. package/src/assets/commands/code-review.mds +1 -3
  63. package/src/assets/commands/debug.mds +13 -8
  64. package/src/assets/commands/dynamic-build.mds +126 -72
  65. package/src/assets/commands/dynamic-plan.mds +7 -1
  66. package/src/assets/commands/explore.mds +9 -1
  67. package/src/assets/commands/implement.mds +147 -141
  68. package/src/assets/commands/plan.mds +12 -8
  69. package/src/assets/commands/release.md +8 -2
  70. package/src/assets/commands/research.mds +8 -2
  71. package/src/assets/commands/resolve.mds +27 -16
  72. package/src/assets/commands/self-review.mds +15 -10
  73. package/src/assets/mds/tracker/_common.mds +1 -1
  74. package/src/assets/mds/tracker/_github.mds +2 -2
  75. package/src/assets/mds/tracker/_jira.mds +2 -2
  76. package/src/assets/mds/tracker/_linear.mds +2 -2
  77. package/src/assets/scripts/ci-wait.cjs +636 -0
  78. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  79. package/src/assets/scripts/hooks/background-memory-update +356 -17
  80. package/src/assets/scripts/hooks/capture-prompt +4 -3
  81. package/src/assets/scripts/hooks/capture-question +4 -3
  82. package/src/assets/scripts/hooks/capture-turn +4 -3
  83. package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
  84. package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
  85. package/src/assets/scripts/hooks/git-marker +71 -0
  86. package/src/assets/scripts/hooks/json-helper.cjs +12 -145
  87. package/src/assets/scripts/hooks/json-parse +24 -129
  88. package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
  89. package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
  90. package/src/assets/scripts/hooks/memory-worker +10 -0
  91. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  92. package/src/assets/scripts/hooks/preamble +9 -1
  93. package/src/assets/scripts/hooks/queue-append +53 -21
  94. package/src/assets/scripts/hooks/session-start-context +108 -29
  95. package/src/assets/scripts/hooks/session-start-memory +33 -11
  96. package/src/assets/scripts/release-trace.cjs +27 -10
  97. package/src/assets/skills/accessibility/SKILL.md +1 -1
  98. package/src/assets/skills/apply-decisions/SKILL.md +12 -82
  99. package/src/assets/skills/apply-feature-knowledge/SKILL.md +8 -42
  100. package/src/assets/skills/architecture/SKILL.md +1 -1
  101. package/src/assets/skills/boundary-validation/SKILL.md +1 -1
  102. package/src/assets/skills/complexity/SKILL.md +1 -1
  103. package/src/assets/skills/compliance/SKILL.md +1 -1
  104. package/src/assets/skills/consistency/SKILL.md +1 -1
  105. package/src/assets/skills/database/SKILL.md +1 -1
  106. package/src/assets/skills/dependencies/SKILL.md +1 -1
  107. package/src/assets/skills/dependency-research/SKILL.md +3 -6
  108. package/src/assets/skills/design-review/SKILL.md +1 -1
  109. package/src/assets/skills/docs-framework/SKILL.md +1 -1
  110. package/src/assets/skills/documentation/SKILL.md +1 -1
  111. package/src/assets/skills/gap-analysis/SKILL.md +1 -1
  112. package/src/assets/skills/git/SKILL.md +1 -1
  113. package/src/assets/skills/go/SKILL.md +1 -1
  114. package/src/assets/skills/java/SKILL.md +1 -1
  115. package/src/assets/skills/patterns/SKILL.md +1 -1
  116. package/src/assets/skills/performance/SKILL.md +1 -1
  117. package/src/assets/skills/python/SKILL.md +1 -1
  118. package/src/assets/skills/qa/SKILL.md +1 -3
  119. package/src/assets/skills/quality-gates/SKILL.md +9 -12
  120. package/src/assets/skills/quality-gates/references/report-template.md +20 -20
  121. package/src/assets/skills/react/SKILL.md +1 -1
  122. package/src/assets/skills/regression/SKILL.md +1 -1
  123. package/src/assets/skills/reliability/SKILL.md +1 -1
  124. package/src/assets/skills/research-codebase/SKILL.md +1 -1
  125. package/src/assets/skills/research-competitor/SKILL.md +1 -1
  126. package/src/assets/skills/research-external/SKILL.md +1 -1
  127. package/src/assets/skills/research-technology/SKILL.md +1 -1
  128. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  129. package/src/assets/skills/rust/SKILL.md +1 -1
  130. package/src/assets/skills/security/SKILL.md +1 -1
  131. package/src/assets/skills/software-design/SKILL.md +1 -1
  132. package/src/assets/skills/test-driven-development/SKILL.md +15 -33
  133. package/src/assets/skills/testing/SKILL.md +1 -1
  134. package/src/assets/skills/typescript/SKILL.md +1 -1
  135. package/src/assets/skills/ui-design/SKILL.md +1 -1
  136. package/src/assets/skills/worktree-support/SKILL.md +3 -55
  137. package/src/assets/skills/worktree-support/references/discovery.md +48 -0
  138. package/src/assets/skills/worktree-support/references/roots.md +2 -2
@@ -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
@@ -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
 
@@ -58,6 +58,11 @@ fi
58
58
  devflow_debug_set_cwd "$CWD"
59
59
  dbg "CWD=$CWD"
60
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
+
61
66
  # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this
62
67
  # hook runs with a CWD inside .devflow/...). One git call yields both roots
63
68
  # (resolve-project-root): PROJECT_ROOT is this checkout's toplevel, LEDGER_ROOT the
@@ -88,8 +93,16 @@ fi
88
93
  # (learning/knowledge only) still get .devflow/ ignored — this is the
89
94
  # memory-independent path that fixes the gitignore/memory coupling. Single
90
95
  # source of truth: ensure-root-gitignore. Soft-fail: a gitignore write must never
91
- # block context injection. Marker keeps it O(1).
92
- [ -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
93
106
 
94
107
  CONTEXT=""
95
108
 
@@ -180,9 +193,6 @@ PROJECT_DEVFLOW_DIR="$PROJECT_ROOT/.devflow"
180
193
  LEDGER_DEVFLOW_DIR="$LEDGER_ROOT/.devflow"
181
194
  LEARNING_DIR="$LEDGER_DEVFLOW_DIR/learning"
182
195
 
183
- # Normal logging
184
- source "$SCRIPT_DIR/hook-log-init" "session-start-context"
185
-
186
196
  # --- Learning gate: the machine switch, narrowed by this checkout ---
187
197
  # D-FEATURES-NARROW-ONLY (see queue-append): ~/.devflow/manifest.json's
188
198
  # features.learning, narrowed by PROJECT_ROOT's project.json / config.json, is
@@ -199,14 +209,23 @@ LEARNING_ENABLED="$_QG_LEARNING"
199
209
 
200
210
  # --- Section 1: Project Decisions TL;DR ---
201
211
  if [ "$LEARNING_ENABLED" = "true" ]; then
202
- # 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).
203
214
  if [ -d "$LEDGER_DEVFLOW_DIR" ] && [ ! -d "$LEARNING_DIR" ]; then
204
- 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
205
220
  fi
206
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.
207
226
  DECISIONS_TLDR=""
208
227
  for kf in "$LEARNING_DIR"/decisions.md "$LEARNING_DIR"/pitfalls.md; do
209
- if [ -f "$kf" ]; then
228
+ if df_file_below "$LEDGER_ROOT" "$kf"; then
210
229
  TLDR_LINE=$(sed -n '1s/<!-- TL;DR: \(.*\) -->/\1/p' "$kf")
211
230
  if [ -n "$TLDR_LINE" ]; then
212
231
  DECISIONS_TLDR="${DECISIONS_TLDR}${TLDR_LINE}\n"
@@ -216,9 +235,11 @@ if [ "$LEARNING_ENABLED" = "true" ]; then
216
235
  # The index line names the decisions index, so the main model can pass it on
217
236
  # as DECISIONS_CONTEXT. It interpolates $LEDGER_ROOT, so it needs that root's
218
237
  # shape gate, and it is left out when the index lists no entry: an empty
219
- # corpus renders `(none)`. Builtins only — no fork on any path.
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.
220
241
  _SC_INDEX="$LEARNING_DIR/index.md"; DECISIONS_INDEX_LINE=""
221
- if [ -n "$DIRECTIVE_LEDGER_SAFE" ] && [ -s "$_SC_INDEX" ]; then
242
+ if [ -n "$DIRECTIVE_LEDGER_SAFE" ] && df_file_below "$LEDGER_ROOT" "$_SC_INDEX" && [ -s "$_SC_INDEX" ]; then
222
243
  _SC_IDX1=""; IFS= read -r _SC_IDX1 < "$_SC_INDEX" || true
223
244
  [ "$_SC_IDX1" != "(none)" ] && DECISIONS_INDEX_LINE="Index: $_SC_INDEX"
224
245
  fi
@@ -259,8 +280,10 @@ if [ "$LEARNING_ENABLED" = "true" ]; then
259
280
  PROCESSING_FILE="$LEARNING_DIR/.pending-turns.processing"
260
281
  PROCESSING_STALE_SECS=900
261
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).
262
285
  LEARNING_WORK=""
263
- if [ -f "$PROCESSING_FILE" ]; then
286
+ if df_file_below "$LEDGER_ROOT" "$PROCESSING_FILE"; then
264
287
  source "$SCRIPT_DIR/get-mtime" 2>/dev/null || true
265
288
  _SC_PROC_MTIME=$(get_mtime "$PROCESSING_FILE" 2>/dev/null || true)
266
289
  _SC_NOW=$(date +%s)
@@ -269,7 +292,7 @@ if [ "$LEARNING_ENABLED" = "true" ]; then
269
292
  else
270
293
  dbg "learning directive suppressed: fresh .processing (live agent owns the batch)"
271
294
  fi
272
- elif [ -s "$QUEUE_FILE" ]; then
295
+ elif df_file_below "$LEDGER_ROOT" "$QUEUE_FILE" && [ -s "$QUEUE_FILE" ]; then
273
296
  LEARNING_WORK="queue"
274
297
  fi
275
298
 
@@ -293,28 +316,83 @@ ${LEARNING_PAUSED_SECTION}"
293
316
  fi
294
317
 
295
318
  if [ -n "$LEARNING_WORK" ]; then
296
- # 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.
297
354
  LEARNING_MODEL=""
298
- if [ -f "$LEARNING_DIR/learning.json" ]; then
299
- 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
300
361
  fi
301
- if [ -z "$LEARNING_MODEL" ] && [ -f "$HOME/.devflow/learning.json" ]; then
302
- LEARNING_MODEL=$(json_field_file "$HOME/.devflow/learning.json" "model" "")
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
380
+ fi
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\","
303
390
  fi
304
- LEARNING_MODEL="${LEARNING_MODEL:-opus}"
305
- # Allowlist before interpolating into the injected directive (defense in depth --
306
- # learning.json is user/config-controlled; a value with newlines/quotes must
307
- # never inject arbitrary text into the SessionStart context). Fallback matches
308
- # learning-tuning-config.ts DEFAULTS.model (duplicated-by-design, see feature KB).
309
- case "$LEARNING_MODEL" in
310
- opus|sonnet|haiku) ;;
311
- *) LEARNING_MODEL="opus" ;;
312
- esac
313
391
 
314
- 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})"
315
393
  LEARNING_SECTION="--- LEARNING MAINTENANCE ---
316
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.
317
- 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\")
318
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."
319
397
  if [ -n "$CONTEXT" ]; then
320
398
  CONTEXT="${CONTEXT}
@@ -629,7 +707,8 @@ if [ -n "$TRACKER_PROVIDER" ] && [ ! -f "$TRACKER_CONVENTIONS" ]; then
629
707
  # assertion of the closed domain rather than a sanitiser, and it is the single
630
708
  # place the tier is validated, so a later config read cannot be wired in
631
709
  # without passing through it. The literal must equal the Tracker agent's
632
- # frontmatter `model:` (pinned against loadShippedDefaults in shell-hooks).
710
+ # frontmatter `model:` (pinned against loadShippedAgentDefaults()['tracker'].model
711
+ # in shell-hooks-tracker).
633
712
  TRACKER_MODEL="sonnet"
634
713
  case "$TRACKER_MODEL" in
635
714
  opus|sonnet|haiku) ;;
@@ -70,7 +70,11 @@ fi
70
70
  # re-spawns (memory disabled mid-flight, host offline, etc.): a SessionStart
71
71
  # arriving >300s after the crash with no intervening Stop hook.
72
72
  # Non-clobber: only recovers when .pending-turns.jsonl does NOT already exist,
73
- # so a concurrent session's fresh queue is never overwritten.
73
+ # so a concurrent session's fresh queue is never overwritten. Nor where a
74
+ # symbolic link sits on the path to the batch or to the queue it is renamed onto
75
+ # (D-HOOKS-NO-SYMLINK, git-marker): `mv` would move the batch into a folder a
76
+ # linked memory folder or queue path names, and a linked batch would become a
77
+ # linked queue.
74
78
  source "$SCRIPT_DIR/get-mtime" || { echo "session-start-memory: failed to source get-mtime" >&2; exit 1; }
75
79
  source "$SCRIPT_DIR/is-hex-sha" || { echo "session-start-memory: failed to source is-hex-sha" >&2; exit 1; }
76
80
  _SSM_PT_PROC="$MEMORY_DIR/.pending-turns.processing"
@@ -81,7 +85,10 @@ if [ -f "$_SSM_PT_PROC" ]; then
81
85
  _SSM_NOW_PT=$(date +%s)
82
86
  _SSM_PT_AGE=$(( _SSM_NOW_PT - _SSM_PT_MTIME ))
83
87
  if [ "$_SSM_PT_AGE" -gt 300 ]; then
84
- if [ ! -f "$_SSM_PT_JSONL" ]; then
88
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$_SSM_PT_PROC" "$_SSM_PT_JSONL"; then
89
+ dbg "Stale .pending-turns.processing skipped — a symbolic link sits on its path or the queue's"
90
+ log "Stale .pending-turns.processing skipped (cold path): a symbolic link sits on the path to $_SSM_PT_PROC or $_SSM_PT_JSONL"
91
+ elif [ ! -f "$_SSM_PT_JSONL" ]; then
85
92
  mv "$_SSM_PT_PROC" "$_SSM_PT_JSONL" 2>/dev/null || true
86
93
  dbg "Recovered orphaned .pending-turns.processing (age ${_SSM_PT_AGE}s)"
87
94
  log "Recovered orphaned .pending-turns.processing → .pending-turns.jsonl (cold path)"
@@ -135,27 +142,31 @@ parse_and_validate_stamp() {
135
142
  esac
136
143
  }
137
144
 
138
- # --- detect_refresh_failing: sets REFRESH_FAILING in caller's scope.
145
+ # --- detect_refresh_failing <now> <memory_dir> <log_dir> <root>: sets
146
+ # REFRESH_FAILING in caller's scope. <root> is the project root the memory folder
147
+ # lies below.
139
148
  # Condition: queue non-empty AND (.last-refresh-ok missing OR >600s old)
140
149
  # orphaned-processing depth fix: count both .pending-turns.jsonl AND
141
150
  # .pending-turns.processing toward _queue_depth. Before this fix, an orphaned
142
151
  # .processing (whose mtime is between 0s and the D56c 300s cold-path gate)
143
152
  # was invisible to State-C — a crashed worker's batch would sit silently with
144
- # no user-visible warning.
153
+ # no user-visible warning. The count reaches the session, so a batch a symbolic
154
+ # link leads to counts as absent (D-HOOKS-NO-SYMLINK, git-marker).
145
155
  detect_refresh_failing() {
146
156
  local _now="$1"
147
157
  local _memory_dir="$2"
148
158
  local _log_dir="$3"
159
+ local _root="$4"
149
160
  local _queue_file="$_memory_dir/.pending-turns.jsonl"
150
161
  local _proc_file="$_memory_dir/.pending-turns.processing"
151
162
  local _ok_file="$_memory_dir/.last-refresh-ok"
152
163
  local _queue_depth=0
153
- if [ -f "$_queue_file" ] && [ -s "$_queue_file" ]; then
164
+ if df_file_below "$_root" "$_queue_file" && [ -s "$_queue_file" ]; then
154
165
  _queue_depth=$(wc -l < "$_queue_file" | tr -d ' ')
155
166
  fi
156
167
  # Also count lines in .processing — an orphaned batch left by a crashed worker
157
168
  # is unprocessed content even if .jsonl is empty (applies B4 blind-spot fix)
158
- if [ -f "$_proc_file" ] && [ -s "$_proc_file" ]; then
169
+ if df_file_below "$_root" "$_proc_file" && [ -s "$_proc_file" ]; then
159
170
  local _proc_depth
160
171
  _proc_depth=$(wc -l < "$_proc_file" | tr -d ' ')
161
172
  _queue_depth=$(( _queue_depth + _proc_depth ))
@@ -180,7 +191,14 @@ detect_refresh_failing() {
180
191
  fi
181
192
  }
182
193
 
183
- if [ -f "$MEMORY_FILE" ]; then
194
+ # D-HOOKS-NO-SYMLINK (git-marker): the working memory, the batches State C counts
195
+ # and the pre-compact backup are all read into the session, so each is read only
196
+ # where no symbolic link sits on its path; a file a link leads to is treated as
197
+ # absent, and its refusal logged once.
198
+ MEMORY_PRESENT=""
199
+ if df_file_below "$PROJECT_ROOT" "$MEMORY_FILE"; then MEMORY_PRESENT="yes"; fi
200
+
201
+ if [ -n "$MEMORY_PRESENT" ]; then
184
202
  dbg "MEMORY_FILE exists: $MEMORY_FILE"
185
203
  MEMORY_CONTENT=$(head -c 65536 "$MEMORY_FILE")
186
204
 
@@ -239,7 +257,7 @@ if [ -f "$MEMORY_FILE" ]; then
239
257
  fi
240
258
 
241
259
  # --- State C detection: refresh failing ---
242
- detect_refresh_failing "$NOW" "$MEMORY_DIR" "$LOG_DIR"
260
+ detect_refresh_failing "$NOW" "$MEMORY_DIR" "$LOG_DIR" "$PROJECT_ROOT"
243
261
 
244
262
  # --- Build state-specific header ---
245
263
  # D-DETACHED-HEAD: where the session is, as one phrase. On a branch it is
@@ -292,8 +310,12 @@ fi
292
310
  # Re-inject compaction snapshot when it's more recent than the memory file (compact overwrote
293
311
  # WORKING-MEMORY.md with a summary, but the pre-compact snapshot may have richer context).
294
312
  BACKUP_FILE="$MEMORY_DIR/backup.json"
295
- if [ -f "$BACKUP_FILE" ]; then
296
- BACKUP_MEMORY=$(json_field_file "$BACKUP_FILE" "memory_snapshot" "")
313
+ if df_file_below "$PROJECT_ROOT" "$BACKUP_FILE"; then
314
+ # Under set -e a failed read would end the hook before it prints the working
315
+ # memory built above, so a backup that does not parse is read as no snapshot,
316
+ # like an absent one, discarding what jq printed before it failed. The read
317
+ # below runs only once this one has parsed the whole file.
318
+ BACKUP_MEMORY=$(json_field_file "$BACKUP_FILE" "memory_snapshot" "") || BACKUP_MEMORY=""
297
319
  if [ -n "$BACKUP_MEMORY" ]; then
298
320
  BACKUP_TS=$(json_field_file "$BACKUP_FILE" "timestamp" "")
299
321
  BACKUP_EPOCH=0
@@ -303,7 +325,7 @@ if [ -f "$BACKUP_FILE" ]; then
303
325
  || echo "0")
304
326
  fi
305
327
  FILE_MTIME_CK=0
306
- if [ -f "$MEMORY_FILE" ]; then
328
+ if [ -n "$MEMORY_PRESENT" ]; then
307
329
  FILE_MTIME_CK=$(get_mtime "$MEMORY_FILE" 2>/dev/null || echo "0")
308
330
  fi
309
331
  if [ "$BACKUP_EPOCH" -gt "$FILE_MTIME_CK" ]; then
@@ -113,12 +113,28 @@ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
113
113
  const KEY_RE = /^[A-Z][A-Z0-9_]{1,9}$/;
114
114
 
115
115
  /**
116
- * Step 3a's closing-keyword regex, byte-for-byte the literal the built
117
- * `gather-release-evidence` references state, applied case-insensitively WITHOUT
118
- * the `u` flag — so a non-ASCII letter (`ſ`, the Kelvin sign) never folds onto an
119
- * ASCII keyword letter. A parity test pins `.source` and `.flags` to the built text.
116
+ * D-TRACE-KEYWORDS: two keyword sets, deliberately unequal, because they answer
117
+ * two different questions.
118
+ *
119
+ * CLOSING_KEYWORD_RE is step 3a's closing-keyword regex, byte-for-byte the literal
120
+ * the built `gather-release-evidence` references state: a commit that carries one
121
+ * of these keywords ships the issue it names. `Refs #N` only mentions an issue, so
122
+ * `refs` is not a closing keyword and the prompt never lets such a commit reach
123
+ * SHIPPED_ISSUES. The script itself classifies with TRACE_KEYWORD_RE; this regex
124
+ * is exported so a parity test can pin the prompt's rule to a literal.
125
+ *
126
+ * TRACE_KEYWORD_RE is the closing set plus `refs`. The trace map asks the weaker
127
+ * question "does this commit name an issue at all?", and a `Refs #N` commit does,
128
+ * so it is `traced` and not `untraced`. Narrowing the closing set must not turn
129
+ * those commits into traceability gaps in the release confirm.
130
+ *
131
+ * Both apply case-insensitively WITHOUT the `u` flag — so a non-ASCII letter
132
+ * (`ſ`, the Kelvin sign) never folds onto an ASCII keyword letter. A parity test
133
+ * pins CLOSING_KEYWORD_RE's `.source` and `.flags` to the built text, and
134
+ * TRACE_KEYWORD_RE to the closing set plus `refs`.
120
135
  */
121
- const KEYWORD_RE = /^\(?(close[sd]?|fix(e[sd])?|resolve[sd]?|refs):?$/i;
136
+ const CLOSING_KEYWORD_RE = /^\(?(close[sd]?|fix(e[sd])?|resolve[sd]?):?$/i;
137
+ const TRACE_KEYWORD_RE = /^\(?(close[sd]?|fix(e[sd])?|resolve[sd]?|refs):?$/i;
122
138
 
123
139
  /** Step 3a's trailing-strip class, byte-for-byte the built literal (parity-pinned). */
124
140
  const TRAILING_CLASS = '[.,;:)\\]!?]';
@@ -377,11 +393,11 @@ function gateCandidate(candidate, grammar, key) {
377
393
 
378
394
  /**
379
395
  * Step 3a, executed, then the grammar's gate: the first reference `message`
380
- * yields, or null. Per line, a whitespace token matching KEYWORD_RE opens a run:
381
- * the next token, plus each further token while the previous one ends in `,`.
396
+ * yields, or null. Per line, a whitespace token matching TRACE_KEYWORD_RE opens a
397
+ * run: the next token, plus each further token while the previous one ends in `,`.
382
398
  * Each run token splits on `,`, is stripped, and non-empty parts are gated.
383
399
  *
384
- * Linear in the message: a keyword token never ends in `,` (KEYWORD_RE is
400
+ * Linear in the message: a keyword token never ends in `,` (TRACE_KEYWORD_RE is
385
401
  * anchored), so every comma run is walked by at most the one keyword before it.
386
402
  *
387
403
  * @param {string} message
@@ -393,7 +409,7 @@ function findReference(message, grammar, key) {
393
409
  for (const line of message.split('\n')) {
394
410
  const tokens = line.split(/\s+/).filter(t => t !== '');
395
411
  for (let i = 0; i < tokens.length; i++) {
396
- if (!KEYWORD_RE.test(tokens[i])) continue;
412
+ if (!TRACE_KEYWORD_RE.test(tokens[i])) continue;
397
413
  for (let j = i + 1; j < tokens.length; j++) {
398
414
  for (const part of tokens[j].split(',')) {
399
415
  const stripped = stripCandidate(part);
@@ -1117,7 +1133,8 @@ module.exports = Object.freeze({
1117
1133
  EXIT_CODES,
1118
1134
  LIMITS,
1119
1135
  RELEASE_TAG_RE,
1120
- KEYWORD_RE,
1136
+ CLOSING_KEYWORD_RE,
1137
+ TRACE_KEYWORD_RE,
1121
1138
  TRAILING_CLASS,
1122
1139
  GRAMMARS,
1123
1140
  GRAMMAR_RULES,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: accessibility
3
- description: This skill should be used when the user asks to "add accessibility", "check ARIA", "handle keyboard navigation", "add focus management", or creates UI components, forms, or interactive elements. Provides WCAG 2.2 AA patterns for keyboard navigation, ARIA roles and states, focus management, color contrast, and screen reader support.
3
+ description: This skill should be used when the user asks to "add accessibility", "check ARIA", "handle keyboard navigation", or creates UI components, forms, or interactive elements.
4
4
  user-invocable: false
5
5
  allowed-tools: Read, Grep, Glob
6
6
  activation: