@ccoalm/ccl-skills 0.18.10 → 0.18.12

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 (36) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/hooks/headless-background-stop.sh +18 -0
  2. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +7 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +155 -8
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-post-merge-cleanup.sh +9 -4
  5. package/dist/assets/marketplace/plugins/ccl-skills/hooks/task-entry.sh +24 -3
  6. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_headless_background_stop.sh +150 -0
  7. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +71 -0
  8. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_post_merge_cleanup.sh +47 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_task_entry.py +63 -0
  10. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +7 -1
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/development-completion.md +2 -2
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +77 -6
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +112 -0
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/references/multi-agent-delegation-playbook.md +1 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +4 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/worktree-mechanics.md +10 -3
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +2 -2
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +6 -1
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-lifecycle-handoff.md +1 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +2 -0
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +15 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-sync-pointers.sh +11 -4
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/skill-paired-eval.py +1546 -0
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ai_coding_implementation_gates.sh +140 -1
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +4 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_sync_pointers.sh +13 -4
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_controlled_escalation_pins.sh +12 -1
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_skill_paired_eval.py +1052 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_teardown_guard_pins.sh +354 -0
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +8 -108
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/merge-and-teardown.md +47 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/pre-merge-landing-checks.md +73 -0
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/shared-branch-rebase.md +1 -1
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/test_worktree_sweep.sh +1 -1
  35. package/dist/assets/release.json +65 -30
  36. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Stop guard for sessions nothing re-invokes (claude -p, the SDKs): one block per background
3
+ # task still running at the stop, because the host stops those tasks when the session ends and
4
+ # work waiting on them is lost. Interactive sessions exit here untouched. Reads only the hook
5
+ # input, never the transcript or host configuration.
6
+ set -u
7
+ case "${CLAUDE_CODE_ENTRYPOINT:-}" in
8
+ sdk|sdk-*) ;;
9
+ *) exit 0 ;;
10
+ esac
11
+ HELPER="$(cd "$(dirname "$0")" && pwd)/host-input.py"
12
+ if ! command -v python3 >/dev/null 2>&1 || [ ! -r "$HELPER" ]; then
13
+ printf '%s\n' '{"systemMessage":"Background task check unavailable: Python input normalizer missing."}'
14
+ exit 0
15
+ fi
16
+ python3 "$HELPER" headless-background 2>/dev/null ||
17
+ printf '%s\n' '{"systemMessage":"Background task check unavailable: input normalizer failed."}'
18
+ exit 0
@@ -161,6 +161,13 @@
161
161
  "timeout": 5,
162
162
  "async": false,
163
163
  "statusMessage": "Checking delivery handoff..."
164
+ },
165
+ {
166
+ "type": "command",
167
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/headless-background-stop.sh\"",
168
+ "timeout": 5,
169
+ "async": false,
170
+ "statusMessage": "Checking background tasks..."
164
171
  }
165
172
  ]
166
173
  }
@@ -579,8 +579,8 @@ def machine_artifact(text):
579
579
  return False
580
580
 
581
581
 
582
- def stop_notice(payload, lane, message):
583
- """Cap notice attempts only; these markers never establish verification."""
582
+ def claim_notice(payload, lane):
583
+ """True on this lane's first attempt, False on a repeat, None when state is unavailable."""
584
584
  try:
585
585
  # Reuse the installed runtime's owned-directory/no-follow/atomic claim
586
586
  # protections. Minimal vendored runtimes may omit this optional helper.
@@ -596,17 +596,81 @@ def stop_notice(payload, lane, message):
596
596
  info = module.regular_info(path)
597
597
  state = module.State(key)
598
598
  try:
599
- if not state.claim_attempt('stop-notice-' + lane, [info.st_dev, info.st_ino]):
600
- return None
599
+ return bool(state.claim_attempt('stop-notice-' + lane, [info.st_dev, info.st_ino]))
601
600
  finally:
602
601
  state.close()
603
602
  except Exception:
604
- # Missing identity, a broken optional helper or unsafe/unavailable state
605
- # must not invent success. This boundary only controls advisory output.
606
- pass
603
+ return None
604
+
605
+
606
+ def stop_notice(payload, lane, message):
607
+ """Cap notice attempts only; these markers never establish verification."""
608
+ # Missing identity, a broken optional helper or unsafe/unavailable state must
609
+ # not invent success: the notice still shows. This only controls advisory output.
610
+ if claim_notice(payload, lane) is False:
611
+ return None
607
612
  return {'systemMessage': message}
608
613
 
609
614
 
