agent-composer 0.3.0 → 0.4.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 (181) hide show
  1. package/README.md +495 -180
  2. package/composer.config.schema.json +206 -2
  3. package/dist/cli/cleanup.d.ts +24 -0
  4. package/dist/cli/cleanup.js +151 -0
  5. package/dist/cli/cleanup.js.map +1 -0
  6. package/dist/cli/doctor.d.ts +12 -0
  7. package/dist/cli/doctor.js +244 -4
  8. package/dist/cli/doctor.js.map +1 -1
  9. package/dist/cli/goal.d.ts +28 -0
  10. package/dist/cli/goal.js +251 -0
  11. package/dist/cli/goal.js.map +1 -0
  12. package/dist/cli/help.d.ts +3 -0
  13. package/dist/cli/help.js +31 -0
  14. package/dist/cli/help.js.map +1 -0
  15. package/dist/cli/init.d.ts +5 -0
  16. package/dist/cli/init.js +116 -21
  17. package/dist/cli/init.js.map +1 -1
  18. package/dist/cli/initArgs.d.ts +16 -0
  19. package/dist/cli/initArgs.js +19 -0
  20. package/dist/cli/initArgs.js.map +1 -0
  21. package/dist/cli/installGitHook.d.ts +7 -0
  22. package/dist/cli/installGitHook.js +61 -0
  23. package/dist/cli/installGitHook.js.map +1 -0
  24. package/dist/cli/mode.d.ts +6 -0
  25. package/dist/cli/mode.js +25 -0
  26. package/dist/cli/mode.js.map +1 -0
  27. package/dist/cli/status.d.ts +105 -0
  28. package/dist/cli/status.js +400 -0
  29. package/dist/cli/status.js.map +1 -0
  30. package/dist/config/env.d.ts +1 -1
  31. package/dist/config/modes.d.ts +10 -0
  32. package/dist/config/modes.js +26 -0
  33. package/dist/config/modes.js.map +1 -0
  34. package/dist/config/oracleRole.d.ts +10 -0
  35. package/dist/config/oracleRole.js +11 -0
  36. package/dist/config/oracleRole.js.map +1 -0
  37. package/dist/config/schema.d.ts +246 -0
  38. package/dist/config/schema.js +127 -2
  39. package/dist/config/schema.js.map +1 -1
  40. package/dist/evolve/reflection.d.ts +9 -0
  41. package/dist/evolve/reflection.js +14 -0
  42. package/dist/evolve/reflection.js.map +1 -1
  43. package/dist/evolve/runner.d.ts +2 -1
  44. package/dist/evolve/runner.js +2 -1
  45. package/dist/evolve/runner.js.map +1 -1
  46. package/dist/index.js +115 -6
  47. package/dist/index.js.map +1 -1
  48. package/dist/providers/AnthropicCompatibleProvider.d.ts +13 -1
  49. package/dist/providers/AnthropicCompatibleProvider.js +115 -9
  50. package/dist/providers/AnthropicCompatibleProvider.js.map +1 -1
  51. package/dist/providers/CLIProvider.d.ts +23 -1
  52. package/dist/providers/CLIProvider.js +283 -65
  53. package/dist/providers/CLIProvider.js.map +1 -1
  54. package/dist/providers/IProvider.d.ts +13 -0
  55. package/dist/providers/SpendGuardProvider.d.ts +32 -0
  56. package/dist/providers/SpendGuardProvider.js +98 -0
  57. package/dist/providers/SpendGuardProvider.js.map +1 -0
  58. package/dist/registry.d.ts +5 -2
  59. package/dist/registry.js +17 -2
  60. package/dist/registry.js.map +1 -1
  61. package/dist/server/activeRuns.d.ts +17 -0
  62. package/dist/server/activeRuns.js +114 -0
  63. package/dist/server/activeRuns.js.map +1 -0
  64. package/dist/server/codexLifecycleRunner.d.ts +29 -0
  65. package/dist/server/codexLifecycleRunner.js +188 -0
  66. package/dist/server/codexLifecycleRunner.js.map +1 -0
  67. package/dist/server/configMutation.d.ts +22 -0
  68. package/dist/server/configMutation.js +121 -0
  69. package/dist/server/configMutation.js.map +1 -0
  70. package/dist/server/handoffContext.d.ts +1 -0
  71. package/dist/server/handoffContext.js +12 -0
  72. package/dist/server/handoffContext.js.map +1 -0
  73. package/dist/server/progress.d.ts +24 -0
  74. package/dist/server/progress.js +109 -0
  75. package/dist/server/progress.js.map +1 -0
  76. package/dist/server/toolDescriptions.d.ts +60 -0
  77. package/dist/server/toolDescriptions.js +134 -0
  78. package/dist/server/toolDescriptions.js.map +1 -0
  79. package/dist/server.d.ts +19 -21
  80. package/dist/server.js +90 -330
  81. package/dist/server.js.map +1 -1
  82. package/dist/tools/audit.d.ts +2 -0
  83. package/dist/tools/audit.js +66 -0
  84. package/dist/tools/audit.js.map +1 -0
  85. package/dist/tools/code.d.ts +2 -0
  86. package/dist/tools/code.js +160 -0
  87. package/dist/tools/code.js.map +1 -0
  88. package/dist/tools/codexLifecycle.d.ts +2 -0
  89. package/dist/tools/codexLifecycle.js +206 -0
  90. package/dist/tools/codexLifecycle.js.map +1 -0
  91. package/dist/tools/config.d.ts +2 -0
  92. package/dist/tools/config.js +183 -0
  93. package/dist/tools/config.js.map +1 -0
  94. package/dist/tools/context.d.ts +31 -0
  95. package/dist/tools/context.js +2 -0
  96. package/dist/tools/context.js.map +1 -0
  97. package/dist/tools/goal.d.ts +2 -0
  98. package/dist/tools/goal.js +159 -0
  99. package/dist/tools/goal.js.map +1 -0
  100. package/dist/tools/handoff.d.ts +2 -0
  101. package/dist/tools/handoff.js +57 -0
  102. package/dist/tools/handoff.js.map +1 -0
  103. package/dist/tools/oracle.d.ts +2 -0
  104. package/dist/tools/oracle.js +248 -0
  105. package/dist/tools/oracle.js.map +1 -0
  106. package/dist/tools/research.d.ts +2 -0
  107. package/dist/tools/research.js +51 -0
  108. package/dist/tools/research.js.map +1 -0
  109. package/dist/tools/review.d.ts +2 -0
  110. package/dist/tools/review.js +233 -0
  111. package/dist/tools/review.js.map +1 -0
  112. package/dist/tools/route.d.ts +2 -0
  113. package/dist/tools/route.js +69 -0
  114. package/dist/tools/route.js.map +1 -0
  115. package/dist/tools/session.d.ts +2 -0
  116. package/dist/tools/session.js +37 -0
  117. package/dist/tools/session.js.map +1 -0
  118. package/dist/tools/status.d.ts +2 -0
  119. package/dist/tools/status.js +34 -0
  120. package/dist/tools/status.js.map +1 -0
  121. package/dist/tools/workflow.d.ts +2 -0
  122. package/dist/tools/workflow.js +27 -0
  123. package/dist/tools/workflow.js.map +1 -0
  124. package/dist/util/applyFileBlocks.d.ts +18 -0
  125. package/dist/util/applyFileBlocks.js +163 -0
  126. package/dist/util/applyFileBlocks.js.map +1 -0
  127. package/dist/util/asyncControl.d.ts +14 -0
  128. package/dist/util/asyncControl.js +106 -0
  129. package/dist/util/asyncControl.js.map +1 -0
  130. package/dist/util/auditLog.d.ts +56 -0
  131. package/dist/util/auditLog.js +232 -0
  132. package/dist/util/auditLog.js.map +1 -0
  133. package/dist/util/codexLifecycle.d.ts +55 -0
  134. package/dist/util/codexLifecycle.js +102 -0
  135. package/dist/util/codexLifecycle.js.map +1 -0
  136. package/dist/util/codexLifecycleJob.d.ts +209 -0
  137. package/dist/util/codexLifecycleJob.js +360 -0
  138. package/dist/util/codexLifecycleJob.js.map +1 -0
  139. package/dist/util/composerDisabled.d.ts +6 -0
  140. package/dist/util/composerDisabled.js +27 -0
  141. package/dist/util/composerDisabled.js.map +1 -0
  142. package/dist/util/dispatchHint.d.ts +5 -3
  143. package/dist/util/dispatchHint.js +62 -2
  144. package/dist/util/dispatchHint.js.map +1 -1
  145. package/dist/util/goal.d.ts +132 -0
  146. package/dist/util/goal.js +616 -0
  147. package/dist/util/goal.js.map +1 -0
  148. package/dist/util/goalReport.d.ts +51 -0
  149. package/dist/util/goalReport.js +164 -0
  150. package/dist/util/goalReport.js.map +1 -0
  151. package/dist/util/jobPolling.d.ts +9 -0
  152. package/dist/util/jobPolling.js +17 -0
  153. package/dist/util/jobPolling.js.map +1 -0
  154. package/dist/util/oracleJob.d.ts +66 -0
  155. package/dist/util/oracleJob.js +295 -0
  156. package/dist/util/oracleJob.js.map +1 -0
  157. package/dist/util/oracleLock.d.ts +38 -0
  158. package/dist/util/oracleLock.js +182 -0
  159. package/dist/util/oracleLock.js.map +1 -0
  160. package/dist/util/reviewDiff.d.ts +8 -0
  161. package/dist/util/reviewDiff.js +29 -0
  162. package/dist/util/reviewDiff.js.map +1 -0
  163. package/dist/util/reviewJob.d.ts +57 -0
  164. package/dist/util/reviewJob.js +207 -0
  165. package/dist/util/reviewJob.js.map +1 -0
  166. package/dist/util/workflowPlan.d.ts +24 -0
  167. package/dist/util/workflowPlan.js +49 -0
  168. package/dist/util/workflowPlan.js.map +1 -0
  169. package/package.json +8 -1
  170. package/plugin/composer-mastermind/commands/evolve.md +4 -0
  171. package/plugin/composer-mastermind/hooks/boundary_guard.sh +43 -2
  172. package/plugin/composer-mastermind/hooks/codex_warm_review.sh +161 -9
  173. package/plugin/composer-mastermind/hooks/learn.sh +172 -32
  174. package/plugin/composer-mastermind/hooks/precommit_codex_review.sh +430 -62
  175. package/plugin/composer-mastermind/plugin.json +1 -1
  176. package/plugin/composer-mastermind/skills/composer-mastermind/SKILL.md +190 -4
  177. package/scripts/composer-oracle-router-safe.sh +47 -0
  178. package/scripts/composer-statusline-segment.mjs +40 -0
  179. package/scripts/oracle-codex-handoff-safe.sh +49 -0
  180. package/scripts/oracle-plan-mcp.sh +66 -0
  181. package/scripts/oracle-pro-safe.sh +471 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-composer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Multi-agent orchestration MCP server. Claude orchestrates; GLM, Codex, and agy do the work.",
