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.
- package/API.md +106 -12
- package/docs/decisions/AGENTS.md +5 -0
- package/docs/decisions/publish-path.md +247 -9
- package/docs/decisions/qa-reproduce.md +30 -0
- package/docs/guide.md +41 -5
- package/docs/publish-unknown-recovery.md +28 -120
- package/docs/publish-verification.md +148 -501
- package/lib/AGENTS.md +9 -13
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +82 -5
- package/lib/crew-api.js +1134 -1027
- package/lib/merge-lock.sh +153 -41
- package/lib/publish-note-vocabulary.js +135 -42
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +69 -0
- package/lib/serve-artifact.js +46 -2
- package/lib/test-detached-integrate.sh +122 -0
- package/lib/test-merge-lock.sh +30 -1
- package/lib/worktree-lifecycle.sh +284 -34
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +55 -89
- package/seed/crons.json +12 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +489 -224
- package/workflows/chore.js +306 -226
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +494 -251
- package/workflows/upgrade.js +13 -1
- package/lib/build-readback-request.js +0 -140
- package/lib/check-intent-freshness.js +0 -101
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -417
- 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
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
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
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
# push
|
|
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
|
|
464
|
-
|
|
465
|
-
if [ -n "$
|
|
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
|
-
|
|
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 $
|
|
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
|
|
478
|
-
# verified
|
|
479
|
-
# the merge lock is
|
|
480
|
-
|
|
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
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
|
|
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
|
-
#
|
|
674
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
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.
|