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
@@ -26,7 +26,7 @@
26
26
  # - session-start-context (always-on, memory-independent — covers memory-off
27
27
  # projects that still use learning/knowledge)
28
28
  # Both reach this one writer so the rule is applied identically everywhere; this
29
- # decouples git-tracking of .devflow/ from any single feature toggle (avoids PF-014).
29
+ # decouples git-tracking of .devflow/ from any single feature toggle.
30
30
  #
31
31
  # Idempotent and O(1) after the first run via the .root-gitignore-configured-v6
32
32
  # marker under .devflow/ (project-local). The marker is a claim, not proof, so the
@@ -58,6 +58,22 @@
58
58
  # `!.claudeignore` entry means "the user owns that line" — emit the block without it.
59
59
  # All matching is whole-line and whitespace-tolerant; never substring.
60
60
  #
61
+ # D-GITIGNORE-LINK-INSIDE: a root .gitignore that is a symbolic link is written only
62
+ # when the file it resolves to lies inside the project root and outside any .git
63
+ # folder in the project, the project's own or a nested repository's: no part of its
64
+ # path below the root may be named .git, in any letter case, as git itself refuses
65
+ # such a path. Then that file is read and written directly, never through the link.
66
+ # Otherwise nothing is read or written anywhere, no marker is stamped, the skip is
67
+ # logged once and the caller goes on. Reason: a repository can commit .gitignore as a
68
+ # link to any file on the machine, and the carve-out would be appended to it; a file
69
+ # in a .git is no file of the repository's either, but git's own hooks and config,
70
+ # and a line appended to a hook runs as a command the next time git runs it. The case
71
+ # matters because a case-insensitive file system (macOS) opens .git for .GIT. The TS
72
+ # twin, ensureDevflowGitignore in src/targets/claude-code/post-install.ts, applies the
73
+ # same rule the same way: the link is followed one hop at a time, at most 40 hops, and
74
+ # only its last folder is resolved physically, so a missing file inside the project
75
+ # is created there as before.
76
+ #
61
77
  # Usage: source ensure-root-gitignore "$PROJECT_ROOT"
62
78
  # Sourced helper: uses `return` (never exit), _ERG_-prefixed locals (never clobbers
63
79
  # caller vars).
@@ -68,6 +84,76 @@ _ERG_DEVFLOW_DIR="$1/.devflow"
68
84
  _ERG_MARKER="$_ERG_DEVFLOW_DIR/.root-gitignore-configured-v6"
69
85
  _ERG_GITIGNORE="$1/.gitignore"
70
86
 
