muse-crew 0.14.5 → 0.14.7

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 (41) hide show
  1. package/API.md +106 -12
  2. package/docs/decisions/AGENTS.md +5 -0
  3. package/docs/decisions/publish-path.md +247 -9
  4. package/docs/decisions/qa-reproduce.md +30 -0
  5. package/docs/guide.md +41 -5
  6. package/docs/publish-unknown-recovery.md +28 -120
  7. package/docs/publish-verification.md +148 -501
  8. package/lib/AGENTS.md +9 -13
  9. package/lib/advance-publish-base.js +3 -3
  10. package/lib/compose-evidence-caption.js +1 -2
  11. package/lib/compute-publish-diff.js +82 -5
  12. package/lib/crew-api.js +1134 -1027
  13. package/lib/merge-lock.sh +153 -41
  14. package/lib/publish-note-vocabulary.js +135 -42
  15. package/lib/qa-db.js +132 -0
  16. package/lib/schema.sql +69 -0
  17. package/lib/serve-artifact.js +46 -2
  18. package/lib/test-detached-integrate.sh +122 -0
  19. package/lib/test-merge-lock.sh +30 -1
  20. package/lib/worktree-lifecycle.sh +284 -34
  21. package/package.json +1 -1
  22. package/seed/AGENTS.md +1 -0
  23. package/seed/cron-body-ack-scan.md +44 -0
  24. package/seed/cron-body-template.md +55 -89
  25. package/seed/crons.json +12 -0
  26. package/workflows/AGENTS.md +1 -1
  27. package/workflows/bugfix.js +489 -224
  28. package/workflows/chore.js +306 -226
  29. package/workflows/crew-dispatch.js +57 -4
  30. package/workflows/crew-init.js +23 -0
  31. package/workflows/crew-uninstall.js +7 -4
  32. package/workflows/docs.js +24 -2
  33. package/workflows/standard.js +494 -251
  34. package/workflows/upgrade.js +13 -1
  35. package/lib/build-readback-request.js +0 -140
  36. package/lib/check-intent-freshness.js +0 -101
  37. package/lib/classify-publish-absence.js +0 -462
  38. package/lib/publish-content.js +0 -154
  39. package/lib/readback-disk.js +0 -195
  40. package/lib/retry-publish.js +0 -417
  41. package/lib/verify-publish.js +0 -416
@@ -19,6 +19,11 @@
19
19
  # prepare <task_id> — create worktree + branch, register ownership
20
20
  # resolve-branch <task_id> — print the resolved task branch name
21
21
  # inspect <task_id> — diff against the integration target for review
22
+ # classify-branch <task_id> — mechanical branch-state classification for Review:
23
+ # has-work | already-merged:<sha> | empty-no-work (pure git, no judgment)
24
+ # version-check <task_id> — mechanical package.json `version` check for Review:
25
+ # VERSION_TOUCHED | VERSION_CLEAN (pure git, no judgment;
26
+ # check failure → stderr + exit 1, fail closed)
22
27
  # integrate <task_id> <commit_msg> — merge lock → merge into the integration target → report commit
23
28
  # integration-target — print the integration target (branch name, or HEAD when detached)
24
29
  # push-target <task_id> — refresh lock, push the integration target (skipped loudly on detached HEAD)
@@ -62,12 +67,33 @@ MERGE_LOCK="$LIB_DIR/merge-lock.sh"
62
67
 
63
68
  # Merge records: $CREW_HOME/.merge-records/<task_id> (key=value, append-only,
64
69
  # on persistent disk — same durability class as $CREW_HOME/.pins/<task>).
65
- # Written by cmd_integrate (post-reconcile HEAD). Read by cmd_verify_merge
66
- # (branch-reclamation fallback) and the workflow. Consumers trust a record
67
- # only after verifying `git merge-base --is-ancestor <merge_commit> <target-tip>` —
68
- # a stale record is inert. Removed by cmd_post_deploy (consumed).
70
+ # A durable per-task delivery log: written by cmd_integrate (post-reconcile
71
+ # HEAD), by cmd_integrate's recovery path, and by cmd_push_target's manual
72
+ # path — every merge the lifecycle attributes gets recorded. Read by
73
+ # cmd_verify_merge (branch-reclamation fallback), cmd_push_target's identity
74
+ # check, and classify-branch (Review-entry branch-state classification).
75
+ # Consumers trust a record only after verifying `git merge-base
76
+ # --is-ancestor <merge_commit> <target-tip>` — a stale record is inert, and
77
+ # staleness is decided at read time. Never deleted: the record is the
78
+ # deterministic proof of delivery, not a transient token. Newest block wins
79
+ # (an operator reading the file: the LAST merge_commit= block is
80
+ # authoritative).
69
81
  MERGE_RECORDS_DIR="$CREW_HOME/.merge-records"
