@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.
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/headless-background-stop.sh +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +155 -8
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-post-merge-cleanup.sh +9 -4
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/task-entry.sh +24 -3
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_headless_background_stop.sh +150 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +71 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_post_merge_cleanup.sh +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_task_entry.py +63 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +7 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/development-completion.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +77 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +112 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/references/multi-agent-delegation-playbook.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/worktree-mechanics.md +10 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +6 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-lifecycle-handoff.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +15 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-sync-pointers.sh +11 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/skill-paired-eval.py +1546 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ai_coding_implementation_gates.sh +140 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_sync_pointers.sh +13 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_controlled_escalation_pins.sh +12 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_skill_paired_eval.py +1052 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_teardown_guard_pins.sh +354 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +8 -108
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/merge-and-teardown.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/pre-merge-landing-checks.md +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/shared-branch-rebase.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/test_worktree_sweep.sh +1 -1
- package/dist/assets/release.json +65 -30
- 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
|
|
583
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
74
|
-
# bootstrap covers EVERY merge path
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|