@iceinvein/agent-skills 0.22.0 → 0.23.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 (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/bug-hunter/SKILL.md +5 -0
  4. package/skills/bug-hunter/evals/README.md +6 -1
  5. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
  6. package/skills/bug-hunter/evals/rules.json +8 -0
  7. package/skills/bug-hunter/skill.json +1 -1
  8. package/skills/index.json +4 -4
  9. package/skills/magpie/README.md +5 -3
  10. package/skills/magpie/SKILL.md +40 -15
  11. package/skills/magpie/bin/magpie.ts +83 -7
  12. package/skills/magpie/evals/README.md +7 -4
  13. package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
  14. package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
  15. package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
  16. package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
  17. package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
  18. package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
  19. package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
  20. package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
  21. package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
  22. package/skills/magpie/evals/quality/README.md +73 -2
  23. package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
  24. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
  25. package/skills/magpie/evals/quality/corpus.ts +4 -1
  26. package/skills/magpie/evals/quality/recall.ts +249 -0
  27. package/skills/magpie/evals/quality/replay-critic.ts +3 -0
  28. package/skills/magpie/evals/quality/replay-full.ts +168 -6
  29. package/skills/magpie/evals/rules.json +9 -1
  30. package/skills/magpie/references/checks.md +78 -0
  31. package/skills/magpie/references/peer-review.md +7 -0
  32. package/skills/magpie/references/scout.md +12 -5
  33. package/skills/magpie/references/specialists.md +10 -8
  34. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
  35. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
  36. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
  37. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
  38. package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
  39. package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
  40. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
  41. package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
  42. package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
  43. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
  44. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
  45. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
  46. package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
  47. package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
  48. package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
  49. package/skills/magpie/scripts/critic-cmd.ts +2 -1
  50. package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
  51. package/skills/magpie/scripts/handoff-cmd.ts +132 -0
  52. package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
  53. package/skills/magpie/scripts/local-diff.ts +228 -0
  54. package/skills/magpie/scripts/post-cmd.ts +1 -0
  55. package/skills/magpie/scripts/render-annotation.ts +1 -0
  56. package/skills/magpie/scripts/resolve-run.ts +17 -3
  57. package/skills/magpie/scripts/server.ts +9 -1
  58. package/skills/magpie/scripts/setup-cmd.ts +241 -49
  59. package/skills/magpie/scripts/status-cmd.ts +22 -6
  60. package/skills/magpie/scripts/target.ts +56 -0
  61. package/skills/magpie/scripts/types.ts +1 -0
  62. package/skills/magpie/skill.json +2 -2
  63. package/skills/sluice/SKILL.md +3 -1
  64. package/skills/sluice/evals/README.md +10 -2
  65. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
  66. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
  67. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
  68. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
  69. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
  70. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
  71. package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
  72. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
  73. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
  74. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
  75. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
  76. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
  77. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
  78. package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
  79. package/skills/sluice/evals/rules.json +11 -0
  80. package/skills/sluice/references/review.md +6 -4
  81. package/skills/sluice/references/test-first.md +24 -4
  82. package/skills/sluice/scripts/content-digest.sh +1 -1
  83. package/skills/sluice/scripts/run-stats.sh +14 -0
  84. package/skills/sluice/scripts/session-start.sh +44 -2
  85. package/skills/sluice/scripts/status.sh +293 -0
  86. package/skills/sluice/scripts/stop-guard.sh +80 -6
  87. package/skills/sluice/scripts/test-changes.sh +135 -0
  88. package/skills/sluice/skill.json +1 -1
@@ -23,6 +23,7 @@
23
23
  # status.sh close
24
24
  # status.sh route <fast|main|deep>
25
25
  # status.sh verify -- <cmd> [args...] | verify --none <reason>
26
+ # status.sh tests-changed --because <t> [--base <ref>] [--only <path>]... {[--with <path>]... -- <cmd> | --none <reason>}
26
27
  #
27
28
  # --dir <path> selects the tree to read (default: $PWD). State lives at
28
29
  # <dir>/.sluice/run.json and closed runs at <dir>/.sluice/archive/. A tree the
@@ -37,6 +38,10 @@
37
38
  # `line` is exempt and always exits 0 in silence, because a statusline renders
38
39
  # on every keystroke and has nowhere to put an error. `verify -- <cmd>` exits
39
40
  # with the command's own code, so a failing check reads as that failure.
41
+ # `tests-changed` adds 7, a flagged test passes on the old code, and 8, the
42
+ # command failed on the old code with the flagged tests at their old version;
43
+ # neither records anything. Its exit 1 also covers a tree whose changed tests
44
+ # cannot be read.
40
45
  #
41
46
  # A flag value may not begin with `--`: unchecked, an omitted value silently
42
47
  # becomes the next flag and the field holds a flag name.
@@ -406,6 +411,294 @@ if [ "$SUB" = "verify" ]; then
406
411
  exit 0
407
412
  fi
408
413
 
414
+ # Records why the session's changed tests are allowed to ask what they now ask,
415
+ # for the test-change gate. A changed test is proved when it fails on the code
416
+ # it was changed against: one that still passes there does not pin the change.
417
+ # The runs happen in a scratch worktree at the base, so the partner's tree is
418
+ # never touched. Like verify it needs no run and reads ORIG_DIR.
419
+ if [ "$SUB" = "tests-changed" ]; then
420
+ TREE="$(git -C "$ORIG_DIR" rev-parse --show-toplevel 2>/dev/null)"
421
+ [ -n "$TREE" ] || { err "tests-changed needs a git work tree, and $ORIG_DIR is not in one"; exit 4; }
422
+ BECAUSE=""
423
+ BASE=""
424
+ REASON=""
425
+ NONE=0
426
+ RUN=0
427
+ WITH=()
428
+ ONLY=""
429
+ while [ $# -gt 0 ]; do
430
+ case "$1" in
431
+ --only) need_value --only $# "${2-}"; ONLY="$ONLY$2"$'\n'; shift 2 ;;
432
+ --because) need_value --because $# "${2-}"; BECAUSE="$2"; shift 2 ;;
433
+ --base) need_value --base $# "${2-}"; BASE="$2"; shift 2 ;;
434
+ --with) need_value --with $# "${2-}"; WITH+=("$2"); shift 2 ;;
435
+ --none) need_value --none $# "${2-}"; REASON="$2"; NONE=1; shift 2 ;;
436
+ --) shift; RUN=1; break ;;
437
+ *) err "tests-changed: unknown argument $1"; exit 4 ;;
438
+ esac
439
+ done
440
+ [ -n "$BECAUSE" ] || { err "tests-changed needs --because <text>"; exit 4; }
441
+ if [ "$RUN" -eq "$NONE" ]; then
442
+ err "tests-changed needs one of -- <cmd> [args...] or --none <reason>"
443
+ exit 4
444
+ fi
445
+ [ "$RUN" -eq 0 ] || [ $# -gt 0 ] || { err "tests-changed -- needs a command to run"; exit 4; }
446
+ [ "$NONE" -eq 0 ] || [ -n "$REASON" ] || { err "tests-changed --none needs a reason"; exit 4; }
447
+ [ "$NONE" -eq 0 ] || [ "${#WITH[@]}" -eq 0 ] || { err "--with only applies to a run, not to --none"; exit 4; }
448
+ for p in "${WITH[@]+"${WITH[@]}"}"; do
449
+ # The link has to land inside the scratch worktree and point inside the tree.
450
+ case "/$p/" in
451
+ //* | */../*) err "--with takes a path inside the tree, and $p is not one"; exit 4 ;;
452
+ esac
453
+ [ -e "$TREE/$p" ] || { err "--with $p: no such path in $TREE"; exit 4; }
454
+ done
455
+ # A command that names the tree or the main checkout by absolute path runs
456
+ # their files, not the scratch worktree's, so both runs would run the new
457
+ # code. Compared after resolving symlinks, since git reports physical paths
458
+ # (/private/var on macOS) and a caller usually passes logical ones.
459
+ if [ "$RUN" -eq 1 ]; then
460
+ ROOTS="$(cd "$TREE" && pwd -P)"
461
+ # The first entry of the list is the main checkout, unless the repo is
462
+ # bare: then it is the git dir, and its parent holds no checkout at all.
463
+ FIRST="$(git -C "$TREE" worktree list --porcelain 2>/dev/null | sed '/^$/q')"
464
+ [ -n "$FIRST" ] || { err "could not list the worktrees of $TREE"; exit 1; }
465
+ case $'\n'"$FIRST"$'\n' in
466
+ *$'\n'bare$'\n'*) ;;
467
+ *)
468
+ MAIN_ROOT="$(printf '%s\n' "$FIRST" | sed -n '1s/^worktree //p')"
469
+ [ -d "$MAIN_ROOT" ] && ROOTS="$ROOTS
470
+ $(cd "$MAIN_ROOT" && pwd -P)"
471
+ ;;
472
+ esac
473
+ for a in "$@"; do
474
+ case "$a" in
475
+ /*) ;;
476
+ *) continue ;;
477
+ esac
478
+ d="$a"
479
+ rest=""
480
+ while [ ! -d "$d" ]; do
481
+ rest="/$(basename "$d")$rest"
482
+ d="$(dirname "$d")"
483
+ done
484
+ resolved="$(cd "$d" && pwd -P)$rest"
485
+ while IFS= read -r root; do
486
+ case "$resolved/" in
487
+ "$root"/*) err "the command names $a, inside $root, so both runs would run the new code; name it relative to the top of the tree"; exit 4 ;;
488
+ esac
489
+ done <<<"$ROOTS"
490
+ done
491
+ fi
492
+
493
+ # An explicit base wins; otherwise the session's own, from line 3 of its
494
+ # stamp. A missing line or one gc has pruned is unknown, and guessing a base
495
+ # would prove the tests against code they were never changed from.
496
+ if [ -z "$BASE" ]; then
497
+ SID="${CLAUDE_CODE_SESSION_ID-}"
498
+ [ -n "$SID" ] || { err "tests-changed needs --base <ref> when CLAUDE_CODE_SESSION_ID is not set"; exit 4; }
499
+ case "$SID" in
500
+ */* | .*) err "session id $SID is not a stamp name; pass --base <ref>"; exit 4 ;;
501
+ esac
502
+ BASE="$(sed -n 3p "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sluice/stamps/$SID" 2>/dev/null)"
503
+ [ -n "$BASE" ] || { err "the session stamp holds no base commit, so the base is unknown; pass --base <ref>"; exit 4; }
504
+ fi
505
+ BASE_SHA="$(git -C "$TREE" rev-parse --verify --quiet "$BASE^{commit}" 2>/dev/null)"
506
+ [ -n "$BASE_SHA" ] || { err "base $BASE no longer resolves to a commit here, so the base is unknown; pass --base <ref>"; exit 4; }
507
+
508
+ TEST_CHANGES="$(dirname "$0")/test-changes.sh"
509
+ FLAGGED="$(bash "$TEST_CHANGES" "$TREE" "$BASE_SHA")" || {
510
+ err "could not read the changed tests in $TREE against $BASE_SHA, so nothing was recorded"
511
+ exit 1
512
+ }
513
+ # --only narrows what this call proves or records to the named paths. A
514
+ # flagged path left out is then just another changed file: present at its
515
+ # current version in every run, so a test reading an edited fixture sees it,
516
+ # and a fixture that passes on the base cannot sink the test beside it.
517
+ if [ -n "$ONLY" ]; then
518
+ while IFS= read -r p; do
519
+ [ -n "$p" ] || continue
520
+ case $'\n'"$FLAGGED"$'\n' in
521
+ *$'\n'"$p"$'\n'*) ;;
522
+ *) err "--only $p: not a flagged test path, so there is nothing to record for it"; exit 4 ;;
523
+ esac
524
+ done <<<"$ONLY"
525
+ NAMED=""
526
+ while IFS= read -r p; do
527
+ case $'\n'"$ONLY" in
528
+ *$'\n'"$p"$'\n'*) NAMED="$NAMED$p"$'\n' ;;
529
+ esac
530
+ done <<<"$FLAGGED"
531
+ FLAGGED="${NAMED%$'\n'}"
532
+ fi
533
+ if [ -z "$FLAGGED" ]; then
534
+ echo "sluice: no changed test needs a record"
535
+ exit 0
536
+ fi
537
+
538
+ mk_dir "$TREE/.sluice" "$TREE"
539
+ # As in verify: a record git can see moves the tree it records.
540
+ git -C "$TREE" check-ignore -q .sluice/tests-changed || {
541
+ err "$TREE/.sluice/.gitignore does not ignore tests-changed, so the record would move the tree it records; add * to it"
542
+ exit 1
543
+ }
544
+
545
+ # Hashed path-aware, so filters apply, the way the stop gate hashes it. A
546
+ # directory where the file was (a fixture split into many) is that file's
547
+ # deletion, here, in proof mode and in the gate alike: it cannot be hashed,
548
+ # and read as unreadable it could never be recorded.
549
+ current_blob() { # <path>: its blob, or - when it is gone
550
+ local sha
551
+ if [ -f "$TREE/$1" ]; then
552
+ sha="$(git -C "$TREE" hash-object -- "$1" 2>/dev/null)"
553
+ [ -n "$sha" ] || { err "could not read $TREE/$1, so nothing was recorded"; exit 1; }
554
+ printf '%s\n' "$sha"
555
+ else
556
+ printf '%s\n' "-"
557
+ fi
558
+ }
559
+
560
+ STATUS=proved
561
+ WHY="$BECAUSE"
562
+ SKIPPED=""
563
+ if [ "$NONE" -eq 1 ]; then
564
+ STATUS=unproven
565
+ WHY="$BECAUSE (unproven: $REASON)"
566
+ # A path already recorded at its current content needs nothing more, so
567
+ # --none after a proof adds only the deletions, and a repeat adds nothing.
568
+ UNRECORDED=""
569
+ while IFS= read -r p; do
570
+ sha="$(current_blob "$p")" || exit 1
571
+ found=""
572
+ if [ -f "$TREE/.sluice/tests-changed" ]; then
573
+ while IFS=$'\t' read -r rec_sha rec_path rest; do
574
+ if [ "$rec_sha" = "$sha" ] && [ "$rec_path" = "$p" ]; then
575
+ found=1
576
+ break
577
+ fi
578
+ done <"$TREE/.sluice/tests-changed"
579
+ fi
580
+ [ -n "$found" ] || UNRECORDED="$UNRECORDED$p"$'\n'
581
+ done <<<"$FLAGGED"
582
+ FLAGGED="${UNRECORDED%$'\n'}"
583
+ if [ -z "$FLAGGED" ]; then
584
+ echo "sluice: every flagged test already has a record at its current content"
585
+ exit 0
586
+ fi
587
+ else
588
+ # Removing a test can only make a suite pass, and a command naming the
589
+ # missing file fails on "not found", so no run can prove a deletion. The
590
+ # deletions are left for --none and the edits beside them proved as usual.
591
+ REMAINING=""
592
+ while IFS= read -r p; do
593
+ if [ -f "$TREE/$p" ]; then
594
+ REMAINING="$REMAINING$p"$'\n'
595
+ else
596
+ SKIPPED="$SKIPPED${SKIPPED:+, }$p"
597
+ fi
598
+ done <<<"$FLAGGED"
599
+ FLAGGED="${REMAINING%$'\n'}"
600
+ if [ -z "$FLAGGED" ]; then
601
+ err "every flagged test was deleted ($SKIPPED), and no run can prove a deletion; record them with --none <reason>. Nothing was recorded"
602
+ exit 4
603
+ fi
604
+ LISTED="$(bash "$TEST_CHANGES" --list "$TREE" "$BASE_SHA")" || {
605
+ err "could not list the changed tests in $TREE against $BASE_SHA, so nothing was recorded"
606
+ exit 1
607
+ }
608
+ # Outside the repo, so nothing the command resolves by walking up from the
609
+ # worktree (node_modules, a config file) can come from the tree or the
610
+ # main checkout instead of from the base.
611
+ WT="$(mktemp -d "${TMPDIR:-/tmp}/sluice-tc.XXXXXX" 2>/dev/null)" || { err "could not make a scratch directory in ${TMPDIR:-/tmp}"; exit 1; }
612
+ trap 'git -C "$TREE" worktree remove --force "$WT" >/dev/null 2>&1; rm -rf "$WT"; git -C "$TREE" worktree prune >/dev/null 2>&1' EXIT
613
+ # Bash skips the EXIT trap on a signal it has no trap for.
614
+ trap 'exit 130' INT
615
+ trap 'exit 143' TERM
616
+ ADD_ERR="$(git -C "$TREE" worktree add --detach "$WT" "$BASE_SHA" 2>&1 >/dev/null)" || {
617
+ err "could not check out $BASE_SHA to run on: $ADD_ERR"
618
+ exit 1
619
+ }
620
+ # Ignored dependencies never reach a fresh checkout.
621
+ for p in "${WITH[@]+"${WITH[@]}"}"; do
622
+ [ ! -e "$WT/$p" ] && [ ! -L "$WT/$p" ] || { err "--with $p is tracked at the base, so there is nothing to link"; exit 4; }
623
+ mkdir -p "$(dirname "$WT/$p")" && ln -s "$TREE/$p" "$WT/$p" || { err "could not link $p into the scratch worktree"; exit 1; }
624
+ done
625
+
626
+ # A directory that replaced a file only removes the file here: its
627
+ # contents are listed paths of their own, sorted after it.
628
+ overlay() { # <path>: puts the tree's current version of it over the base
629
+ if [ -f "$TREE/$1" ] || [ -L "$TREE/$1" ]; then
630
+ mkdir -p "$(dirname "$WT/$1")" && cp -P "$TREE/$1" "$WT/$1" || { err "could not copy $1 onto the base"; exit 1; }
631
+ else
632
+ rm -f "$WT/$1" || { err "could not delete $1 from the base"; exit 1; }
633
+ fi
634
+ }
635
+
636
+ # The control run is the base with every changed test but the flagged ones
637
+ # brought forward, and each proof run differs from it by one flagged path,
638
+ # so a proof run's failure is that path's. Bringing them all forward at
639
+ # once let any new test that fails on old code prove every flagged path.
640
+ # The unit is the file: one file that loosens an assertion and adds a new
641
+ # failing test is still proved, and so is a flagged test that newly imports
642
+ # source the base lacks, since it fails on the base for that alone.
643
+ #
644
+ # Rebuilt from the base before every run, because a run writes into the
645
+ # worktree (fixtures, databases, caches) and what one leaves would decide
646
+ # the next. -x takes ignored residue too; the --with links are kept.
647
+ control_tree() {
648
+ local excludes=() w listed
649
+ for w in "${WITH[@]+"${WITH[@]}"}"; do
650
+ excludes+=(-e "/$w")
651
+ done
652
+ git -C "$WT" checkout -q HEAD -- . && git -C "$WT" clean -fdxq "${excludes[@]+"${excludes[@]}"}" || {
653
+ err "could not reset the scratch worktree to $BASE_SHA"
654
+ exit 1
655
+ }
656
+ while IFS= read -r listed; do
657
+ [ -n "$listed" ] || continue
658
+ case $'\n'"$FLAGGED"$'\n' in
659
+ *$'\n'"$listed"$'\n'*) continue ;;
660
+ esac
661
+ overlay "$listed"
662
+ done <<<"$LISTED"
663
+ }
664
+
665
+ control_tree
666
+
667
+ # Without this a suite that cannot run on the base at all (a missing
668
+ # dependency, a new test for code the base lacks, a test already red there)
669
+ # would fail every proof run and read as proved. Every run's stdin is
670
+ # /dev/null: the proof loop's list arrives on stdin, so a command that
671
+ # reads it would swallow the paths still to be proved, and a runner
672
+ # waiting on input would hang the call.
673
+ if ! (cd "$WT" && "$@") </dev/null; then
674
+ err "the command failed on $BASE_SHA with the flagged tests left as they were, so it cannot prove anything here; link an ignored dependency with --with <path>, narrow the command to the flagged files, or record with --none <reason>. Nothing was recorded"
675
+ exit 8
676
+ fi
677
+
678
+ while IFS= read -r p; do
679
+ control_tree
680
+ overlay "$p"
681
+ if (cd "$WT" && "$@") </dev/null; then
682
+ err "$p passes on $BASE_SHA, the code it was changed against, so it does not pin the change. Nothing was recorded"
683
+ exit 7
684
+ fi
685
+ done <<<"$FLAGGED"
686
+ fi
687
+
688
+ LINES=""
689
+ COUNT=0
690
+ while IFS= read -r p; do
691
+ SHA="$(current_blob "$p")" || exit 1
692
+ LINES="$LINES$SHA $p $STATUS $WHY
693
+ "
694
+ COUNT=$((COUNT + 1))
695
+ done <<<"$FLAGGED"
696
+ printf '%s' "$LINES" >>"$TREE/.sluice/tests-changed" || { err "could not write $TREE/.sluice/tests-changed"; exit 1; }
697
+ echo "sluice: recorded $COUNT changed test(s) as $STATUS"
698
+ [ -z "$SKIPPED" ] || echo "sluice: not recorded, since no run can prove a deletion: $SKIPPED; record it with --none <reason>"
699
+ exit 0
700
+ fi
701
+
409
702
  command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