615
+ # Claude Code reports `sdk-cli` for `claude -p` and an `sdk` entrypoint for the SDKs; interactive
616
+ # sessions report other values and are re-invoked when a background task finishes.
617
+ NON_INTERACTIVE_ENTRYPOINT = re.compile(r'sdk(?:-[a-z]+)?')
618
+
619
+
620
+ def claim_background_notice(payload, task_id):
621
+ """True the first time this session stops with the task still running, False on a repeat,
622
+ None when state is unavailable. Keyed by session, not transcript, which may not exist."""
623
+ try:
624
+ source = Path(__file__).resolve().with_name('skill-loading.py')
625
+ spec = importlib.util.spec_from_file_location('ccl_stop_state', source)
626
+ module = importlib.util.module_from_spec(spec)
627
+ spec.loader.exec_module(module)
628
+ session = payload.get('session_id')
629
+ if not isinstance(session, str) or not session or len(session) > 1024:
630
+ return None
631
+ state = module.State(module.digest([str(module.ROOT), session, 'headless-background']))
632
+ try:
633
+ return bool(state.claim_attempt('headless-background', [task_id]))
634
+ finally:
635
+ state.close()
636
+ except Exception:
637
+ return None
638
+
639
+
640
+ def headless_background(payload):
641
+ """Block a stop once per background task still running in a session nothing re-invokes; the
642
+ caller has already checked that the entrypoint is one (NON_INTERACTIVE_ENTRYPOINT).
643
+
644
+ A headless session ends at the stop and the host stops its background tasks seconds later,
645
+ so work waiting on their results is lost. Reads only the hook input, so it also works when
646
+ session persistence is off and the transcript-reading hooks cannot run."""
647
+ if not isinstance(payload, dict) or payload.get('hook_event_name') != 'Stop':
648
+ return None
649
+ tasks = payload.get('background_tasks')
650
+ running = [task for task in tasks if isinstance(task, dict) and task.get('status') == 'running'
651
+ and isinstance(task.get('id'), str) and 0 < len(task['id']) <= 128] if isinstance(tasks, list) else []
652
+ fresh = []
653
+ for task in running:
654
+ claimed = claim_background_notice(payload, task['id'])
655
+ # Without state a repeat cannot be told apart, so the host's own retry flag bounds it.
656
+ if claimed or (claimed is None and payload.get('stop_hook_active') is False):
657
+ fresh.append(task)
658
+ if not fresh:
659
+ return None
660
+ def label(task):
661
+ text = re.sub(r'\s+', ' ', str(task.get('description') or task.get('type') or 'task')).strip()
662
+ return f'- {text[:100]} ({task["id"]})'
663
+ listed = [label(task) for task in fresh[:5]] + ([f'- and {len(fresh) - 5} more'] if len(fresh) > 5 else [])
664
+ return {'decision': 'block', 'reason': (
665
+ 'Background task check: this is a headless session. When you stop, the session ends and these '
666
+ 'background tasks are stopped within seconds, with no notification afterwards:\n' + '\n'.join(listed) +
667
+ '\nIf the request still depends on one of them, wait for that task in the foreground: poll its output '
668
+ 'file, or the files it writes, in a bounded foreground loop until it has finished, then act on its '
669
+ 'result. Do not start it again; a second run would repeat its effects. If none is needed, stop it with '
670
+ 'TaskStop or say why it can be dropped. '
671
+ 'This check fires once per task.')}
672
+
673
+
610
674
  def extraction_overflow(payload):
611
675
  path = payload.get('transcript_path')
612
676
  cwd = payload.get('cwd')
@@ -721,6 +785,75 @@ def doc_closeout_note(payload):
721
785
  'the substance stays as the owning skill decided.'.format(names))
722
786
 
723
787
 
788
+ # Every request re-reads the whole context, so a long session's token cost grows
789
+ # with its size, and a 1M-token window compacts only near its limit by default.
790
+ # The notice goes to the user only (systemMessage, never the model's context),
791
+ # once per band per transcript, and reads just the transcript tail.
792
+ CONTEXT_BANDS = (300000, 600000)
793
+ CONTEXT_TAIL_BYTES = 1024 * 1024
794
+
795
+
796
+ def last_context_tokens(path):
797
+ """Context size of the latest real Claude request, or None when unknown."""
798
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
799
+ with os.fdopen(descriptor, 'rb') as stream:
800
+ metadata = os.fstat(stream.fileno())
801
+ if not stat.S_ISREG(metadata.st_mode):
802
+ return None
803
+ stream.seek(max(0, metadata.st_size - CONTEXT_TAIL_BYTES))
804
+ tail = stream.read(CONTEXT_TAIL_BYTES)
805
+ for line in reversed(tail.split(b'\n')):
806
+ if b'"usage"' not in line:
807
+ continue
808
+ try:
809
+ event = json.loads(line)
810
+ except ValueError:
811
+ continue
812
+ if not isinstance(event, dict) or event.get('type') != 'assistant' or event.get('isSidechain'):
813
+ continue
814
+ message = event.get('message')
815
+ usage = message.get('usage') if isinstance(message, dict) else None
816
+ if not isinstance(usage, dict):
817
+ continue
818
+ total = 0
819
+ for key in ('input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'):
820
+ value = usage.get(key)
821
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0:
822
+ total += value
823
+ if total:
824
+ return total
825
+ return None
826
+
827
+
828
+ def context_notice(payload):
829
+ path = payload.get('transcript_path')
830
+ if not isinstance(path, str) or not path:
831
+ return None
832
+ tokens = last_context_tokens(path)
833
+ reached = [band for band in CONTEXT_BANDS if tokens is not None and tokens >= band]
834
+ if not reached:
835
+ return None
836
+ message = ('本会话上下文约 {} 万 token:之后每次请求都会重读这些内容,长会话的 token 主要花在这里。'
837
+ '当前交付收口后可先写好交接再 /clear 开新会话;或用 /autocompact 把自动压缩提前'
838
+ '(如 /autocompact 400k)。此提示只显示给你,不影响当前任务。').format(tokens // 10000)
839
+ # Optional information: without the state helper it stays quiet rather than
840
+ # repeating on every stop.
841
+ return message if claim_notice(payload, 'context-{}k'.format(reached[-1] // 1000)) is True else None
842
+
843
+
844
+ def with_context_notice(payload, result):
845
+ try:
846
+ notice = context_notice(payload) if isinstance(payload, dict) else None
847
+ except Exception: # advisory: a failed size check never costs another notice
848
+ notice = None
849
+ if not notice:
850
+ return result
851
+ result = dict(result or {})
852
+ result['systemMessage'] = (result['systemMessage'] + '\n' + notice
853
+ if result.get('systemMessage') else notice)
854
+ return result
855
+
856
+
724
857
  def proposed_next(payload):
725
858
  if (not isinstance(payload, dict) or payload.get('hook_event_name') != 'Stop'
726
859
  or payload.get('stop_hook_active') is not False):
@@ -810,6 +943,18 @@ def main():
810
943
  'verifiable': False, 'truncated': True, 'prior_handoff': False,
811
944
  'continuation_contract_visible': False}))