70
82
 
83
+ # append_merge_record(task_id, merge_commit, target): append one block to
84
+ # the task's durable merge record. One atomic printf (not three appends —
85
+ # a torn write against a concurrent reader is benign today but not worth
86
+ # the risk), and cut -d= -f2- on read so values containing `=` survive.
87
+ # (merge-lease fix, 2026-09-12; durable per-task delivery log, 2026-09-21.)
88
+ append_merge_record() {
89
+ local task_id="$1" merge_commit="$2" target="$3"
90
+ mkdir -p "$MERGE_RECORDS_DIR"
91
+ printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
92
+ "$merge_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
93
+ >> "$MERGE_RECORDS_DIR/$task_id"
94
+ echo "RECORDED: merge commit $merge_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
95
+ }
96
+
71
97
  # --- helpers ---
72
98
 
73
99
  # Task branch name. Resolution order (task 4e1a1bba, 2026-09-17):
@@ -336,6 +362,31 @@ acquire_merge_lock() {
336
362
  done
337
363
  }
338
364
 
365
+ # current_branch_merge(branch): print the newest merge commit on live HEAD's
366
+ # first-parent chain whose branch-side parent (^2) is the branch's CURRENT
367
+ # tip — i.e. the merge that brought the branch's present state onto the
368
+ # line. Prints nothing and returns 1 when no such merge exists.
369
+ #
370
+ # (blocker 35, room #26 J3, 2026-09-21): the merged-but-unpushed recovery
371
+ # used to check only that the RECORDED merge is an ancestor of live HEAD.
372
+ # Ancestry proves containment, not identity — rework can move the task
373
+ # branch after the merge was recorded, and the recovery would then push
374
+ # the stale recorded state labeled as the task's deliverable (the task
375
+ # later collided with its own stale merge in theme.css). cmd_integrate
376
+ # always merges with --no-ff, so M^2 is the branch tip at merge time;
377
+ # requiring M^2 == current branch tip proves the merge still represents
378
+ # the branch. The first-parent walk also covers the manual R5 merge
379
+ # (scratch-worktree merge, ff'd onto the line) and do_push's reconcile
380
+ # merges above the recorded commit.
381
+ current_branch_merge() {
382
+ local branch="$1" tip h p1 p2 rest
383
+ tip=$(git rev-parse --verify "$branch" 2>/dev/null) || return 1
384
+ while read -r h p1 p2 rest; do
385
+ if [ "$p2" = "$tip" ]; then printf '%s' "$h"; return 0; fi
386
+ done < <(git log --first-parent --min-parents=2 --format='%H %P' HEAD 2>/dev/null)
387
+ return 1
388
+ }
389
+
339
390
  # Deterministic push of the integration target. Called inline by
340
391
  # cmd_integrate on the main path (the lock was just acquired there — no
341
392
  # refresh needed) and by cmd_push_target for the R5 manual-conflict path