410
703
 
411
704
  require_run() {
@@ -16,10 +16,12 @@
16
16
  # fired this turn, which is what keeps this from looping. One refusal per turn, then:
17
17
  # a nudge with the state in it rather than a wall.
18
18
  #
19
- # Two checks run ahead of the run guard, both keyed on the tree having moved
20
- # since the session opened: an entry check for a session that never routed,
21
- # and a verification gate refusing a fast or main handback with no passing
22
- # check recorded against the tree as it stands (see their own sections).
19
+ # Three checks run ahead of the run guard, all keyed on the tree having moved
20
+ # since the session opened: an entry check for a session that never routed, a
21
+ # verification gate refusing a fast or main handback with no passing check
22
+ # recorded against the tree as it stands, and a test gate refusing a handback
23
+ # that weakened an existing test with no record of why (see their own
24
+ # sections).
23
25
  #
24
26
  # Reads the harness's stop JSON on stdin for `cwd` and `stop_hook_active`. To
25
27
  # refuse, prints {"decision":"block","reason":...} on stdout. Always exits 0.
@@ -70,6 +72,7 @@ fi
70
72
  # moved_tree only when it moved; it stays empty when it did not or when that
71
73
  # cannot be told.
72
74
  moved_tree=""
75
+ moved_stamp=""
73
76
  read_moved() {
74
77
  local stamp tree line_tree line_digest now
75
78
  [ -n "$sid" ] || return 0
@@ -89,12 +92,14 @@ read_moved() {
89
92
 
90
93
  # A tree that cannot be read is unknown, not moved. Losing git's exit
91
94
  # status here would turn a held lock or a rebase in flight into a refused
92
- # turn over a tree nobody touched.
93
- now="$(bash "$here/tree-snapshot.sh" "$tree" 2>/dev/null)" || return 0
95
+ # turn over a tree nobody touched. Without optional locks for the reason
96
+ # session-start.sh gives: a hook must leave the index as it found it.
97
+ now="$(GIT_OPTIONAL_LOCKS=0 bash "$here/tree-snapshot.sh" "$tree" 2>/dev/null)" || return 0
94
98
  [ -n "$now" ] || return 0
95
99
  [ "$now" = "$line_digest" ] && return 0
96
100
 
97
101
  moved_tree="$tree"
102
+ moved_stamp="$stamp"
98
103
  }
99
104
  read_moved
100
105
 
@@ -231,6 +236,75 @@ if ! verify_gate; then
231
236
  exit 0
232
237
  fi
233
238
 
239
+ # ---- test gate -------------------------------------------------------------
240
+ # A test edited until it agrees with the new code proves nothing about that
241
+ # code, and a passing check cannot tell that edit from an honest one. So a
242
+ # changed existing test (a removed line, a deletion, a skip or focus marker,
243
+ # as test-changes.sh decides) needs a line in .sluice/tests-changed naming its
244
+ # current content: either proved red on the session's base by
245
+ # `status.sh tests-changed -- <cmd>`, or recorded unproven with a reason. An
246
+ # edit made after its record names other content and is refused again. A new
247
+ # test only asks more of the code, so it needs nothing.
248
+ #
249
+ # The base is line 3 of the stamp read_moved found. A stamp without one, or
250
+ # one gc has pruned, is unknown, and test-changes.sh fails on a base it cannot
251
+ # read, so either way this stays silent. It applies in every channel, after
252
+ # the verify gate so one attempt carries one refusal, and once per turn comes
253
+ # from the `stop_hook_active` exit at the top.
254
+ test_gate() {
255
+ local base flagged missing p sha rec_sha rec_path rest found
256
+ [ -n "$moved_tree" ] || return 0
257
+ base="$(sed -n '3p' "$moved_stamp" 2>/dev/null)"
258
+ [ -n "$base" ] || return 0
259
+ base="$(git -C "$moved_tree" rev-parse --verify --quiet "$base^{commit}" 2>/dev/null)" || return 0
260
+ [ -n "$base" ] || return 0
261
+ flagged="$(bash "$here/test-changes.sh" "$moved_tree" "$base" 2>/dev/null)" || return 0
262
+ [ -n "$flagged" ] || return 0
263
+
264
+ missing=""
265
+ while IFS= read -r p; do
266
+ if [ -f "$moved_tree/$p" ]; then
267
+ # Hashed the way status.sh records it, path-aware so filters apply.
268
+ # A file that cannot be hashed has no record status.sh could have
269
+ # written either, so it is named as unreadable rather than letting
270
+ # one path silence the gate for every other. A directory where the
271
+ # file was is its deletion, as status.sh reads it.
272
+ sha="$(git -C "$moved_tree" hash-object -- "$p" 2>/dev/null)"
273
+ if [ -z "$sha" ]; then
274
+ missing="$missing$p (cannot be read)"$'\n'
275
+ continue
276
+ fi
277
+ else
278
+ sha="-"
279
+ fi
280
+ found=""
281
+ if [ -f "$moved_tree/.sluice/tests-changed" ]; then
282
+ while IFS=$'\t' read -r rec_sha rec_path rest; do
283
+ if [ "$rec_sha" = "$sha" ] && [ "$rec_path" = "$p" ]; then
284
+ found=1
285
+ break
286
+ fi
287
+ done <"$moved_tree/.sluice/tests-changed"
288
+ fi
289
+ [ -n "$found" ] || missing="$missing$p"$'\n'
290
+ done <<<"$flagged"
291
+ [ -n "$missing" ] || return 0
292
+
293
+ # Paths land in a reason the harness prints, so control bytes are stripped
294
+ # as the run guard strips them from the topic and tree.
295
+ jq -n --arg paths "$missing" --arg base "$base" '{
296
+ decision: "block",
297
+ reason: ("sluice: this session changed existing tests in a way that asks less of the code (an edited or removed assertion, a deleted test, a skip or focus marker), and nothing records why: "
298
+ + ($paths | split("\n") | map(select(. != "") | gsub("[\u0000-\u001f\u007f]"; "")) | join(", "))
299
+ + ". A test bent to fit new code proves nothing about it, so each one needs a record against the session base \($base). Prove it fails on the old code: status.sh tests-changed --because \"<the behaviour the change replaces>\" -- <the suite, scoped to these files> (the run is in a scratch worktree of the base, so a node suite usually needs --with node_modules). If it cannot run on the old code, record why: status.sh tests-changed --because \"<the behaviour the change replaces>\" --none \"<why it cannot run on the old code>\". No run can prove a deleted test, so prove the edited ones first, then record each deletion with --none. --only <path> proves or records just those paths, so a fixture that cannot be proved can go to --none --only <fixture> on its own. Then name each changed test, and why it changed, in your reply.")
300
+ }'
301
+ return 1
302
+ }
303
+
304
+ if ! test_gate; then
305
+ exit 0
306
+ fi
307
+
234
308
  [ -f "$STATUS" ] || exit 0
235
309
 
236
310
  # The run the session's own tree answers for, and only that: the state beside
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # Print the test paths a session changed in a way that needs a record, for the
3
+ # test-change gate.
4
+ #
5
+ # Usage: test-changes.sh [--list] <tree> <base>
6
+ # Without --list, prints each test path needing a record, one per line, sorted;
7
+ # empty output means none does. With --list, prints every test path that
8
+ # differs from base (added, modified, deleted, and both ends of a rename), for
9
+ # the proof run to copy or, where the path is gone now, delete. Exit 0 printed
10
+ # the answer, exit 1 the tree or base could not be read, and then nothing is
11
+ # printed: an unreadable tree is "unknown", never "changed", and the caller
12
+ # stays silent.
13
+ #
14
+ # A test that only grows is a test that only asks more of the code, so it
15
+ # passes. What needs a record is a test that asks less, or asks nothing:
16
+ #
17
+ # - a modified file with any removed line, since an edited assertion is a
18
+ # removed line plus an added one;
19
+ # - a deleted file;
20
+ # - an added line carrying a skip or focus marker, in a new file or an old
21
+ # one, since `it.skip(` switches a test off without removing a line, and
22
+ # `.only(` switches every other test off.
23
+ #
24
+ # A 100% rename moves a test without changing what it asks, so it passes.
25
+ #
26
+ # The comparison is base against the working tree as `git add -A` would stage
27
+ # it, so untracked test files count. It is staged into a copy of the index, as
28
+ # content-digest.sh does it: staging the partner's work in the real index to
29
+ # read it would be a change of its own.
30
+
31
+ set -uo pipefail
32
+
33
+ mode=records
34
+ if [ "${1-}" = "--list" ]; then
35
+ mode=list
36
+ shift
37
+ fi
38
+ tree="${1-}"
39
+ base="${2-}"
40
+ [ -n "$tree" ] && [ -n "$base" ] || exit 1
41
+ cd "$tree" 2>/dev/null || exit 1
42
+ git rev-parse --show-toplevel >/dev/null 2>&1 || exit 1
43
+ cd "$(git rev-parse --show-toplevel)" 2>/dev/null || exit 1
44
+ git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || exit 1
45
+ index="$(git rev-parse --git-path index 2>/dev/null)" || exit 1
46
+ [ -n "$index" ] || exit 1
47
+
48
+ markers='\.(skip|only)\(|\bx(it|describe|test)\(|@pytest\.mark\.(skip|xfail)|pytest\.skip\(|\bt\.Skip|@Disabled|@Ignore|#\[ignore\]'
49
+
50
+ # The list comes from the base rather than HEAD or the working tree: a session
51
+ # commits, so HEAD is its to move, and it should not be able to move its own
52
+ # tests out of view by editing .sluice-test-paths.
53
+ pathspecs=()
54
+ if git cat-file -e "$base:.sluice-test-paths" 2>/dev/null; then
55
+ listed="$(git cat-file blob "$base:.sluice-test-paths" 2>/dev/null)" || exit 1
56
+ while IFS= read -r line; do
57
+ line="${line%$'\r'}"
58
+ case "$line" in
59
+ '' | '#'*) continue ;;
60
+ esac
61
+ pathspecs+=(":(glob)$line")
62
+ done <<<"$listed"
63
+ # A list of nothing but comments would otherwise diff every path.
64
+ [ "${#pathspecs[@]}" -gt 0 ] || exit 0
65
+ else
66
+ for glob in '**/test/**' '**/tests/**' '**/__tests__/**' '**/spec/**' \
67
+ '**/*.test.*' '**/*.spec.*' '**/*_test.*' '**/test_*.*' \
68
+ '**/__snapshots__/**' '**/*.snap' '**/testdata/**' '**/golden/**'; do
69
+ pathspecs+=(":(glob)$glob")
70
+ done
71
+ fi
72
+
73
+ # The scratch index lives in the git dir for content-digest.sh's reason: a
74
+ # sandbox can refuse the temp dir, and git add has to write the git dir anyway.
75
+ gitdir="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 1
76
+ scratch="$(mktemp -d "$gitdir/sluice-test-changes.XXXXXX" 2>/dev/null)" || exit 1
77
+ trap 'rm -rf "$scratch"' EXIT
78
+ # -p keeps the index's own mtime. A fresh one would make git trust the stat
79
+ # data of a file edited in the same second it was staged, and miss a same-size
80
+ # edit to it.
81
+ if [ -f "$index" ]; then
82
+ cp -p "$index" "$scratch/index" 2>/dev/null || exit 1
83
+ fi
84
+ export GIT_INDEX_FILE="$scratch/index"
85
+ git add -A >/dev/null 2>&1 || exit 1
86
+
87
+ # The repo's colour, external diff and textconv settings would all change the
88
+ # lines the marker grep reads, so every diff turns them off.
89
+ plain=(--no-color --no-ext-diff --no-textconv)
90
+
91
+ # NUL-separated, because git C-quotes a name holding a quote, backslash, tab or
92
+ # newline in any other form, and a variable cannot hold a NUL.
93
+ git diff --cached "${plain[@]}" -M100% --name-status -z "$base" -- "${pathspecs[@]}" >"$scratch/statuses" || exit 1
94
+
95
+ flagged=""
96
+ while IFS= read -r -d '' status; do
97
+ IFS= read -r -d '' path || exit 1
98
+ source_path=""
99
+ case "$status" in
100
+ R*)
101
+ source_path="$path"
102
+ IFS= read -r -d '' path || exit 1
103
+ ;;
104
+ esac
105
+ if [ "$mode" = list ]; then
106
+ # The source too: it is gone now, and the proof's overlay has to remove it
107
+ # from the base, or a file moved into a directory of its own name blocks
108
+ # that directory.
109
+ flagged="$flagged$source_path${source_path:+$'\n'}$path"$'\n'
110
+ continue
111
+ fi
112
+ case "$status" in
113
+ D | T) flagged="$flagged$path"$'\n' ;;
114
+ A | M)
115
+ # A binary file reports "-" for its line counts, and a changed binary
116
+ # snapshot is as much an edit as a changed text one. A new file has
117
+ # nothing to remove, binary or not.
118
+ if [ "$status" = M ]; then
119
+ numstat="$(git diff --cached "${plain[@]}" --numstat "$base" -- ":(literal)$path")" || exit 1
120
+ removed="$(printf '%s\n' "$numstat" | cut -f2)"
121
+ if [ "$removed" != 0 ]; then
122
+ flagged="$flagged$path"$'\n'
123
+ continue
124
+ fi
125
+ fi
126
+ diff="$(git diff --cached "${plain[@]}" -U0 "$base" -- ":(literal)$path")" || exit 1
127
+ if printf '%s\n' "$diff" | sed -n '/^@@/,$p' | grep '^+' | grep -Eq "$markers"; then
128
+ flagged="$flagged$path"$'\n'
129
+ fi
130
+ ;;
131
+ esac
132
+ done <"$scratch/statuses"
133
+
134
+ printf '%s' "$flagged" | grep -v '^$' | LC_ALL=C sort -u
135
+ exit 0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Claude Code only; stands down where the superpowers pipeline governs the repo.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",