87
+ # _erg_resolve_inside <root> <link> — follow the symbolic link <link> one hop at a
88
+ # time, at most 40, and set _ERG_RESOLVED to the file it leads to, its folder
89
+ # resolved physically. Returns 0 when that file lies inside <root> and no part of its
90
+ # path below <root> is named .git in any letter case (D-GITIGNORE-LINK-INSIDE);
91
+ # returns 1, with _ERG_RESOLVED empty, when it does not and when the link cannot be
92
+ # followed: a read that fails, an empty or folder-like target, a chain longer than
93
+ # 40, a folder that does not resolve. A predicate: call it in an `if`, never as a
94
+ # plain statement under `set -e`. Relative targets are joined to the folder the link
95
+ # sits in as spelled, never normalised, so `..` is resolved by the file system as the
96
+ # write itself would resolve it. Each hop forks one readlink; `cd -P` and $PWD are
97
+ # builtins, and the caller's folder is restored before returning.
98
+ _erg_resolve_inside() {
99
+ local _root="$1" _cur="$2" _target="" _dir="" _base="" _root_real="" _hops=0 _back="$PWD"
100
+ _ERG_RESOLVED=''
101
+ # Absolute from the start, so no path is read as an option, searched for along
102
+ # CDPATH, or taken relative to a folder this function moved into.
103
+ case "$_root" in /*) ;; *) _root="$_back/$_root" ;; esac
104
+ case "$_cur" in /*) ;; *) _cur="$_back/$_cur" ;; esac
105
+ while [ -L "$_cur" ]; do
106
+ [ "$_hops" -lt 40 ] || return 1
107
+ # The `x` keeps a target's own trailing newlines from command substitution; the
108
+ # one newline readlink adds is then removed.
109
+ _target="$(readlink "$_cur" 2>/dev/null && printf x)"
110
+ case "$_target" in *x) _target="${_target%x}" ;; *) return 1 ;; esac
111
+ _target="${_target%$'\n'}"
112
+ case "$_target" in
113
+ '') return 1 ;;
114
+ /*) _cur="$_target" ;;
115
+ *) _cur="${_cur%/*}/$_target" ;;
116
+ esac
117
+ _hops=$((_hops + 1))
118
+ done
119
+ _base="${_cur##*/}"
120
+ _dir="${_cur%/*}"
121
+ case "$_base" in ''|.|..) return 1 ;; esac
122
+ [ -n "$_dir" ] || _dir=/
123
+ if cd -P "$_root" 2>/dev/null; then
124
+ _root_real="${PWD%/}"
125
+ if cd -P "$_dir" 2>/dev/null; then _ERG_RESOLVED="${PWD%/}/$_base"; fi
126
+ fi
127
+ cd "$_back" 2>/dev/null || true
128
+ case "$_ERG_RESOLVED" in
129
+ '') ;;
130
+ "$_root_real"/?*)
131
+ # Each part below the root is tested whole, in any letter case: `cd -P` keeps
132
+ # the spelling the link gave, and a case-insensitive file system opens `.git`
133
+ # for `.GIT`.
134
+ case "/${_ERG_RESOLVED#"$_root_real"/}/" in
135
+ */.[Gg][Ii][Tt]/*) ;;
136
+ *) return 0 ;;
137
+ esac
138
+ ;;
139
+ esac
140
+ _ERG_RESOLVED=''
141
+ return 1
142
+ }
143
+
144
+ # D-GITIGNORE-LINK-INSIDE (above): checked before the fast path, so a link that leads
145
+ # elsewhere is neither read nor written. A regular .gitignore costs one builtin test.
146
+ if [ -L "$_ERG_GITIGNORE" ]; then
147
+ if _erg_resolve_inside "$1" "$_ERG_GITIGNORE"; then
148
+ _ERG_GITIGNORE="$_ERG_RESOLVED"
149
+ else
150
+ if declare -F log >/dev/null 2>&1; then
151
+ log "Skipped: $_ERG_GITIGNORE is a symbolic link that leads outside the project, into a .git, or nowhere; nothing was written through it"
152
+ fi
153
+ return 0
154
+ fi
155
+ fi
156
+
71
157
  # Whole-line, whitespace-tolerant matchers. `*` and `.` are escaped so the ERE
72
158
  # matches the literal gitignore patterns.
73
159
  _ERG_RE_OPTOUT='^[[:space:]]*/\.devflow/[[:space:]]*$'
@@ -208,18 +294,30 @@ else
208
294
  # Upgrade our legacy wholesale entry: strip the bare `.devflow/` line and our old
209
295
  # comment first, then fall through to the shared append below. Portable (grep
210
296
  # filter + mv; no sed -i, which differs on BSD/GNU).
211
- # The pipeline's own status is deliberately ignored: grep -v exits 1 when it
297
+ # The filter's own status is deliberately ignored: grep -v exits 1 when it
212
298
  # emits no lines, which is the correct outcome for a .gitignore whose only
213
- # content was the legacy entry. The redirect creates _ERG_TMP either way.
299
+ # content was the legacy entry, so `true` ends the group and the subshell fails
300
+ # only when the copy cannot be created. The copy is created only where nothing
301
+ # stands at its name: builtin tests come first, so an entry already there, a
302
+ # link to a FIFO or a device included, is never opened (noclobber alone would
303
+ # open one, as no regular file stands there), and noclobber then makes the
304
+ # create itself exclusive (D-HOOKS-NO-SYMLINK, git-marker). Such an entry is
305
+ # removed instead; the upgrade then waits for a later run, and the skip is
306
+ # logged once.
214
307
  _ERG_TMP="$_ERG_GITIGNORE.devflow-tmp.$$"