@@ -450,34 +501,52 @@ cmd_integrate() {
450
501
  # merge landed but its push never completed. Take the lock and push now
451
502
  # instead of reporting MERGED_EMPTY — otherwise the retry loop parks
452
503
  # the task with the work stranded and no path ever pushes it.
453
- # The check is ancestor-based, not equality-based: if a prior attempt's
454
- # push failed, do_push's retry path may have added a reconcile merge
455
- # on top of the recorded commit before failing again. Equality would
456
- # miss that and fall through to MERGED_EMPTY — reporting PASS while the
457
- # push never happened.
504
+ # The check is identity-based, not ancestry-based (blocker 35,
505
+ # 2026-09-21): the old ancestor check proved the recorded merge was
506
+ # contained in live HEAD but not that it still represents the task
507
+ # branch — rework moving the branch after the merge made the recovery
508
+ # push stale state. current_branch_merge finds the merge whose ^2 is
509
+ # the branch's CURRENT tip: the recorded merge, a do_push reconcile
510
+ # merge above it, or the manual R5 merge. No such merge plus a record
511
+ # means the branch moved since the merge — STALE_MERGE fails closed
512
+ # (never MERGED_EMPTY: an unpushed merge may sit on the line, and
513
+ # MERGED_EMPTY would report PASS while Publish diffs would still
514
+ # carry the abandoned work).
458
515
  local record_file="$MERGE_RECORDS_DIR/$task_id"
459
516
  local recorded_commit=""
460
517
  if [ -f "$record_file" ]; then
461
518
  recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
462
519
  fi
463
- local live_head
464
- live_head=$(git rev-parse HEAD)
465
- if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$live_head" 2>/dev/null; then
520
+ local live_merge
521
+ live_merge=$(current_branch_merge "$branch") || live_merge=""
522
+ if [ -n "$live_merge" ]; then
466
523
  acquire_merge_lock "$task_id" || exit 2
467
524
  if do_push "$task_id"; then
468
- echo "MERGED: $recorded_commit (recovered: merge landed in a prior attempt; push completed now)"
525
+ # The recovery path lands merges too — record them, or a later
526
+ # classify-branch sees an unrecorded merge (blocker 34, 2026-09-21).
527
+ append_merge_record "$task_id" "$live_merge" "$target"
528
+ if [ "$live_merge" = "$recorded_commit" ]; then
529
+ echo "MERGED: $live_merge (recovered: merge landed in a prior attempt; push completed now)"
530
+ else
531
+ echo "MERGED: $live_merge (recovered: branch tip merged in $live_merge; recorded ${recorded_commit:-none} superseded — push completed now)"
532
+ fi
469
533
  else
470
- echo "ERROR: push recovery failed for previously-merged $recorded_commit"
534
+ echo "ERROR: push recovery failed for previously-merged $live_merge"
471
535
  return 1
472
536
  fi
473
537
  echo "LOCK: held by $task_id — release after deploy with post-deploy"
474
538
  return 0
475
539
  fi
540
+ if [ -n "$recorded_commit" ]; then
541
+ echo "STALE_MERGE: recorded merge $recorded_commit no longer corresponds to $branch (tip $(git rev-parse --verify "$branch" 2>/dev/null)) — no merge of the current tip is on the live line; refusing to push possibly-stale state"
542
+ return 1
543
+ fi
476
544
  # Approved empty diff: the deliverable was runtime state (cron,
477
- # scheduler, dashboard config) or an already-merged sha the workflow
478
- # verified — not a repo change. Nothing to merge, nothing to serialize —
479
- # the merge lock is intentionally not taken.
480
- echo "MERGED_EMPTY: $branch has no commits ahead of $target — runtime-state deliverable or verified already-merged sha, nothing to merge"
545
+ # scheduler, dashboard config) or a task-attributed merge the
546
+ # classifier verified from the task's own merge records — not a repo
547
+ # change. Nothing to merge, nothing to serialize — the merge lock is
548
+ # intentionally not taken.
549
+ echo "MERGED_EMPTY: $branch has no commits ahead of $target — runtime-state deliverable or task-attributed merge, nothing to merge"
481
550
  return 0
482
551
  fi
483
552
 
@@ -524,17 +593,11 @@ cmd_integrate() {
524
593
  merged_commit=$(git rev-parse HEAD)
525
594
  echo "MERGED: $merged_commit"
526
595
 
527
- # Record the merge commit (merge-lease fix, 2026-09-12): if the branch is
528
- # later reclaimed (e.g. under an expired lease), verify-merge can still
529
- # confirm the merge landed from this record. Append-only, on persistent
530
- # disk. One atomic printf (not three appends — a torn write against a
531
- # concurrent reader is benign today but not worth the risk), and cut
532
- # -d= -f2- on read so values containing `=` survive.
533
- mkdir -p "$MERGE_RECORDS_DIR"
534
- printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
535
- "$merged_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
536
- >> "$MERGE_RECORDS_DIR/$task_id"
537
- echo "RECORDED: merge commit $merged_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
596
+ # Record the merge commit: if the branch is later reclaimed (e.g. under an
597
+ # expired lease), verify-merge can still confirm the merge landed from this
598
+ # record. Durable per-task delivery log — never deleted (blocker 34,
599
+ # 2026-09-21).
600
+ append_merge_record "$task_id" "$merged_commit" "$target"
538
601
 
539
602
  # Push inline while the lock is held — no agent round-trip (the old STEP 2
540
603
  # agent run added only a lock refresh). R5's manual-conflict path uses the
@@ -670,8 +733,11 @@ cmd_post_deploy() {
670
733
  # Release merge lock
671
734
  "$MERGE_LOCK" release "$task_id" 2>/dev/null && echo "LOCK: released" || true
672
735
 
673
- # Consume the merge record — the merge is now deployed
674
- rm -f "$MERGE_RECORDS_DIR/$task_id"
736
+ # The merge record is NOT deleted here (blocker 34, 2026-09-21):
737
+ # records are an append-only delivery log — they survive deploy so a
738
+ # later dispatch can still classify the task's branch against the live
739
+ # target. Consumers validate ancestry at read time; a stale record is
740
+ # inert. See the MERGE_RECORDS_DIR contract at the top of this file.
675
741
 
676
742
  echo "DEPLOYED: $final_commit"
677
743
  }
@@ -862,6 +928,114 @@ cmd_integration_target() {
862
928
  integration_target
863
929
  }
864
930
 
931
+ # Branch-state classification for Review entry (blocker 34, 2026-09-21).
932
+ # Usage: classify-branch <task_id>
933
+ # Stdout: exactly one successful line —
934
+ # BRANCH_STATE: has-work | already-merged:<40-hex> | empty-no-work
935
+ # Stderr: exactly one DIAG line per invocation —
936
+ # DIAG: classify-branch records=<n> resolvable=<m>
937
+ # (blocker 34 design v2.1). Merge records are load-bearing with no
938
+ # fallback: silent record loss must surface as records>0 resolvable=0,
939
+ # never as a lying empty-no-work. The workflow logs the DIAG line
940
+ # verbatim.
941
+ # Resolution failure: exit 1 with diagnostics on stderr, no BRANCH_STATE
942
+ # line (the workflow fails closed; there is no fourth stdout state).
943
+ #
944
+ # Attribution is identity-first, never declared:
945
+ # 1. current-tip identity — the live merge carrying the current branch
946
+ # tip (current_branch_merge, blocker 35's identity primitive). If the
947
+ # tip is on the live line via a merge, that merge IS the deliverable.
948
+ # 2. the task's own durable merge records, newest first (at most 20
949
+ # candidates scanned; garbage/unresolvable entries skipped). The
950
+ # first record that resolves to a MERGE commit and is an ancestor of
951
+ # the live target wins; the SHA is canonicalized to full 40-hex.
952
+ # Poison rule: the newest RESOLVABLE non-ancestor record poisons the
953
+ # fallback — an older record is never accepted beneath a newer known-
954
+ # stale one. The answer then is empty-no-work (stderr notes the poison),
955
+ # never a fabricated already-merged.
956
+ # The branch is empty here, so the blocker-35 identity/ancestry
957
+ # distinction does not apply: there is no moved branch to go stale
958
+ # against, and every candidate is this task's own record.
959
+ cmd_classify_branch() {
960
+ local task_id="$1"
961
+ validate_task_id "$task_id"
962
+
963
+ # DIAG pin (blocker 34 design v2.1): every invocation emits exactly one
964
+ # stderr line counting the durable merge-record scan. Merge records are
965
+ # load-bearing with no fallback — silent record loss (deleted file, torn
966
+ # lines, gc'd objects) must surface as records>0 resolvable=0, never as
967
+ # a lying empty-no-work. records = candidates scanned (valid-hex
968
+ # merge_commit= lines, at most 20); resolvable = candidates that resolved
969
+ # to MERGE commits. Paths that bypass the scan report 0/0. The workflow
970
+ # logs this line verbatim.
971
+ local diag_records=0 diag_resolvable=0
972
+ diag() { echo "DIAG: classify-branch records=$diag_records resolvable=$diag_resolvable" >&2; }
973
+
974
+ local branch
975
+ branch=$(resolve_branch "$task_id") || { echo "classify-branch: cannot resolve task branch" >&2; diag; return 1; }
976
+
977
+ cd "$REPO"
978
+ git rev-parse --verify "$branch" >/dev/null 2>&1 || { echo "classify-branch: branch $branch does not exist" >&2; diag; return 1; }
979
+
980
+ local target
981
+ target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
982
+
983
+ local ahead
984
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null) || { echo "classify-branch: rev-list failed" >&2; diag; return 1; }
985
+ if [ "$ahead" -gt 0 ]; then
986
+ diag
987
+ echo "BRANCH_STATE: has-work"
988
+ return 0
989
+ fi
990
+
991
+ # 1. Current-tip identity.
992
+ local tip_merge
993
+ if tip_merge=$(current_branch_merge "$branch" 2>/dev/null); then
994
+ tip_merge=$(git rev-parse --verify "$tip_merge" 2>/dev/null) || tip_merge=""
995
+ if [ -n "$tip_merge" ] && git merge-base --is-ancestor "$tip_merge" "$target" 2>/dev/null; then
996
+ diag
997
+ echo "BRANCH_STATE: already-merged:$tip_merge"
998
+ return 0
999
+ fi
1000
+ fi
1001
+
1002
+ # 2. Durable merge records, newest first. Empty-file-safe: no pipelines
1003
+ # under set -e/pipefail, at most 20 candidate lines read.
1004
+ local rec="$MERGE_RECORDS_DIR/$task_id"
1005
+ if [ -f "$rec" ]; then
1006
+ local count=0 sha full poisoned=""
1007
+ while IFS= read -r line && [ "$count" -lt 20 ]; do
1008
+ case "$line" in
1009
+ merge_commit=*) sha="${line#merge_commit=}" ;;
1010
+ *) continue ;;
1011
+ esac
1012
+ case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
1013
+ count=$((count + 1))
1014
+ diag_records=$((diag_records + 1))
1015
+ full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
1016
+ # A merge commit: the frozen first-parent diff the workflow reviews
1017
+ # only exists for merges. A non-merge ancestor is never the task's
1018
+ # merge — skipped, not poison (garbage must not veto delivery).
1019
+ git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
1020
+ diag_resolvable=$((diag_resolvable + 1))
1021
+ if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
1022
+ if [ -n "$poisoned" ]; then
1023
+ echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
1024
+ break
1025
+ fi
1026
+ diag
1027
+ echo "BRANCH_STATE: already-merged:$full"
1028
+ return 0
1029
+ fi
1030
+ [ -z "$poisoned" ] && poisoned="$full"
1031
+ done < <(tac "$rec" 2>/dev/null)
1032
+ fi
1033
+
1034
+ diag
1035
+ echo "BRANCH_STATE: empty-no-work"
1036
+ return 0
1037
+ }
1038
+
865
1039
  # Agent-side one-liner: prints the remote branch a push updates. Attached:
866
1040
  # the branch name. Detached: the resolved destination from
867
1041
  # resolve_push_destination() (fails when genuinely unknowable).
@@ -897,8 +1071,22 @@ cmd_push_target() {
897
1071
  # The lock tells them apart.
898
1072
  if cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
899
1073
  echo "LOCK: refreshed for push (manual-merge path)"
900
- do_push "$task_id"
901
- return $?
1074
+ # Blocker 34 (2026-09-21): the manual merge has no record yet —
1075
+ # attribute it by current-tip identity, push, and record ONLY after
1076
+ # the push succeeds. A failed push records nothing.
1077
+ local branch manual_merge push_target_name
1078
+ if branch=$(resolve_branch "$task_id" 2>/dev/null) \
1079
+ && manual_merge=$(current_branch_merge "$branch" 2>/dev/null) \
1080
+ && [ -n "$manual_merge" ]; then
1081
+ if do_push "$task_id"; then
1082
+ push_target_name=$(integration_target) || push_target_name="unknown"
1083
+ append_merge_record "$task_id" "$manual_merge" "$push_target_name"
1084
+ return 0
1085
+ fi
1086
+ return 1
1087
+ fi
1088
+ echo "ERROR: cannot attribute a manual merge for $task_id — no merge of the current branch tip is on the live line" >&2
1089
+ return 1
902
1090
  fi
903
1091
  echo "PUSH_SKIPPED: no merge record for $task_id and no lock held -- nothing was merged, nothing to push"
904
1092
  return 0
@@ -912,10 +1100,70 @@ cmd_push_target() {
912
1100
  fi
913
1101
  echo "LOCK: refreshed for push"
914
1102
 
1103
+ # Blocker-35 (2026-09-21): the ERROR-after-MERGED retry pushed live HEAD
1104
+ # with no check that it still carries the task's deliverable — rework
1105
+ # between the merge and the retry made it push stale state. Prove the
1106
+ # branch's current tip was merged onto the live line before pushing.
1107
+ # Branch gone (merge-lease reclaim): no rework could have moved it, so
1108
+ # the recorded merge on the line is still the deliverable.
1109
+ local branch live_merge recorded_commit
1110
+ recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
1111
+ live_merge=""
1112
+ if branch=$(resolve_branch "$task_id" 2>/dev/null); then
1113
+ if git rev-parse --verify "$branch" >/dev/null 2>&1; then
1114
+ live_merge=$(current_branch_merge "$branch") || live_merge=""
1115
+ elif [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$(git rev-parse HEAD 2>/dev/null)" 2>/dev/null; then
1116
+ live_merge="$recorded_commit"
1117
+ fi
1118
+ else
1119
+ echo "ERROR: cannot resolve task branch for $task_id — not pushing without a verifiable branch"
1120
+ return 1
1121
+ fi
1122
+ if [ -z "$live_merge" ]; then
1123
+ echo "STALE_MERGE: no merge of the current ${branch:-unknown} tip is on the live line (recorded: ${recorded_commit:-none}) — the task branch changed since the recorded merge; refusing to push possibly-stale state"
1124
+ return 1
1125
+ fi
1126
+ echo "PUSH_IDENTITY_OK: pushing merge $live_merge carrying the current $branch tip"
1127
+
915
1128
  do_push "$task_id"
916
1129
  return $?
917
1130
  }
918
1131
 
1132
+ # Usage: version-check <task_id>
1133
+ # Output: exactly one machine line on stdout:
1134
+ # VERSION_TOUCHED — the task branch's three-dot diff against the
1135
+ # integration target touches package.json's `version`
1136
+ # field (versions are assigned at publish time,
1137
+ # never in branches)
1138
+ # VERSION_CLEAN — no `version` change on the branch
1139
+ # A check failure (unresolvable branch/target, diff error) prints
1140
+ # diagnostics on stderr and exits 1 — the workflow treats that as touched
1141
+ # (fail closed). There is no VERSION_CHECK_FAILED stdout token (blocker
1142
+ # 34, 2026-09-21): stdout carries only the two real states.
1143
+ # A deterministic git fact for Review: the reviewer never adjudicates it.
1144
+ cmd_version_check() {
1145
+ local task_id="$1"
1146
+ validate_task_id "$task_id"
1147
+
1148
+ local branch
1149
+ branch=$(resolve_branch "$task_id") || { echo "version-check: cannot resolve task branch" >&2; return 1; }
1150
+
1151
+ cd "$REPO"
1152
+ git rev-parse --verify "$branch" >/dev/null 2>&1 || { echo "version-check: branch $branch does not exist" >&2; return 1; }
1153
+
1154
+ local target
1155
+ target=$(integration_target) || { echo "version-check: cannot resolve integration target" >&2; return 1; }
1156
+
1157
+ local diff
1158
+ diff=$(git diff "$target...$branch" -- package.json 2>/dev/null) || { echo "version-check: diff failed" >&2; return 1; }
1159
+ if printf '%s' "$diff" | grep -qE '^[+-].*"version"[[:space:]]*:'; then
1160
+ echo "VERSION_TOUCHED"
1161
+ else
1162
+ echo "VERSION_CLEAN"
1163
+ fi
1164
+ return 0
1165
+ }
1166
+
919
1167
  # --- dispatch ---
920
1168
 
921
1169
  cmd="${1:-}"
@@ -929,6 +1177,8 @@ case "$cmd" in
929
1177
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
930
1178
  verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
931
1179
  integration-target) cmd_integration_target ;;
1180
+ classify-branch) cmd_classify_branch "${1:?task_id required}" ;;
1181
+ version-check) cmd_version_check "${1:?task_id required}" ;;
932
1182
  push-destination) cmd_push_destination ;;