812
945
  return 1
946
+ elif sys.argv[1] == 'headless-background':
947
+ if not NON_INTERACTIVE_ENTRYPOINT.fullmatch(os.environ.get('CLAUDE_CODE_ENTRYPOINT', '')):
948
+ return 0 # an interactive session hears nothing from this check, whatever its input
949
+ try:
950
+ raw = sys.stdin.read(2 * 1024 * 1024 + 1)
951
+ if len(raw) > 2 * 1024 * 1024:
952
+ raise ValueError('oversized input')
953
+ result = headless_background(json.loads(raw))
954
+ if result:
955
+ print(json.dumps(result))
956
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
957
+ print(json.dumps({'systemMessage': 'Background task check unavailable: input could not be verified.'}))
813
958
  elif sys.argv[1] in ('proposed-next', 'extraction-overflow'):
814
959
  payload = {}
815
960
  try:
@@ -818,12 +963,14 @@ def main():
818
963
  raise ValueError('oversized input')
819
964
  payload = json.loads(raw)
820
965
  result = (extraction_overflow(payload) if sys.argv[1] == 'extraction-overflow'
821
- else proposed_next(payload))
966
+ else with_context_notice(payload, proposed_next(payload)))
822
967
  if result:
823
968
  print(json.dumps(result))
824
969
  except TranscriptTruncated:
825
970
  result = stop_notice(payload, 'handoff-overflow',
826
971
  'Delivery handoff reminder unverified: transcript scan exceeded its bounded limit.')
972
+ if sys.argv[1] == 'proposed-next':
973
+ result = with_context_notice(payload, result)
827
974
  if result:
828
975
  print(json.dumps(result))
829
976
  except (OSError, ValueError, TypeError, IndexError, AttributeError):