6
6
  "bin": {
@@ -9,6 +9,11 @@
9
9
  "files": [
10
10
  "dist/",
11
11
  "plugin/",
12
+ "scripts/oracle-pro-safe.sh",
13
+ "scripts/oracle-plan-mcp.sh",
14
+ "scripts/composer-oracle-router-safe.sh",
15
+ "scripts/oracle-codex-handoff-safe.sh",
16
+ "scripts/composer-statusline-segment.mjs",
12
17
  "composer.config.schema.json",
13
18
  "README.md"
14
19
  ],
@@ -32,6 +37,8 @@
32
37
  "test:hooks": "bash tests/hooks/run.sh",
33
38
  "test:scripts": "bash tests/scripts/run.sh",
34
39
  "test:all": "npm run test && npm run test:hooks && npm run test:scripts",
40
+ "ci": "npm run typecheck && npm run test && npm run test:hooks && npm run test:scripts && npm run schema:lint",
41
+ "bench:speed": "node --import tsx scripts/bench-composer.mjs",
35
42
  "eval:routes": "tsx evals/scripts/route-compare.ts",
36
43
  "usage": "npx -y ccusage daily",
37
44
  "schema:lint": "ajv compile --strict=true -c ajv-formats -s composer.config.schema.json && ajv validate --strict=false -c ajv-formats -s composer.config.schema.json -d composer.config.json && ajv compile --strict=true -s plugin.schema.json && ajv validate --strict=true -s plugin.schema.json -d 'plugin/*/plugin.json'",
@@ -52,6 +52,10 @@ The summary prints to stdout:
52
52
  - Task descriptions in `evals/tasks.jsonl` are passed verbatim to the reflection provider — keep that file under version control to retain a clear trust boundary.
53
53
  - **SKILL.md edits are safe during a real-mode run.** Each task eval runs in a throwaway `git worktree` at `/tmp/composer-eval-<pid>-<taskId>`. The candidate skill is written only into the worktree copy; the real repo's SKILL.md is never touched during evaluation. You can freely edit SKILL.md in your editor while `/evolve --eval-mode real` is running.
54
54
 
55
+ ## Evidence
56
+
57
+ The reflection mutator now receives recent route/audit failures from the durable audit trail (`composer_audit_record` → `readAuditFailures`). Up to 20 recent failures (events with `status=failed` or `userCorrection=true`) are extracted from the audit log and injected into the reflection prompt before the current-ecosystem section. This means proposed skill rewrites are driven by real routing and outcome failures — wrong route choice, unnecessary Oracle use, issues a review caught after the fact, user-corrected routes — not just synthetic scorer signals.
58
+
55
59
  ## v1 caveat
56
60
 
57
61
  The v1 scorer is synthetic (heuristic-based):
@@ -6,6 +6,9 @@
6
6
  # exit: always 0; semantics carried by JSON
7
7
  # Fail-closed: any unexpected condition (missing jq / empty stdin /
8
8
  # malformed JSON / absent tool_name) MUST emit a deny payload.
9
+ # Scope: GLOBAL. When Composer is enabled, this hook denies main-thread
10
+ # file-mutation tools in every repo and every path. Enforcement is controlled
11
+ # only by kill switches such as ~/.claude/composer.disabled or /composer disable.
9
12
 
10
13
  set -u
11
14
 
@@ -51,7 +54,11 @@ if ! command -v jq >/dev/null 2>&1; then
51
54
  fi
52
55
 
53
56
  # 2. Read tool-call JSON from stdin.
54
- INPUT="$(cat || true)"
57
+ if command -v timeout >/dev/null 2>&1; then
58
+ INPUT="$(timeout 5 cat 2>/dev/null || true)"
59
+ else
60
+ INPUT="$(cat || true)"
61
+ fi
55
62
  if [[ -z "$INPUT" ]]; then
56
63
  emit_deny "boundary_guard: empty stdin, failing closed"
57
64
  fi
@@ -101,6 +108,40 @@ if [[ "$TRANSCRIPT" == */subagents/* ]] \
101
108
  exit 0
102
109
  fi
103
110
 
111
+ # 3.8. Brain housekeeping carve-out.
112
+ # Claude (the orchestrator / "brain") must persist its OWN state — e.g.
113
+ # cross-session memory — even while enforcement is ON. These paths are NOT
114
+ # project code, so routing them through the executor adds no safety. Allow a
115
+ # main-thread mutation whose target path is under an approved brain-state
116
+ # root. Default: the Claude memory store. Extra roots may be added via
117
+ # COMPOSER_GUARD_ALLOW_GLOBS (colon-separated case globs). Paths containing a
118
+ # ".." traversal segment are NEVER allow-listed, so an allowed prefix cannot
119
+ # be used to escape into ~/.claude/hooks or similar. Tools without a file
120
+ # path (Bash, mcp exec/bash) have no FILE and fall through to the block list.
121
+ FILE="$(jq -r '.tool_input.file_path // .tool_input.path // .tool_input.notebook_path // empty' <<<"$INPUT" 2>/dev/null)"
122
+ if [[ -n "$FILE" && "$FILE" != *"/../"* && "$FILE" != *"/.." ]]; then
123
+ brain_globs="$HOME/.claude/projects/*/memory/*"
124
+ if [[ -n "${COMPOSER_GUARD_ALLOW_GLOBS:-}" ]]; then
125
+ brain_globs="$brain_globs:$COMPOSER_GUARD_ALLOW_GLOBS"
126
+ fi
127
+ set -f
128
+ brain_old_ifs="$IFS"
129
+ IFS=':'
130
+ for glob in $brain_globs; do
131
+ [[ -z "$glob" ]] && continue
132
+ # shellcheck disable=SC2254
133
+ case "$FILE" in
134
+ $glob)
135
+ IFS="$brain_old_ifs"
136
+ set +f
137
+ exit 0
138
+ ;;
139
+ esac
140
+ done
141
+ IFS="$brain_old_ifs"
142
+ set +f
143
+ fi
144
+
104
145
  # 4. Block list — native dangerous file-mutating tools + MCP-prefixed variants.
105
146
  # Native Bash is allowed on the main thread for inspection and verification;
106
147
  # the orchestrator skill still forbids using Bash to author code or perform
@@ -110,7 +151,7 @@ case "$TOOL" in
110
151
  Edit|Update|Write|NotebookEdit \
111
152
  | mcp__*__write_file | mcp__*__edit_file | mcp__*__bash \
112
153
  | mcp__*__write | mcp__*__edit | mcp__*__exec)
113
- emit_deny "DENY (main thread): route Edit/Update/Write via Task(subagent_type=\"coder\"). Native Bash is allowed for inspection and verification."
154
+ emit_deny "Composer is handling file edits. Route this change through composer_code_cli (or composer_code_chain), or run /composer disable to edit directly. Bash inspection stays available."
114
155
  ;;
115
156
  esac
116
157
 
@@ -300,19 +300,171 @@ if [[ "$status" -ne 0 || -z "$output" ]] || ! jq -e . >/dev/null 2>&1 <<<"$outpu
300
300
  append_run_log "skip" "$duration_ms" 0
301
301
  exit 0
302
302
  fi
303
- if jq -e "(.parseError // null) as \$error | (\$error != null and \$error != false and ((\$error | tostring) | length) > 0)" >/dev/null 2>&1 <<<"$output"; then
303
+ parse_error_message="$(jq -r '
304
+ def parsed_json($value):
305
+ if ($value | type) == "string" then (($value | fromjson?) // {}) else {} end;
306
+ def first_error($items):
307
+ [
308
+ $items[]
309
+ | select(. != null and . != false and ((. | tostring) | length) > 0)
310
+ ]
311
+ | first // "";
312
+
313
+ (parsed_json(.rawOutput? // null)) as $rawOutputJson
314
+ | (parsed_json(.codex.stdout? // null)) as $codexStdoutJson
315
+ | first_error([
316
+ .parseError?,
317
+ .result.parseError?,
318
+ .review?.parseError?,
319
+ .review?.result?.parseError?,
320
+ .data?.parseError?,
321
+ .data?.result?.parseError?,
322
+ .output?.parseError?,
323
+ .output?.result?.parseError?,
324
+ .response?.parseError?,
325
+ .response?.result?.parseError?,
326
+ .payload?.parseError?,
327
+ .payload?.result?.parseError?,
328
+ $rawOutputJson.parseError?,
329
+ $rawOutputJson.result.parseError?,
330
+ $codexStdoutJson.parseError?,
331
+ $codexStdoutJson.result.parseError?
332
+ ])
333
+ ' <<<"$output" 2>/dev/null || true)"
334
+ if [[ -n "$parse_error_message" ]]; then
304
335
  append_run_log "skip" "$duration_ms" 0
305
336
  exit 0
306
337
  fi
307
- normalized="$(jq -c "
308
- def array_or_empty(\$value): if (\$value | type) == \"array\" then \$value else [] end;
309
- {
310
- verdict: (.result.verdict // .verdict // null),
311
- summary: (.result.summary // .summary // \"\"),
312
- findings: array_or_empty(.result.findings // .findings // []),
313
- next_steps: array_or_empty(.result.next_steps // .next_steps // [])
338
+ normalized="$(jq -c '
339
+ def parsed_json($value):
340
+ if ($value | type) == "string" then (($value | fromjson?) // {}) else {} end;
341
+ def first_value($items):
342
+ [
343
+ $items[]
344
+ | select(. != null and . != "")
345
+ ]
346
+ | first // null;
347
+ def first_text($items):
348
+ [
349
+ $items[]
350
+ | select((. | type) == "string" and length > 0)
351
+ ]
352
+ | first // "";
353
+ def first_array($items):
354
+ [
355
+ $items[]
356
+ | select((. | type) == "array")
357
+ ]
358
+ | first // [];
359
+
360
+ (parsed_json(.rawOutput? // null)) as $rawOutputJson
361
+ | (parsed_json(.codex.stdout? // null)) as $codexStdoutJson
362
+ | (parsed_json(.stdout? // null)) as $stdoutJson
363
+ | {
364
+ verdict: first_value([
365
+ .result.verdict?,
366
+ .verdict?,
367
+ .review?.result?.verdict?,
368
+ .review?.verdict?,
369
+ .data?.result?.verdict?,
370
+ .data?.verdict?,
371
+ .output?.result?.verdict?,
372
+ .output?.verdict?,
373
+ .response?.result?.verdict?,
374
+ .response?.verdict?,
375
+ .payload?.result?.verdict?,
376
+ .payload?.verdict?,
377
+ $rawOutputJson.result.verdict?,
378
+ $rawOutputJson.verdict?,
379
+ $rawOutputJson.review?.result?.verdict?,
380
+ $rawOutputJson.review?.verdict?,
381
+ $codexStdoutJson.result.verdict?,
382
+ $codexStdoutJson.verdict?,
383
+ $codexStdoutJson.review?.result?.verdict?,
384
+ $codexStdoutJson.review?.verdict?,
385
+ $stdoutJson.result.verdict?,
386
+ $stdoutJson.verdict?
387
+ ]),
388
+ summary: (first_text([
389
+ .result.summary?,
390
+ .summary?,
391
+ .review?.result?.summary?,
392
+ .review?.summary?,
393
+ .data?.result?.summary?,
394
+ .data?.summary?,
395
+ .output?.result?.summary?,
396
+ .output?.summary?,
397
+ .response?.result?.summary?,
398
+ .response?.summary?,
399
+ .payload?.result?.summary?,
400
+ .payload?.summary?,
401
+ $rawOutputJson.result.summary?,
402
+ $rawOutputJson.summary?,
403
+ $rawOutputJson.review?.result?.summary?,
404
+ $rawOutputJson.review?.summary?,
405
+ $codexStdoutJson.result.summary?,
406
+ $codexStdoutJson.summary?,
407
+ $codexStdoutJson.review?.result?.summary?,
408
+ $codexStdoutJson.review?.summary?,
409
+ $stdoutJson.result.summary?,
410
+ $stdoutJson.summary?
411
+ ]) // ""),
412
+ findings: first_array([
413
+ .result.findings?,
414
+ .findings?,
415
+ .review?.result?.findings?,
416
+ .review?.findings?,
417
+ .data?.result?.findings?,
418
+ .data?.findings?,
419
+ .output?.result?.findings?,
420
+ .output?.findings?,
421
+ .response?.result?.findings?,
422
+ .response?.findings?,
423
+ .payload?.result?.findings?,
424
+ .payload?.findings?,
425
+ $rawOutputJson.result.findings?,
426
+ $rawOutputJson.findings?,
427
+ $rawOutputJson.review?.result?.findings?,
428
+ $rawOutputJson.review?.findings?,
429
+ $codexStdoutJson.result.findings?,
430
+ $codexStdoutJson.findings?,
431
+ $codexStdoutJson.review?.result?.findings?,
432
+ $codexStdoutJson.review?.findings?,
433
+ $stdoutJson.result.findings?,
434
+ $stdoutJson.findings?
435
+ ]),
436
+ next_steps: first_array([
437
+ .result.next_steps?,
438
+ .next_steps?,
439
+ .review?.result?.next_steps?,
440
+ .review?.next_steps?,
441
+ .data?.result?.next_steps?,
442
+ .data?.next_steps?,
443
+ .output?.result?.next_steps?,
444
+ .output?.next_steps?,
445
+ .response?.result?.next_steps?,
446
+ .response?.next_steps?,
447
+ .payload?.result?.next_steps?,
448
+ .payload?.next_steps?,
449
+ $rawOutputJson.result.next_steps?,
450
+ $rawOutputJson.next_steps?,
451
+ $rawOutputJson.review?.result?.next_steps?,
452
+ $rawOutputJson.review?.next_steps?,
453
+ $codexStdoutJson.result.next_steps?,
454
+ $codexStdoutJson.next_steps?,
455
+ $codexStdoutJson.review?.result?.next_steps?,
456
+ $codexStdoutJson.review?.next_steps?,
457
+ $stdoutJson.result.next_steps?,
458
+ $stdoutJson.next_steps?
459
+ ]),
460
+ raw_text: first_text([
461
+ .codex.stdout?,
462
+ .rawOutput?,
463
+ .stdout?,
464
+ .review?
465
+ ])
314
466
  }
315
- " <<<"$output" 2>/dev/null || true)"
467
+ ' <<<"$output" 2>/dev/null || true)"
316
468
  if [[ -z "$normalized" ]] || ! jq -e . >/dev/null 2>&1 <<<"$normalized"; then
317
469
  append_run_log "skip" "$duration_ms" 0
318
470
  exit 0
@@ -8,6 +8,19 @@
8
8
 
9
9
  set -u
10
10
 
11
+ LEARN_DEFAULT_TIMEOUT_MS=5000
12
+ LEARN_MAX_TIMEOUT_MS=30000
13
+ LEARN_LOG="${COMPOSER_LEARN_LOG:-/tmp/composer-learn-log.jsonl}"
14
+ LEARN_TEMP_FILES=()
15
+
16
+ cleanup_learn_temp() {
17
+ if ((${#LEARN_TEMP_FILES[@]} > 0)); then
18
+ rm -f "${LEARN_TEMP_FILES[@]}" 2>/dev/null || true
19
+ fi
20
+ }
21
+
22
+ trap cleanup_learn_temp EXIT
23
+
11
24
  composer_disabled() {
12
25
  case "${COMPOSER_ENABLED:-}" in
13
26
  0|false|FALSE|off|OFF|no|NO) return 0 ;;
@@ -31,13 +44,120 @@ if composer_disabled; then
31
44
  exit 0
32
45
  fi
33
46
 
47
+ resolve_learn_timeout_ms() {
48
+ local configured="${COMPOSER_LEARN_HOOK_TIMEOUT_MS:-$LEARN_DEFAULT_TIMEOUT_MS}"
49
+ case "$configured" in
50
+ ''|*[!0-9]*) configured="$LEARN_DEFAULT_TIMEOUT_MS" ;;
51
+ esac
52
+ if [[ "$configured" -lt 1 ]]; then
53
+ configured="$LEARN_DEFAULT_TIMEOUT_MS"
54
+ elif [[ "$configured" -gt "$LEARN_MAX_TIMEOUT_MS" ]]; then
55
+ configured="$LEARN_MAX_TIMEOUT_MS"
56
+ fi
57
+ printf '%s\n' "$configured"
58
+ }
59
+
60
+ timeout_ms_to_seconds() {
61
+ local timeout_ms="$1"
62
+ local seconds=$(( (timeout_ms + 999) / 1000 ))
63
+ [[ "$seconds" -gt 0 ]] || seconds=1
64
+ printf '%s\n' "$seconds"
65
+ }
66
+
67
+ remaining_learn_seconds() {
68
+ local started="$1"
69
+ local total="$2"
70
+ local now elapsed remaining
71
+ now="$(date +%s)"
72
+ elapsed=$(( now - started ))
73
+ remaining=$(( total - elapsed ))
74
+ if [[ "$remaining" -le 0 ]]; then
75
+ printf '0\n'
76
+ else
77
+ printf '%s\n' "$remaining"
78
+ fi
79
+ }
80
+
81
+ learn_mktemp() {
82
+ local path
83
+ path="$(mktemp -t composer_learnings.XXXXXX)" || return 1
84
+ LEARN_TEMP_FILES+=("$path")
85
+ printf '%s\n' "$path"
86
+ }
87
+
88
+ log_learn_timeout() {
89
+ local elapsed_ms="${1:-0}"
90
+ printf '{"ts":"%s","reason_code":"hook_timeout","stage":"learn_stop","elapsed_wall_ms":%s}\n' \
91
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$elapsed_ms" >> "$LEARN_LOG" 2>/dev/null || true
92
+ }
93
+
94
+ kill_tree() {
95
+ local sig="$1" root="$2" child
96
+ kill -STOP "$root" 2>/dev/null || true
97
+ for child in $(pgrep -P "$root" 2>/dev/null); do
98
+ kill_tree "$sig" "$child"
99
+ done
100
+ kill -"$sig" "$root" 2>/dev/null || true
101
+ kill -CONT "$root" 2>/dev/null || true
102
+ }
103
+
104
+ run_with_timeout() {
105
+ local timeout_seconds="$1"
106
+ shift
107
+ local pid watchdog status marker start end
108
+ marker="${TMPDIR:-/tmp}/composer-learn-timeout.$$.$RANDOM"
109
+ start="$(date +%s)"
110
+ ( "$@" ) &
111
+ pid=$!
112
+ (
113
+ sleeper=""
114
+ trap '[[ -n "$sleeper" ]] && kill "$sleeper" 2>/dev/null || true; exit 0' TERM INT
115
+ sleep "$timeout_seconds" &
116
+ sleeper=$!
117
+ wait "$sleeper" 2>/dev/null || exit 0
118
+ printf '1' >"$marker" 2>/dev/null || true
119
+ kill_tree TERM "$pid"
120
+ sleep 1
121
+ kill_tree KILL "$pid"
122
+ ) &
123
+ watchdog=$!
124
+ wait "$pid"
125
+ status=$?
126
+ kill "$watchdog" 2>/dev/null || true
127
+ wait "$watchdog" 2>/dev/null || true
128
+ if [[ -f "$marker" ]]; then
129
+ rm -f "$marker" 2>/dev/null || true
130
+ end="$(date +%s)"
131
+ log_learn_timeout "$(( (end - start) * 1000 ))"
132
+ return 124
133
+ fi
134
+ rm -f "$marker" 2>/dev/null || true
135
+ return "$status"
136
+ }
137
+
138
+ run_capture_with_timeout() {
139
+ local timeout_seconds="$1"
140
+ local stdin_file="$2"
141
+ shift 2
142
+ local output_file status
143
+ output_file="$(learn_mktemp)" || return 1
144
+ run_with_timeout "$timeout_seconds" bash -c 'exec "$@"' _ "$@" < "$stdin_file" > "$output_file" 2>/dev/null
145
+ status=$?
146
+ cat "$output_file" 2>/dev/null || true
147
+ return "$status"
148
+ }
149
+
34
150
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
35
151
  LEARN_DIR="$PROJECT_DIR/.claude/learnings"
36
152
  MONTH="$(date +%Y-%m)"
37
153
  OUT="$LEARN_DIR/${MONTH}.md"
38
154
 
39
155
  # Read JSON envelope from stdin (Anthropic Stop hook contract).
40
- INPUT="$(cat || true)"
156
+ if command -v timeout >/dev/null 2>&1; then
157
+ INPUT="$(timeout 5 cat 2>/dev/null || true)"
158
+ else
159
+ INPUT="$(cat || true)"
160
+ fi
41
161
  if [[ -z "$INPUT" ]]; then
42
162
  exit 0
43
163
  fi
@@ -46,7 +166,24 @@ if ! command -v jq >/dev/null 2>&1; then
46
166
  exit 0
47
167
  fi
48
168
 
49
- TRANSCRIPT_PATH="$(jq -r '.transcript_path // empty' <<<"$INPUT" 2>/dev/null)"
169
+ LEARN_TIMEOUT_MS="$(resolve_learn_timeout_ms)"
170
+ LEARN_TIMEOUT_SECONDS="$(timeout_ms_to_seconds "$LEARN_TIMEOUT_MS")"
171
+ LEARN_STARTED="$(date +%s)"
172
+ INPUT_FILE="$(learn_mktemp)" || exit 0
173
+ printf '%s' "$INPUT" > "$INPUT_FILE" 2>/dev/null || exit 0
174
+ REMAINING_SECONDS="$(remaining_learn_seconds "$LEARN_STARTED" "$LEARN_TIMEOUT_SECONDS")"
175
+ if [[ "$REMAINING_SECONDS" -le 0 ]]; then
176
+ log_learn_timeout "$LEARN_TIMEOUT_MS"
177
+ exit 0
178
+ fi
179
+ TRANSCRIPT_PATH="$(run_capture_with_timeout "$REMAINING_SECONDS" "$INPUT_FILE" jq -r '.transcript_path // empty')"
180
+ TRANSCRIPT_STATUS=$?
181
+ if [[ "$TRANSCRIPT_STATUS" -eq 124 ]]; then
182
+ exit 0
183
+ fi
184
+ if [[ "$TRANSCRIPT_STATUS" -ne 0 ]]; then
185
+ exit 0
186
+ fi
50
187
  if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
51
188
  exit 0
52
189
  fi
@@ -57,39 +194,42 @@ mkdir -p "$LEARN_DIR" 2>/dev/null || exit 0
57
194
  # Conservative regex; expand from real data over time.
58
195
  TRIGGER='(?i)\b(no|don.t|do not|wrong|stop|actually|instead|never|please don.t)\b'
59
196
 
60
- # Anthropic transcripts are JSONL (one event per line). Filter user-role
61
- # events whose content matches the trigger regex, then append new short
62
- # bullets only. Truncate to 400 chars to keep the log scannable.
63
- MATCHES="$(mktemp -t composer_learnings.XXXXXX)" || exit 0
64
- jq -r --arg trig "$TRIGGER" '
65
- select(.role == "user" or .type == "user")
66
- | (.content // .message // "") as $raw
67
- | (if ($raw | type) == "array" then ($raw | map(.text // "") | join(" ")) else ($raw | tostring) end) as $text
68
- | select($text | test($trig))
69
- | "- " + ($text | gsub("\\s+"; " ") | .[0:400])
70
- ' "$TRANSCRIPT_PATH" 2>/dev/null > "$MATCHES" || {
71
- rm -f "$MATCHES"
72
- exit 0
73
- }
197
+ process_transcript() {
198
+ local matches="" new_matches=""
199
+ trap 'rm -f "$matches" "$new_matches" 2>/dev/null || true' EXIT TERM INT
200
+ # Anthropic transcripts are JSONL (one event per line). Filter user-role
201
+ # events whose content matches the trigger regex, then append new short
202
+ # bullets only. Truncate to 400 chars to keep the log scannable.
203
+ matches="$(mktemp -t composer_learnings.XXXXXX)" || exit 0
204
+ jq -r --arg trig "$TRIGGER" '
205
+ select(.role == "user" or .type == "user")
206
+ | (.content // .message // "") as $raw
207
+ | (if ($raw | type) == "array" then ($raw | map(.text // "") | join(" ")) else ($raw | tostring) end) as $text
208
+ | select($text | test($trig))
209
+ | "- " + ($text | gsub("\\s+"; " ") | .[0:400])
210
+ ' "$TRANSCRIPT_PATH" 2>/dev/null > "$matches" || exit 0
74
211
 
75
- NEW_MATCHES="$(mktemp -t composer_learnings_new.XXXXXX)" || {
76
- rm -f "$MATCHES"
77
- exit 0
78
- }
79
- while IFS= read -r line; do
80
- [[ -n "$line" ]] || continue
81
- if [[ ! -f "$OUT" ]] || ! grep -Fxq -- "$line" "$OUT" 2>/dev/null; then
82
- printf '%s\n' "$line" >> "$NEW_MATCHES"
212
+ new_matches="$(mktemp -t composer_learnings_new.XXXXXX)" || exit 0
213
+ while IFS= read -r line; do
214
+ [[ -n "$line" ]] || continue
215
+ if [[ ! -f "$OUT" ]] || ! grep -Fxq -- "$line" "$OUT" 2>/dev/null; then
216
+ printf '%s\n' "$line" >> "$new_matches"
217
+ fi
218
+ done < "$matches"
219
+
220
+ if [[ -s "$new_matches" ]]; then
221
+ {
222
+ printf '\n## Session ended %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
223
+ cat "$new_matches"
224
+ } >> "$OUT" 2>/dev/null || true
83
225
  fi
84
- done < "$MATCHES"
226
+ }
85
227
 
86
- if [[ -s "$NEW_MATCHES" ]]; then
87
- {
88
- printf '\n## Session ended %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
89
- cat "$NEW_MATCHES"
90
- } >> "$OUT" 2>/dev/null || true
228
+ REMAINING_SECONDS="$(remaining_learn_seconds "$LEARN_STARTED" "$LEARN_TIMEOUT_SECONDS")"
229
+ if [[ "$REMAINING_SECONDS" -le 0 ]]; then
230
+ log_learn_timeout "$LEARN_TIMEOUT_MS"
231
+ exit 0
91
232
  fi
92
-
93
- rm -f "$MATCHES" "$NEW_MATCHES"
233
+ run_with_timeout "$REMAINING_SECONDS" process_transcript >/dev/null 2>&1 || true
94
234
 
95
235
  exit 0