933
1183
  push-target) cmd_push_target "${1:?task_id required}" ;;
934
1184
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
@@ -940,7 +1190,7 @@ case "$cmd" in
940
1190
  merge-record) cmd_merge_record "${1:?task_id required}" ;;
941
1191
  *)
942
1192
  echo "Usage: worktree-lifecycle.sh <command> [args]"
943
- echo "Commands: validate, prepare, resolve-branch, inspect, integrate, integration-target, push-target, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
1193
+ echo "Commands: validate, prepare, resolve-branch, inspect, classify-branch, version-check, integrate, integration-target, push-target, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
944
1194
  exit 1
945
1195
  ;;
946
1196
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
package/seed/AGENTS.md CHANGED
@@ -5,6 +5,7 @@ Init source data. Everything `crew-init.js` reads when setting up a new crew ins
5
5
  - `crons.json` — the declarative cron manifest; crew-init creates/updates each entry idempotently; `enabled` is a creation-time default only
6
6
  - `cron-body-template.md` — template for creating the dispatch cron job
7
7
  - `cron-body-update-watch.md` — template for the daily `crew-update-watch` cron job: runs `lib/update-watch.js --crew-home {crewHome}` verbatim and reports stdout
8
+ - `cron-body-ack-scan.md` — template for the 5-minute `crew-ack-scan` cron job: runs the observation-only `scan-ack-pending` pass so publish verdicts land quickly. It never issues artifact edits — issuance stays in the 15-minute poll tick's claim-and-issue path.
8
9
  - `posture.md` — PM posture that shapes the user's assistant for crew interaction