215
- grep -vE "$_ERG_RE_LEGACY" "$_ERG_GITIGNORE" 2>/dev/null \
216
- | grep -vF '# Devflow runtime data (local by default; remove to share via git)' \
217
- > "$_ERG_TMP" 2>/dev/null
218
- if [ -f "$_ERG_TMP" ] && mv "$_ERG_TMP" "$_ERG_GITIGNORE"; then
308
+ if [ ! -e "$_ERG_TMP" ] && [ ! -L "$_ERG_TMP" ] && (set -o noclobber; {
309
+ grep -vE "$_ERG_RE_LEGACY" "$_ERG_GITIGNORE" \
310
+ | grep -vF '# Devflow runtime data (local by default; remove to share via git)'
311
+ true
312
+ } > "$_ERG_TMP") 2>/dev/null \
313
+ && [ -f "$_ERG_TMP" ] && mv "$_ERG_TMP" "$_ERG_GITIGNORE"; then
219
314
  :
220
315
  else
221
- rm -f "$_ERG_TMP" 2>/dev/null
316
+ rm -f "$_ERG_TMP" 2>/dev/null || true
222
317
  _ERG_MODE=fail
318
+ if declare -F log >/dev/null 2>&1; then
319
+ log "Legacy .devflow/ entry not upgraded: its copy could not be written ($_ERG_TMP); a later run retries"
320
+ fi
223
321
  fi
224
322
  fi
225
323
  fi
@@ -231,7 +329,8 @@ fi
231
329
  # unchanged, and when the run ends on a last line with no newline, one is added
232
330
  # before <text>. Each read ends in a trailing `x`, so command substitution cannot
233
331
  # strip the file's own trailing newlines. The file is rewritten in place with one
234
- # `>`, as the TS twin's writeFile does, so a symlinked .gitignore keeps its link.
332
+ # `>`, as the TS twin's writeFile does; a .gitignore linked to a file inside the
333
+ # project is rewritten at that file, so the link stays (D-GITIGNORE-LINK-INSIDE).
235
334
  # Returns non-zero, having written nothing, when the anchor line cannot be found.
