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
@@ -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
+ }
@@ -11,18 +11,8 @@
11
11
  //
12
12
  // Operations:
13
13
  // get-field <field> [default] Read field from stdin JSON
14
- // validate Exit 0 if stdin is valid JSON, 1 otherwise
15
- // compact Compact stdin JSON to single line
16
- // construct <json-template> [--arg k v] Build JSON object with args
17
- // update-field <field> <value> [--json] Set field on stdin JSON (--json parses value)
18
- // update-fields <json-patches> Apply multiple field updates from stdin JSON
14
+ // get-string-field <field> Read field from stdin JSON only when it is a string
19
15
  // extract-cwd-field <field> Extract cwd + arbitrary field, SOH-byte delimited
20
- // extract-text-messages Extract text content from Claude message format
21
- // merge-evidence Flatten, dedupe, limit to 10 from stdin JSON
22
- // slurp-sort <field> [limit] Read stdin JSONL, sort by field desc, limit results
23
- // slurp-cap <field> [limit] Read stdin JSONL, sort by field desc, output limit lines
24
- // array-length <path> Get length of array at dotted path in stdin JSON
25
- // array-item <path> <index> Get item at index from array at path in stdin JSON
26
16
  // session-output <context> Build SessionStart output envelope
27
17
  // prompt-output <context> Build UserPromptSubmit output envelope
28
18
  // backup-construct Build pre-compact backup JSON from --arg pairs
@@ -165,31 +155,15 @@ function getNestedField(obj, field) {
165
155
  return current;
166
156
  }
167
157
 
168
- /** The JSON values of a JSONL text's lines; a line that does not parse is skipped. */
169
- function parseJsonlText(text) {
170
- const lines = text.split('\n').filter(Boolean);
171
- return lines.map(l => {
172
- try { return JSON.parse(l); } catch { return null; }
173
- }).filter(Boolean);
174
- }
175
-
176
158
  function parseArgs(argList) {
177
159
  const result = {};
178
- const jsonArgs = {};
179
160
  for (let i = 0; i < argList.length; i++) {
180
161
  if (argList[i] === '--arg' && i + 2 < argList.length) {
181
162
  result[argList[i + 1]] = argList[i + 2];
182
163
  i += 2;
183
- } else if (argList[i] === '--argjson' && i + 2 < argList.length) {
184
- try {
185
- jsonArgs[argList[i + 1]] = JSON.parse(argList[i + 2]);
186
- } catch {
187
- jsonArgs[argList[i + 1]] = argList[i + 2];
188
- }
189
- i += 2;
190
164
  }
191
165
  }
192
- return { ...result, ...jsonArgs };
166
+ return result;
193
167
  }
194
168
 
195
169
  // ---------------------------------------------------------------------------