@@ -70,8 +70,9 @@ masked=$(printf '%s' "$cmd" | sed -E \
70
70
  # `-f body=` values, echoed docs, `git log --grep`), and an advisory that cries
71
71
  # wolf gets ignored. Raw-API / GraphQL / curl merges are an ACCEPTED best-effort
72
72
  # NON-fire — `glab mr merge` / `gh pr merge` is the near-universal agent merge
73
- # path, and the human-readable cleanup rule in worktree-isolation SKILL.md +
74
- # bootstrap covers EVERY merge path regardless of this reminder.
73
+ # path, and the human-readable cleanup rule in worktree-isolation
74
+ # references/merge-and-teardown.md + bootstrap covers EVERY merge path
75
+ # regardless of this reminder.
75
76
  # `gh help pr merge` / `glab help mr merge` print help (the merge guard's help
76
77
  # denial points there). Remove only those literal invocations, never a prefix,
77
78
  # so a real merge before or after them in the same command still matches.
@@ -129,12 +130,16 @@ if command -v git >/dev/null 2>&1 && git -C "$cwd" rev-parse --git-dir >/dev/nul
129
130
  wt=$(git -C "$cwd" worktree list 2>/dev/null)
130
131
  fi
131
132
 
132
- reminder="🧹 worktree-isolation 收尾提醒(自动):检测到 MR/PR 合并命令。先按本节「已集成判据」确认这次合并**已真正完成**(平台 MR/PR 已在当前 head SHA 上 merged;仅授权、仅排队 auto-merge、或合并失败都不算已集成);确认后,若源分支是临时 feature 分支就立即清理三侧,别攒:
133
+ # The text points at the canonical teardown section and carries the guards that
134
+ # must not be lost at this moment; a digest that drops any of them would
135
+ # out-vote the canonical rule the agent loaded earlier.
136
+ reminder="🧹 worktree-isolation 收尾提醒(自动):检测到 MR/PR 合并命令。动手前先读 worktree-isolation/references/merge-and-teardown.md 的「收尾」节,按其「已集成判据」确认这次合并**已真正完成**(平台 MR/PR 已在当前 head SHA 上 merged;仅授权、仅排队 auto-merge、或合并失败都不算已集成);确认后,若源分支是临时 feature 分支就立即清理三侧,别攒:
137
+ git -C <path> status --ignored -s # 删 worktree 前必须先跑且必须 exit 0;非空先按重算代价判定,贵的产物先救回主检出
133
138
  git worktree remove <path> # 不加 --force(脏树/未合并被拒=安全网)
134
139
  git branch -d <branch> # 不加 -D(未合并被拒=安全网)
135
140
  git push origin --delete <branch> # 远端源分支——破坏性,务必先确认已集成再删
136
141
  git worktree prune && git worktree list && git branch # 验证三侧都没了
137
- 唯一例外:源分支本身是永久/集成分支(如 dev→main promotion,源是 dev)——绝不删。squash 合并测不到祖先则保守保留、先确认已集成。"
142
+ 不删的例外:① 源分支本身是永久/集成分支(如 dev→main promotion,源是 dev);② 分支名含 release 的分支(大小写不敏感,如 release/*、hotfix-release),要删由用户显式指名。squash 合并测不到祖先则保守保留、先确认已集成;worktree 里仍有未完成的外部副作用任务(迁移/部署等)时,等它完成再清。"
138
143
  if [ -n "$wt" ]; then
139
144
  reminder="${reminder}
140
145
  当前 worktrees(挑出刚合并的那个源 worktree 清理):
@@ -1,15 +1,36 @@
1
1
  #!/usr/bin/env bash
2
- # Deliver the canonical task entry before sampling, without inspecting the prompt.
2
+ # Deliver the canonical task entry before sampling. The prompt is never classified
3
+ # or echoed. One structural check spots a prompt that is exactly one
4
+ # <task-notification> envelope, the turn the host starts itself to deliver a
5
+ # background completion: that turn keeps the skill-loading and unfinished-work
6
+ # boundary but not the routing list, which SessionStart already keeps in context.
3
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
4
8
  if ! command -v python3 >/dev/null 2>&1; then
5
9
  printf 'ccl-skills task-entry: python3 unavailable; task entry omitted\n' >&2
6
10
  printf '{}\n'
7
11
  exit 0
8
12
  fi
9
- python3 - "$SCRIPT_DIR/../agent-context/session-start.md" <<'PY'
13
+ # fd 3 carries the hook input; stdin is the heredoc program. A closed stdin reads
14
+ # as empty input, which keeps the entry.
15
+ if ! { : 3<&0; } 2>/dev/null; then exec 0</dev/null; fi
16
+ python3 - "$SCRIPT_DIR/../agent-context/session-start.md" 3<&0 <<'PY'
10
17
  import json
18
+ import os
11
19
  import sys
12
20
 
21
+ host_turn = False
22
+ try:
23
+ with os.fdopen(3, 'rb') as hook_input:
24
+ raw = hook_input.read(1048577)
25
+ payload = json.loads(raw) if len(raw) <= 1048576 else None
26
+ prompt = payload.get('prompt') if isinstance(payload, dict) else None
27
+ envelope = prompt.strip() if isinstance(prompt, str) else ''
28
+ host_turn = (envelope.startswith('<task-notification>') and envelope.endswith('</task-notification>')
29
+ and envelope.count('<task-notification>') == 1 and envelope.count('</task-notification>') == 1)
30
+ except (OSError, ValueError, RecursionError):
31
+ # Unreadable input keeps the full entry: trimming is only for a proven host turn.
32
+ host_turn = False
33
+
13
34
  try:
14
35
  with open(sys.argv[1], 'rb') as stream:
15
36
  raw = stream.read(32769)
@@ -35,7 +56,7 @@ try:
35
56
  'Inspect failure evidence, research or change the approach, repair safely and rerun the relevant checks. '
36
57
  'A report alone does not complete it. Continue available authorized work; hand back only for a '
37
58
  'required user decision or unavailable authority/resource, stating the concrete blocker.\n\n')
38
- context = '<ccl-task-entry>\n' + boundary + entry + '\n</ccl-task-entry>'
59
+ context = '<ccl-task-entry>\n' + (boundary.rstrip('\n') if host_turn else boundary + entry) + '\n</ccl-task-entry>'
39
60
  if len(context.encode('utf-8')) > 4096:
40
61
  raise ValueError('oversized entry')
41
62
  print(json.dumps({'hookSpecificOutput': {
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # Deterministic behavior suite for hooks/headless-background-stop.sh.
3
+ # Registered in the Makefile `test` target; requires jq to build hook inputs.
4
+ set -u
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
7
+ HOOK="${HEADLESS_BG_HOOK:-$SCRIPT_DIR/headless-background-stop.sh}"
8
+ [ -f "$HOOK" ] || { echo "FAIL: hook not found: $HOOK" >&2; exit 1; }
9
+ command -v jq >/dev/null 2>&1 || { echo "FAIL: jq required for this suite" >&2; exit 1; }
10
+ bash -n "$HOOK" || { echo "FAIL: hook is not syntactically valid" >&2; exit 1; }
11
+
12
+ pass=0; fail=0
13
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/headless-bg-test.XXXXXX")"
14
+ trap 'rm -rf "$WORK"' EXIT
15
+ STATE="$WORK/state"; mkdir -p "$STATE"
16
+ ERR="$WORK/err"
17
+
18
+ # payload <session> <stop_hook_active> <tasks-json>
19
+ payload() {
20
+ jq -nc --arg s "$1" --argjson a "$2" --argjson t "$3" \
21
+ '{session_id:$s,hook_event_name:"Stop",stop_hook_active:$a,background_tasks:$t}'
22
+ }
23
+ task() { # task <id> <status> [description]
24
+ jq -nc --arg i "$1" --arg st "$2" --arg d "${3:-Run the review gate}" \
25
+ '{id:$i,type:"shell",status:$st,description:$d,command:"sleep 60"}'
26
+ }
27
+
28
+ # run <entrypoint> <tmpdir> <stdin> -> sets OUT; fails the case unless exit 0 with silent stderr
29
+ run() {
30
+ local rc errbytes
31
+ OUT=$(printf '%s' "$3" | CLAUDE_CODE_ENTRYPOINT="$1" TMPDIR="$2" bash "$HOOK" 2>"$ERR"); rc=$?
32
+ errbytes=$(wc -c <"$ERR" | tr -d ' ')
33
+ [ "$rc" = 0 ] && [ "$errbytes" = 0 ] && return 0
34
+ echo "FAIL: hook must exit 0 with silent stderr (rc=$rc, stderr=${errbytes}B)"; fail=$((fail + 1)); return 1
35
+ }
36
+
37
+ # expect <block|quiet|notice> <label> <entrypoint> <tmpdir> <stdin> [needle...]
38
+ expect() {
39
+ local want="$1" label="$2" got needle
40
+ run "$3" "$4" "$5" || return
41
+ if [ -z "$OUT" ]; then
42
+ got=quiet
43
+ elif printf '%s' "$OUT" | jq -e '.decision == "block" and (.reason | type == "string")' >/dev/null 2>&1; then
44
+ got=block
45
+ elif printf '%s' "$OUT" | jq -e '.systemMessage | startswith("Background task check unavailable")' >/dev/null 2>&1; then
46
+ got=notice
47
+ else
48
+ echo "FAIL [$label]: unexpected output: $OUT"; fail=$((fail + 1)); return
49
+ fi
50
+ if [ "$got" != "$want" ]; then
51
+ echo "FAIL [$label]: expected $want, got $got: $OUT"; fail=$((fail + 1)); return
52
+ fi
53
+ shift 5
54
+ for needle in "$@"; do
55
+ if ! printf '%s' "$OUT" | jq -e --arg n "$needle" '.reason | contains($n)' >/dev/null 2>&1; then
56
+ echo "FAIL [$label]: reason lacks '$needle': $OUT"; fail=$((fail + 1)); return
57
+ fi
58
+ done
59
+ pass=$((pass + 1))
60
+ }
61
+
62
+ one="[$(task t1 running 'Run the external review on the diff')]"
63
+ expect block "headless stop with a running task" sdk-cli "$STATE" "$(payload s1 false "$one")" \
64
+ "headless session" "Run the external review on the diff (t1)" "foreground" "Do not start it again" "TaskStop" "once per task"
65
+ if printf '%s' "$OUT" | jq -e '.reason | test("(?i)run(ning)? it again|rerun")' >/dev/null 2>&1; then
66
+ echo "FAIL [headless stop with a running task]: the reason advises starting the task again"; fail=$((fail + 1))
67
+ fi
68
+ expect quiet "the same task at the next stop" sdk-cli "$STATE" "$(payload s1 true "$one")"
69
+ expect quiet "the same task without the host retry flag" sdk-cli "$STATE" "$(payload s1 false "$one")"
70
+ both="[$(task t1 running),$(task t2 running 'Wait for the lane')]"
71
+ expect block "a new task in the same session" sdk-cli "$STATE" "$(payload s1 true "$both")" "Wait for the lane (t2)"
72
+ if printf '%s' "$OUT" | jq -e '.reason | contains("(t1)")' >/dev/null 2>&1; then
73
+ echo "FAIL [a new task in the same session]: an already reported task was listed again"; fail=$((fail + 1))
74
+ fi
75
+ expect block "another session with the same task id" sdk-cli "$STATE" "$(payload s2 false "$one")" "(t1)"
76
+ expect block "an SDK entrypoint" sdk-py "$STATE" "$(payload s3 false "$one")" "(t1)"
77
+
78
+ expect quiet "an interactive session" cli "$STATE" "$(payload s4 false "$one")"
79
+ expect quiet "no entrypoint" "" "$STATE" "$(payload s4 false "$one")"
80
+ expect quiet "a lookalike entrypoint" sdkx "$STATE" "$(payload s4 false "$one")"
81
+ done_task="[$(task t9 completed)]"
82
+ expect quiet "no task still running" sdk-cli "$STATE" "$(payload s5 false "$done_task")"
83
+ expect quiet "no background tasks field" sdk-cli "$STATE" '{"session_id":"s5","hook_event_name":"Stop","stop_hook_active":false}'
84
+ expect quiet "another hook event" sdk-cli "$STATE" "$(jq -nc --argjson t "$one" '{session_id:"s5",hook_event_name:"SubagentStop",stop_hook_active:false,background_tasks:$t}')"
85
+ expect notice "input that is not JSON" sdk-cli "$STATE" 'not json'
86
+
87
+ # Without usable state a repeat cannot be recognized, so the host retry flag bounds it.
88
+ expect block "no state, first stop" sdk-cli "$WORK/missing/dir" "$(payload s6 false "$one")" "(t1)"
89
+ expect quiet "no state, host retry" sdk-cli "$WORK/missing/dir" "$(payload s6 true "$one")"
90
+
91
+ # The helper repeats the entrypoint check, so it holds even when called without the wrapper.
92
+ HELPER="$(dirname "$HOOK")/host-input.py"
93
+ helper_quiet() { # helper_quiet <label> <entrypoint>
94
+ local out
95
+ out=$(printf '%s' "$(payload s9 false "$one")" | CLAUDE_CODE_ENTRYPOINT="$2" TMPDIR="$STATE" python3 "$HELPER" headless-background 2>"$ERR")
96
+ if [ -z "$out" ] && [ ! -s "$ERR" ]; then pass=$((pass + 1)); else echo "FAIL [$1]: helper spoke: $out"; fail=$((fail + 1)); fi
97
+ }
98
+ helper_quiet "the helper alone in an interactive session" cli
99
+ helper_quiet "the helper alone with a lookalike entrypoint" sdkx
100
+ out=$(printf 'not json' | CLAUDE_CODE_ENTRYPOINT=cli TMPDIR="$STATE" python3 "$HELPER" headless-background 2>"$ERR")
101
+ if [ -z "$out" ] && [ ! -s "$ERR" ]; then pass=$((pass + 1)); else echo "FAIL [the helper alone, interactive, bad input]: $out"; fail=$((fail + 1)); fi
102
+
103
+ # The wrapper leaves interactive sessions before the helper: no Python start, no notice about it.
104
+ BASH_BIN="$(command -v bash)"; mkdir -p "$WORK/no-python"; ln -s "$(command -v dirname)" "$WORK/no-python/dirname"
105
+ no_python() { # no_python <label> <entrypoint> <expected output or empty>
106
+ local out
107
+ out=$(printf '%s' "$(payload s10 false "$one")" | CLAUDE_CODE_ENTRYPOINT="$2" PATH="$WORK/no-python" "$BASH_BIN" "$HOOK" 2>"$ERR")
108
+ if [ "$out" = "$3" ] && [ ! -s "$ERR" ]; then pass=$((pass + 1)); else echo "FAIL [$1]: got '$out'"; fail=$((fail + 1)); fi
109
+ }
110
+ no_python "an interactive session without Python" cli ""
111
+ no_python "a headless session without Python" sdk-cli \
112
+ '{"systemMessage":"Background task check unavailable: Python input normalizer missing."}'
113
+
114
+ many="[$(for i in 1 2 3 4 5 6 7; do task "m$i" running; printf ','; done | sed 's/,$//')]"
115
+ expect block "a long task list" sdk-cli "$STATE" "$(payload s7 false "$many")" "(m5)" "and 2 more"
116
+ if printf '%s' "$OUT" | jq -e '.reason | test("\\(m[67]\\)")' >/dev/null 2>&1; then
117
+ echo "FAIL [a long task list]: a task beyond the first five was listed"; fail=$((fail + 1))
118
+ fi
119
+ hundred=$(printf 'x%.0s' $(seq 100))
120
+ long="[$(task c1 running "${hundred}yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy")]"
121
+ expect block "a long description" sdk-cli "$STATE" "$(payload s11 false "$long")" "${hundred} (c1)"
122
+
123
+ # Identifiers from the input never name a path: hostile ones stay inside the state root.
124
+ hostile="[$(task '../../../evil-task' running)]"
125
+ expect block "hostile identifiers, first stop" sdk-cli "$STATE" "$(payload '../../escape' false "$hostile")" "(../../../evil-task)"
126
+ expect quiet "hostile identifiers, next stop" sdk-cli "$STATE" "$(payload '../../escape' false "$hostile")"
127
+ if find "$WORK" -name '*evil*' -o -name '*escape*' | grep -q .; then
128
+ echo "FAIL [hostile identifiers]: an input identifier became a path"; fail=$((fail + 1))
129
+ else pass=$((pass + 1)); fi
130
+
131
+ # Two stops racing for the same new task: the claim is atomic, so exactly one blocks.
132
+ race="$(payload race false "[$(task r1 running)]")"
133
+ for i in 1 2 3 4; do
134
+ (printf '%s' "$race" | CLAUDE_CODE_ENTRYPOINT=sdk-cli TMPDIR="$STATE" bash "$HOOK" > "$WORK/race.$i" 2>/dev/null) &
135
+ done
136
+ wait
137
+ blocks=$(cat "$WORK"/race.* | grep -c '"decision": "block"')
138
+ if [ "$blocks" = 1 ]; then pass=$((pass + 1)); else echo "FAIL [racing stops]: $blocks blocks for one task"; fail=$((fail + 1)); fi
139
+
140
+ # A state root that is a symlink is refused, so the guard falls back to the host retry flag.
141
+ mkdir -p "$WORK/sym" "$WORK/elsewhere"; ln -s "$WORK/elsewhere" "$WORK/sym/ccl-skill-loading-$(id -u)"
142
+ expect block "symlinked state root, first stop" sdk-cli "$WORK/sym" "$(payload s12 false "$one")" "(t1)"
143
+ expect quiet "symlinked state root, host retry" sdk-cli "$WORK/sym" "$(payload s12 true "$one")"
144
+ if [ -z "$(ls -A "$WORK/elsewhere")" ]; then pass=$((pass + 1)); else echo "FAIL [symlinked state root]: state written through the link"; fail=$((fail + 1)); fi
145
+ multiline="[$(task n1 running "$(printf 'line one\nline two')")]"
146
+ expect block "a description with a line break" sdk-cli "$STATE" "$(payload s8 false "$multiline")" "line one line two (n1)"
147
+
148
+ echo "pass=$pass fail=$fail"
149
+ [ "$fail" = 0 ] || exit 1
150
+ echo "test_headless_background_stop_ok"
@@ -541,6 +541,77 @@ class ProposedNextTests(unittest.TestCase):
541
541
  (self.hooks / 'host-input.py').unlink()
542
542
  self.assertIn('unavailable', self.run_hook().get('systemMessage', ''))
543
543
 
544
+ def usage_event(self, context, cache_read=None, model='claude-opus-5-5'):
545
+ read = context - 2000 if cache_read is None else cache_read
546
+ return {'type': 'assistant', 'message': {'model': model, 'content': [{'type': 'text', 'text': 'ok'}],
547
+ 'usage': {'input_tokens': 2, 'cache_read_input_tokens': read,
548
+ 'cache_creation_input_tokens': context - 2 - read, 'output_tokens': 50}}}
549
+
550
+ def run_with_state(self, payload=None):
551
+ # The once-per-band cap uses the optional state helper; give it a private TMPDIR.
552
+ shutil.copyfile(ROOT / 'hooks/skill-loading.py', self.hooks / 'skill-loading.py')
553
+ state = self.root.parent / (self.root.name + '-state')
554
+ state.mkdir(exist_ok=True)
555
+ value = self.payload if payload is None else payload
556
+ result = subprocess.run(['bash', str(self.hooks / 'proposed-next-stop.sh')],
557
+ input=json.dumps(value), text=True, capture_output=True, cwd=self.root,
558
+ env=dict(os.environ, TMPDIR=str(state)))
559
+ self.assertEqual(result.returncode, 0, result.stderr)
560
+ self.assertEqual(result.stderr, '')
561
+ return json.loads(result.stdout) if result.stdout else {}
562
+
563
+ def test_large_context_notice_is_user_only_and_once_per_band(self):
564
+ self.events([self.usage_event(120000), self.usage_event(299999)])
565
+ self.assertEqual(self.run_with_state(), {})
566
+ self.events([self.usage_event(120000), self.usage_event(321000)])
567
+ first = self.run_with_state()
568
+ self.assertEqual(set(first), {'systemMessage'}) # shown to the user, never a block or model context
569
+ self.assertIn('32 万', first['systemMessage'])
570
+ self.assertIn('/autocompact', first['systemMessage'])
571
+ self.assertIn('/clear', first['systemMessage'])
572
+ self.assertEqual(self.run_with_state(), {}) # same band: no repeat
573
+ self.events([self.usage_event(321000), self.usage_event(612000)])
574
+ second = self.run_with_state()
575
+ self.assertIn('61 万', second.get('systemMessage', ''))
576
+ self.assertEqual(self.run_with_state(), {})
577
+
578
+ def test_large_context_notice_stays_quiet_without_its_state_helper(self):
579
+ # Without the once-per-band record the notice would repeat on every stop, so
580
+ # it is withheld; notices that must show (handoff overflow) still show.
581
+ self.events([self.usage_event(450000)])
582
+ self.assertFalse((self.hooks / 'skill-loading.py').exists())
583
+ self.assertEqual(self.run_hook(), {})
584
+ self.assertEqual(self.run_hook(), {})
585
+
586
+ def test_large_context_notice_rides_with_a_continuation_reminder(self):
587
+ self.events([self.usage_event(450000)])
588
+ payload = dict(self.payload, last_assistant_message='Fixed.\n\nproposed-next: run the integration suite')
589
+ value = self.run_with_state(payload)
590
+ self.assertEqual(value.get('decision'), 'block')
591
+ self.assertIn('proposed-next:', value['reason'])
592
+ self.assertIn('45 万', value.get('systemMessage', ''))
593
+ self.assertNotIn('万 token', value['reason']) # the model-facing reason stays unchanged
594
+
595
+ def test_large_context_notice_reads_only_the_latest_real_claude_usage(self):
596
+ # A trailing zero-usage (synthetic) entry is skipped; Codex shapes and huge
597
+ # trailing tool output without usage stay quiet; the notice never needs the
598
+ # bounded full-transcript scan.
599
+ synthetic = self.usage_event(0, cache_read=0, model='<synthetic>')
600
+ synthetic['message']['usage'] = {'input_tokens': 0, 'cache_read_input_tokens': 0,
601
+ 'cache_creation_input_tokens': 0, 'output_tokens': 0}
602
+ self.events([self.usage_event(330000), synthetic])
603
+ self.assertIn('33 万', self.run_with_state().get('systemMessage', ''))
604
+ self.setUp()
605
+ self.events([{'type': 'event_msg', 'payload': {'type': 'token_count', 'info': {
606
+ 'last_token_usage': {'input_tokens': 900000}}}}])
607
+ self.assertEqual(self.run_with_state(), {})
608
+ self.setUp()
609
+ self.events([self.usage_event(500000)] + [{'type': 'user', 'text': 'x' * 400000}] * 3)
610
+ self.assertEqual(self.run_with_state(), {})
611
+ self.setUp()
612
+ self.events([{'type': 'ignored'}] * 20000 + [self.usage_event(700000)])
613
+ self.assertIn('70 万', self.run_with_state().get('systemMessage', ''))
614
+
544
615
  def test_scan_is_bounded_and_no_filesystem_markers_are_written(self):
545
616
  self.events([{'type': 'ignored'}] * 20000 + self.claude_load())
546
617
  before = set(self.root.rglob('*'))
@@ -128,6 +128,53 @@ probe remind 'glab mr merge 123 --yes; glab help mr merge' 'Merged !123'
128
128
  # a successful-looking string response still reminds
129
129
  probe remind 'glab mr merge 123 --yes' 'Merged! https://.../merge_requests/123'
130
130
 
131
+ # --- Reminder TEXT contract: the injected text is what the agent acts on right
132
+ # after the merge, so it must route to the canonical teardown section and
133
+ # carry the guards a lossy digest once dropped — the ignored-artifact scan
134
+ # that must exit 0 before any worktree removal, and the release-name branch
135
+ # exception — instead of claiming a single exception. ---
136
+ reminder_text=$(jq -nc --arg c 'gh pr merge 45 --merge' --arg r 'Merged' \
137
+ '{tool_input:{command:$c},tool_response:$r,cwd:"/tmp"}' | bash "$HOOK" \
138
+ | jq -r '.hookSpecificOutput.additionalContext // empty')
139
+ text_has() { # <label> <needle>
140
+ if printf '%s' "$reminder_text" | grep -Fq -- "$2"; then pass=$((pass+1))
141
+ else fail=$((fail+1)); printf 'FAIL [reminder text lacks %s] %s\n' "$1" "$2" >&2; fi
142
+ }
143
+ text_lacks() { # <label> <needle>
144
+ if printf '%s' "$reminder_text" | grep -Fq -- "$2"; then
145
+ fail=$((fail+1)); printf 'FAIL [reminder text still carries %s] %s\n' "$1" "$2" >&2
146
+ else pass=$((pass+1)); fi
147
+ }
148
+ if [ -n "$reminder_text" ]; then pass=$((pass+1))
149
+ else fail=$((fail+1)); echo 'FAIL [no reminder text extracted]' >&2; fi
150
+ TEARDOWN_REF='worktree-isolation/references/merge-and-teardown.md'
151
+ text_has 'canonical teardown pointer' "$TEARDOWN_REF"
152
+ text_has 'ignored-artifact scan before removal' 'status --ignored -s'
153
+ text_has 'scan exit-0 requirement' '必须 exit 0'
154
+ text_lacks 'single-exception claim' '唯一例外'
155
+ # A guard is its meaning, not one keyword: every needle must sit on the same
156
+ # line, so dropping the "keep" sense while a keyword survives still fails.
157
+ line_has_all() { # <label> <needle>...
158
+ local label="$1"; shift
159
+ if printf '%s\n' "$reminder_text" | awk -v n="$#" 'BEGIN{for(i=1;i<=n;i++) want[i]=ARGV[i]; ARGC=1}
160
+ {hit=1; for(i=1;i<=n;i++) if (index($0, want[i])==0) hit=0; if (hit) found=1}
161
+ END{exit found?0:1}' "$@"; then pass=$((pass+1))
162
+ else fail=$((fail+1)); printf 'FAIL [reminder text lacks %s on one line]\n' "$label" >&2; fi
163
+ }
164
+ line_has_all 'release-name branch kept' '不删的例外' '分支名含 release' '要删由用户显式指名'
165
+ line_has_all 'permanent/integration branch kept' '不删的例外' '源分支本身是永久/集成分支'
166
+ line_has_all 'external-side-effect wait' '未完成的外部副作用任务' '等它完成再清'
167
+ # The scan guards the removal, so it must come first — an order the substring
168
+ # checks above cannot see.
169
+ scan_at=$(printf '%s\n' "$reminder_text" | grep -n -F 'status --ignored -s' | head -1 | cut -d: -f1)
170
+ remove_at=$(printf '%s\n' "$reminder_text" | grep -n -F 'git worktree remove' | head -1 | cut -d: -f1)
171
+ if [ -n "$scan_at" ] && [ -n "$remove_at" ] && [ "$scan_at" -lt "$remove_at" ]; then pass=$((pass+1))
172
+ else fail=$((fail+1)); echo "FAIL [reminder text lacks scan ordered before removal] scan=${scan_at:-none} remove=${remove_at:-none}" >&2; fi
173
+ # The pointer must resolve: the canonical file exists in this tree and still
174
+ # carries the teardown section heading the pointer names.
175
+ if grep -Fq '## 收尾:' "$SCRIPT_DIR/../skills/$TEARDOWN_REF" 2>/dev/null; then pass=$((pass+1))
176
+ else fail=$((fail+1)); echo "FAIL [teardown pointer dangles] skills/$TEARDOWN_REF lacks '## 收尾:'" >&2; fi
177
+
131
178
  printf 'remind-post-merge-cleanup tests: pass=%d fail=%d\n' "$pass" "$fail"
132
179
  [ "$fail" -eq 0 ] || exit 1
133
180
  echo "test_remind_post_merge_cleanup_ok"