9
10
  - `feedback/` — feedback feature placeholder
10
11
  - `workflows/` — human-readable workflow definitions, copied to `$CREW_HOME/.orchestration/workflows/` during init
@@ -0,0 +1,44 @@
1
+ # Muse Crew acknowledgement scan (fast observation tick)
2
+
3
+ You are the ack-scan worker for the Muse Crew at {crewHome}. This is an
4
+ observation-only tick: it runs the version acknowledgement scan every 5
5
+ minutes so publish verdicts land quickly. It NEVER issues artifact edits —
6
+ issuance belongs to the 15-minute poll tick's Step 4.4/4.4b claim-and-issue
7
+ path.
8
+
9
+ 1. Run the scan verbatim:
10
+
11
+ ```
12
+ node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-ack-pending
13
+ ```
14
+
15
+ The command cross-checks that the running release is the active one
16
+ (`resolveActiveRelease`); if it reports a release mismatch, do NOT
17
+ proceed — log the mismatch and stop.
18
+
19
+ Failure behavior (mechanical): if the scan exits nonzero, report its
20
+ stderr verbatim as the complete result of this tick, take NO other
21
+ action, do NOT rerun the scan, do NOT invoke any other Crew API
22
+ command, and stop. A release mismatch ("not the active release") is a
23
+ stop, not a retry. A nonzero exit is never retried inside this tick —
24
+ the next 5-minute tick runs the scan again on its own.
25
+
26
+ 2. Log every list the scan returns: `acknowledged`, `refused`, `reissued`,
27
+ `timeouts`, `skipped`.
28
+ - `acknowledged` / `refused` / `timeouts` are fully handled by code — take
29
+ NO action.
30
+ - `reissued` entries are fresh intents staged for the poll tick —
31
+ immediately claimable, no backoff. This worker does NOT claim or
32
+ issue them; the poll tick's `scan-publish-intent` +
33
+ `record-intent-issuance` path does that.
34
+ - `skipped` with `window-open` means the attempt's 30-minute window from
35
+ latest issuance is still open — no action. The scan runs every 5 minutes
36
+ so the scan's own latency never eats the window: with 6 scans per
37
+ window, the verdict lands within one cadence of the ack or the expiry.
38
+
39
+ 3. Verdicts are evidence-only. The exact per-attempt version string on the
40
+ artifact's disk is the sole positive signal; a `scan-ack-pending` result
41
+ that names `acknowledged` IS the verdict. Never stamp provenance from
42
+ prose, never infer a verdict from timing, and never issue a publish edit
43
+ yourself — a publish edit is always the tick worker's directly-issued
44
+ `artifact_edit` against a claimed intent, never an agent-issued one.