236
335
  _erg_insert_in_block() {
237
336
  local _anchor="$1" _run="$2" _text="$3" _hit="" _n="" _next="" _line="" _i=0 _head="" _tail=""
@@ -284,9 +383,22 @@ case "$_ERG_MODE" in
284
383
  ;;
285
384
  esac
286
385
 
287
- # On success, stamp the current-format v6 marker and drop legacy markers.
386
+ # On success, stamp the current-format v6 marker and drop legacy markers. The marker
387
+ # is created only where nothing stands, never `touch`ed: `touch` follows a symbolic
388
+ # link committed at its path (D-HOOKS-NO-SYMLINK, git-marker), and so does
389
+ # noclobber's create when the link leads to a FIFO, where it would wait at every
390
+ # session start and prompt, or to a device. So builtin tests come first: a link
391
+ # there is left as it was and the skip logged once, a marker already there is all
392
+ # the fast path needs, and noclobber makes the create itself exclusive. A `.devflow`
393
+ # that is itself a link is refused by both callers before they reach this file.
288
394
  [ "$_ERG_OK" = 1 ] && {
289
- touch "$_ERG_MARKER"
395
+ if [ -L "$_ERG_MARKER" ]; then
396
+ if declare -F log >/dev/null 2>&1; then
397
+ log "Marker not stamped: $_ERG_MARKER is a symbolic link"
398
+ fi
399
+ elif [ ! -e "$_ERG_MARKER" ]; then
400
+ (set -o noclobber; : > "$_ERG_MARKER") 2>/dev/null || true
401
+ fi
290
402
  _erg_drop_legacy_markers
291
403
  }
292
404
  return 0
@@ -62,3 +62,74 @@ df_is_project_root() {
62
62
  [ -n "$_root_real" ] || return 1
63
63
  [ "$_root_real" != "$_home_real" ]
64
64
  }
65
+
66
+ # df_no_symlink_below <root> <path>... — returns 0 when no <path>, and no folder
67
+ # between <root> and it, is a symbolic link. Returns 1 when one is, and for any
68
+ # call it cannot vouch for: no <path>, an empty <root>, a <path> not below <root>,
69
+ # or a <path> with an empty, `.` or `..` part or more than 64 parts below <root>.
70
+ # A predicate: call it in an `if` or an `||` list, never as a plain statement
71
+ # under `set -e`.
72
+ #
73
+ # D-HOOKS-NO-SYMLINK: a hook writes under a project's `.devflow/`, and reads a file
74
+ # there into the session context, a backup, a prompt or an agent directive, only
75
+ # where nothing on the way down from the root is a symbolic link. A repository can
76
+ # commit a link anywhere in its own `.devflow/`, and the hooks' shell commands follow
77
+ # one. `>>` and `touch` write through a linked file and `mkdir -p` creates folders
78
+ # inside a linked folder, so a linked queue or folder would put captured
79
+ # conversation text into whatever file the link names. `head`, `sed`, `read` and
80
+ # `jq` read through one, so a linked working memory, backup or decisions file would
81
+ # put whatever file the link names into the session, the memory refresh's prompt or
82
+ # the backup the next session restores, with no one choosing to read it. Each hook
83
+ # checks every path this rule covers: a refused write is skipped and a refused
84
+ # read is treated as an absent file (df_file_below below), each logged once. A
85
+ # rename's target is one of those paths: `mv` moves a file into the folder a link
86
+ # at its target names, so a link there, to anything, refuses the rename.
87
+ #
88
+ # Only the part of each path below <root> is checked, never the root or a folder
89
+ # above it: the root is where the session runs or where git keeps the ledger,
90
+ # chosen by the user and not by what the repository holds, and a folder above it
91
+ # may legitimately be a link (on macOS /tmp and /var lead into /private). The check
92
+ # runs just before the write or the read, so a process that swaps a link in between
93
+ # is not stopped; such a process already runs as the user.
94
+ #
95
+ # Zero forks: builtin `[ -L ]` tests and parameter expansion only.
96
+ df_no_symlink_below() {
97
+ local _root="$1" _path="" _rest="" _seg="" _at="" _n=0
98
+ [ -n "$_root" ] || return 1
99
+ shift
100
+ [ "$#" -gt 0 ] || return 1
101
+ for _path in "$@"; do
102
+ case "$_path" in
103
+ "$_root"/?*) _rest="${_path#"$_root"/}" ;;
104
+ *) return 1 ;;
105
+ esac
106
+ _at="$_root"
107
+ _n=0
108
+ while [ -n "$_rest" ]; do
109
+ [ "$_n" -lt 64 ] || return 1
110
+ _seg="${_rest%%/*}"
111
+ if [ "$_seg" = "$_rest" ]; then _rest=""; else _rest="${_rest#*/}"; fi
112
+ case "$_seg" in ''|.|..) return 1 ;; esac
113
+ _at="$_at/$_seg"
114
+ if [ -L "$_at" ]; then return 1; fi
115
+ _n=$((_n + 1))
116
+ done
117
+ done
118
+ return 0
119
+ }
120
+
121
+ # df_file_below <root> <path> — returns 0 when <path> is a regular file that
122
+ # df_no_symlink_below admits, so a hook may read it. Returns 1 when no regular file
123
+ # stands there, and when a symbolic link sits on its path: the file is then treated
124
+ # as absent (D-HOOKS-NO-SYMLINK, above), and that refusal is logged once through the
125
+ # caller's log(), where it defines one. `[ -f ]` follows a link, so a dangling link
126
+ # or a link to a folder reads as absent with nothing logged: nothing would be read
127
+ # through it. A predicate, like df_no_symlink_below; it forks nothing unless it logs.
128
+ df_file_below() {
129
+ [ -f "$2" ] || return 1
130
+ if df_no_symlink_below "$1" "$2"; then return 0; fi
131
+ if declare -F log >/dev/null 2>&1; then
132
+ log "Skipped: a symbolic link sits on the path to $2; treated as absent"
133
+ fi
134
+ return 1
135
+ }
@@ -3,7 +3,7 @@
3
3
  # Usage: source "$SCRIPT_DIR/is-hex-sha" then is_hex_sha <value> [min_len] [max_len]
4
4
  # Returns 0 (true) if value consists entirely of lowercase hex chars and has
5
5
  # length within [min_len, max_len]. Defaults: min_len=7, max_len=40.
6
- # Pure shell — no forks (PF-008-safe).
6
+ # Pure shell — no forks.
7
7
 
8
8
  is_hex_sha() {
9
9
  local _v="$1" _min="${2:-7}" _max="${3:-40}" _len