@@ -278,7 +252,8 @@ function heartbeat(root) {
278
252
  // The learning ops run from the project root and take no path to a learning file:
279
253
  // each builds its paths from the current directory. Every op that writes takes the
280
254
  // store's one learning lock through withDecisionsLock (D-ONE-LEARNING-LOCK) and
281
- // refuses, creating nothing, when .devflow/learning/ is absent (D-NO-STRAY-TREE).
255
+ // refuses, creating nothing, when .devflow/learning/ is absent (D-NO-STRAY-TREE)
256
+ // or when it, or .devflow, is a symbolic link (D-NO-LINKED-TREE).
282
257
  // A locked body returns its Result; emit prints it once the lock is released.
283
258
  if (require.main === module) {
284
259
  try {
@@ -293,54 +268,15 @@ try {
293
268
  break;
294
269
  }
295
270
 
296
- case 'validate': {
297
- try {
298
- const text = readStdin();
299
- if (!text) process.exit(1);
300
- JSON.parse(text);
301
- process.exit(0);
302
- } catch {
303
- process.exit(1);
304
- }
305
- break;
306
- }
307
-
308
- case 'compact': {
271
+ case 'get-string-field': {
272
+ // The typed read: get-field stringifies, so the number 42 and the string
273
+ // "42" are the same to it. Here a JSON string prints byte-exact (no
274
+ // trailing newline added, so the caller sees the value's own) and every
275
+ // other type, an absent field, or a string holding a NUL (no shell
276
+ // variable can carry one) prints nothing.
309
277
  const input = JSON.parse(readStdin());
310
- console.log(JSON.stringify(input));
311
- break;
312
- }
313
-
314
- case 'construct': {
315
- // Build JSON from --arg/--argjson pairs
316
- const template = parseArgs(args);
317
- console.log(JSON.stringify(template));
318
- break;
319
- }
320
-
321
- case 'update-field': {
322
- const input = JSON.parse(readStdin());
323
- const field = args[0];
324
- const value = args[1];
325
- const isJson = args[2] === '--json';
326
- input[field] = isJson ? JSON.parse(value) : value;
327
- console.log(JSON.stringify(input));
328
- break;
329
- }
330
-
331
- case 'update-fields': {
332
- // Read stdin JSON, apply field updates from args: field1=val1 field2=val2
333
- const input = JSON.parse(readStdin());
334
- for (const arg of args) {
335
- const eqIdx = arg.indexOf('=');
336
- if (eqIdx > 0) {
337
- const key = arg.slice(0, eqIdx);
338
- const val = arg.slice(eqIdx + 1);
339
- // Try to parse as JSON, fall back to string
340
- try { input[key] = JSON.parse(val); } catch { input[key] = val; }
341
- }
342
- }
343
- console.log(JSON.stringify(input));
278
+ const val = getNestedField(input, args[0]);
279
+ if (typeof val === 'string' && !val.includes('\0')) process.stdout.write(val);
344
280
  break;
345
281
  }
346
282
 
@@ -356,75 +292,6 @@ try {
356
292
  break;
357
293
  }
358
294
 
359
- case 'extract-text-messages': {
360
- const input = JSON.parse(readStdin());
361
- const content = input?.message?.content;
362
- if (typeof content === 'string') {
363
- console.log(content);
364
- break;
365
- }
366
- if (!Array.isArray(content)) {
367
- console.log('');
368
- break;
369
- }
370
- const texts = content
371
- .filter(c => c.type === 'text')
372
- .map(c => c.text);
373
- console.log(texts.join('\n'));
374
- break;
375
- }
376
-
377
- case 'merge-evidence': {
378
- const input = JSON.parse(readStdin());
379
- // input is [[old_evidence], [new_evidence]] — flatten, dedupe, limit
380
- const flat = input.flat();
381
- const unique = [...new Set(flat)];
382
- console.log(JSON.stringify(unique.slice(0, 10)));
383
- break;
384
- }
385
-
386
- case 'slurp-sort': {
387
- const field = args[0];
388
- const limit = parseInt(args[1]) || 30;
389
- const parsed = parseJsonlText(readStdin());
390
- parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
391
- console.log(JSON.stringify(parsed.slice(0, limit)));
392
- break;
393
- }
394
-
395
- case 'slurp-cap': {
396
- // Read JSONL, sort by field desc, output top N as JSONL (one per line)
397
- const field = args[0];
398
- const limit = parseInt(args[1]) || 100;
399
- const parsed = parseJsonlText(readStdin());
400
- parsed.sort((a, b) => (b[field] || 0) - (a[field] || 0));
401
- for (const item of parsed.slice(0, limit)) {
402
- console.log(JSON.stringify(item));
403
- }
404
- break;
405
- }
406
-
407
- case 'array-length': {
408
- const input = JSON.parse(readStdin());
409
- const dotPath = args[0];
410
- const arr = getNestedField(input, dotPath);
411
- console.log(Array.isArray(arr) ? arr.length : 0);
412
- break;
413
- }
414
-
415
- case 'array-item': {
416
- const input = JSON.parse(readStdin());
417
- const dotPath = args[0];
418
- const index = parseInt(args[1]);
419
- const arr = getNestedField(input, dotPath);
420
- if (Array.isArray(arr) && index >= 0 && index < arr.length) {
421
- console.log(JSON.stringify(arr[index]));
422
- } else {
423
- console.log('null');
424
- }
425
- break;
426
- }
427
-
428
295
  case 'session-output': {
429
296
  const ctx = args[0];
430
297
  console.log(JSON.stringify({
@@ -1,10 +1,12 @@
1
1
  #!/bin/bash
2
2
 
3
- # JSON parsing helper — tries jq, falls back to node json-helper.js
3
+ # JSON parsing helper — tries jq, falls back to node json-helper.cjs
4
4
  # Usage: source "$SCRIPT_DIR/json-parse"
5
5
  #
6
6
  # After sourcing, check $_JSON_AVAILABLE before using json_* functions.
7
7
  # All functions read from stdin unless noted otherwise.
8
+ # Where a jq path discards stderr, its node fallback does too, so input that
9
+ # cannot be parsed fails silently on either backend.
8
10
 
9
11
  _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
12
  _JSON_HELPER="$_SCRIPT_DIR/json-helper.cjs"
@@ -28,134 +30,42 @@ json_field() {
28
30
  if [ "$_HAS_JQ" = "true" ]; then
29
31
  jq -r ".$field // \"$default\"" 2>/dev/null
30
32
  else
31
- node "$_JSON_HELPER" get-field "$field" "$default"
33
+ node "$_JSON_HELPER" get-field "$field" "$default" 2>/dev/null
32
34
  fi
33
35
  }
34
36
 
35
37
  # Extract a field from a JSON file. Usage: json_field_file "/path/to/file.json" "field" "default"
36
38
  # Note: uses if/then/else to preserve boolean false (jq // operator would replace false with default)
37
39
  # The node fallback reads the file on stdin: json-helper takes no file path.
40
+ # Both paths are silent on stderr: an unparseable or missing file yields empty
41
+ # output and a failing status, as jq does with its errors discarded. The node
42
+ # path redirects stderr BEFORE stdin, so the shell's own error for a `< "$file"`
43
+ # that cannot open is discarded too.
38
44
  json_field_file() {
39
45
  local file="$1" field="$2" default="${3:-}"
40
46
  if [ "$_HAS_JQ" = "true" ]; then
41
47
  jq -r "if (.$field | type) == \"null\" then \"$default\" else (.$field | tostring) end" "$file" 2>/dev/null
42
48
  else
43
- node "$_JSON_HELPER" get-field "$field" "$default" < "$file"
49
+ node "$_JSON_HELPER" get-field "$field" "$default" 2>/dev/null < "$file"
44
50
  fi
45
51
  }
46
52
 
47
- # --- Validation ---
48
-
49
- # Validate stdin as JSON (exit code 0/1). Usage: echo '{}' | json_valid
50
- json_valid() {
51
- if [ "$_HAS_JQ" = "true" ]; then
52
- jq -e . >/dev/null 2>&1
53
- else
54
- node "$_JSON_HELPER" validate
55
- fi
56
- }
57
-
58
- # --- Compact ---
59
-
60
- # Compact stdin JSON to single line. Usage: echo '{ "k": "v" }' | json_compact
61
- json_compact() {
62
- if [ "$_HAS_JQ" = "true" ]; then
63
- jq -c '.' 2>/dev/null
64
- else
65
- node "$_JSON_HELPER" compact
66
- fi
67
- }
68
-
69
- # --- Construction ---
70
-
71
- # Build a JSON object from --arg/--argjson pairs.
72
- # Usage: json_construct --arg key1 val1 --arg key2 val2
73
- json_construct() {
74
- if [ "$_HAS_JQ" = "true" ]; then
75
- # Build jq expression from args
76
- local jq_args=()
77
- local fields=""
78
- local i=0
79
- while [ $i -lt $# ]; do
80
- local flag="${!((i+1))}"
81
- if [ "$flag" = "--arg" ]; then
82
- local name="${!((i+2))}"
83
- local val="${!((i+3))}"
84
- jq_args+=(--arg "$name" "$val")
85
- if [ -n "$fields" ]; then fields="$fields, "; fi
86
- fields="${fields}${name}: \$${name}"
87
- i=$((i + 3))
88
- elif [ "$flag" = "--argjson" ]; then
89
- local name="${!((i+2))}"
90
- local val="${!((i+3))}"
91
- jq_args+=(--argjson "$name" "$val")
92
- if [ -n "$fields" ]; then fields="$fields, "; fi
93
- fields="${fields}${name}: \$${name}"
94
- i=$((i + 3))
95
- else
96
- i=$((i + 1))
97
- fi
98
- done
99
- jq -n -c "${jq_args[@]}" "{$fields}" 2>/dev/null
100
- else
101
- node "$_JSON_HELPER" construct "$@"
102
- fi
103
- }
104
-
105
- # --- Field updates ---
106
-
107
- # Update a field on stdin JSON. Usage: echo '{"k":"v"}' | json_update_field "k" "new_v"
108
- json_update_field() {
109
- local field="$1" value="$2"
110
- if [ "$_HAS_JQ" = "true" ]; then
111
- jq -c --arg v "$value" ".$field = \$v" 2>/dev/null
112
- else
113
- node "$_JSON_HELPER" update-field "$field" "$value"
114
- fi
115
- }
116
-
117
- # Update a field with a JSON value. Usage: echo '{}' | json_update_field_json "k" '42'
118
- json_update_field_json() {
119
- local field="$1" value="$2"
53
+ # The typed read of a field from a JSON file. Usage: json_string_field_file "/path/to/file.json" "field"
54
+ # json_field_file stringifies, so the number 42 and the string "42" read the same;
55
+ # this prints the field only when it is a JSON string, and nothing for any other
56
+ # type (a number, boolean, object, array or null), an absent field, or a string
57
+ # holding a NUL, which no shell variable can carry. The string is printed
58
+ # byte-exact with no newline added, so a caller that must see the value's own
59
+ # trailing newline appends a sentinel before the command substitution strips it:
60
+ # v=$(json_string_field_file "$file" "k"; printf x); v=${v%x}
61
+ # Silent on stderr and failing on an unparseable or missing file, as json_field_file
62
+ # is, and read the same way on both backends (the node path reads the file on stdin).
63
+ json_string_field_file() {
64
+ local file="$1" field="$2"
120
65
  if [ "$_HAS_JQ" = "true" ]; then
121
- jq -c --argjson v "$value" ".$field = \$v" 2>/dev/null
66
+ jq -j ".$field | select(type == \"string\" and all(explode[]; . != 0))" "$file" 2>/dev/null
122
67
  else
123
- node "$_JSON_HELPER" update-field "$field" "$value" --json
124
- fi
125
- }
126
-
127
- # --- Slurp operations ---
128
-
129
- # Slurp JSONL file, sort by field desc, output as JSONL (one line per entry).
130
- # Usage: json_slurp_cap "file.jsonl" "confidence" 100
131
- json_slurp_cap() {
132
- local file="$1" field="$2" limit="${3:-100}"
133
- if [ "$_HAS_JQ" = "true" ]; then
134
- jq -c '.' "$file" | jq -s "sort_by(.$field) | reverse | .[0:$limit][]" 2>/dev/null
135
- else
136
- node "$_JSON_HELPER" slurp-cap "$field" "$limit" < "$file"
137
- fi
138
- }
139
-
140
- # --- Array operations ---
141
-
142
- # Get length of array at path. Usage: echo '{"obs":[1,2]}' | json_array_length "observations"
143
- json_array_length() {
144
- local path="$1"
145
- if [ "$_HAS_JQ" = "true" ]; then
146
- jq ".$path | length" 2>/dev/null
147
- else
148
- node "$_JSON_HELPER" array-length "$path"
149
- fi
150
- }
151
-
152
- # Get item at index from array. Usage: echo '{"obs":[{},{}]}' | json_array_item "observations" 0
153
- json_array_item() {
154
- local path="$1" index="$2"
155
- if [ "$_HAS_JQ" = "true" ]; then
156
- jq -c ".${path}[$index]" 2>/dev/null
157
- else
158
- node "$_JSON_HELPER" array-item "$path" "$index"
68
+ node "$_JSON_HELPER" get-string-field "$field" 2>/dev/null < "$file"
159
69
  fi
160
70
  }
161
71
 
@@ -176,7 +86,7 @@ json_extract_cwd_field() {
176
86
  if [ "$_HAS_JQ" = "true" ]; then
177
87
  jq -r --arg f "$field" '(.cwd // "") + "\u0001" + (.[$f] // "")' 2>/dev/null
178
88
  else
179
- node "$_JSON_HELPER" extract-cwd-field "$field"
89
+ node "$_JSON_HELPER" extract-cwd-field "$field" 2>/dev/null
180
90
  fi
181
91
  }
182
92
 
@@ -186,21 +96,6 @@ json_extract_cwd_prompt() {
186
96
  json_extract_cwd_field "prompt"
187
97
  }
188
98
 
189
- # --- Transcript extraction ---
190
-
191
- # Extract text messages from Claude message JSON. Usage: printf '%s\n' '{"message":...}' | json_extract_messages
192
- json_extract_messages() {
193
- if [ "$_HAS_JQ" = "true" ]; then
194
- jq -r 'if .message.content then
195
- if (.message.content | type) == "string" then .message.content
196
- else [.message.content[] | select(.type == "text") | .text] | join("\n")
197
- end
198
- else "" end' 2>/dev/null
199
- else
200
- node "$_JSON_HELPER" extract-text-messages
201
- fi
202
- }
203
-
204
99
  # --- Hook output envelopes ---
205
100
 
206
101
  # Build SessionStart output. Usage: json_session_output "$CONTEXT"