muse-crew 0.4.4 → 0.4.5

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 CHANGED
@@ -71,14 +71,14 @@ Send a stuck or failed task to a specific workflow phase for recovery.
71
71
 
72
72
  ### `parktask`
73
73
 
74
- Atomically park a task for human attention: sets `state` to `parked` and writes the explanatory `note` event in one transaction. Either both land or neither does callers never see a note without a park or a park without a note. The dispatcher skips parked tasks; moving the task back to `todo` is the human re-queue path (which stamps `retry_reset_at` see `updatetask`).
74
+ Atomically park a task for human attention: sets `state` to `parked`, writes the explanatory `note` event, and settles every `running` session for the task — in one transaction. Either all land or none do: callers never see a note without a park, a park without a note, or a parked task with a ghost `running` session left behind (a terminal task transition settles its sessions). Settled sessions move to `failed` the status `recovertask` accepts, so recovery works immediately after a park — with `ended_at` stamped and `Parked: <message>` appended to their notes; `failure_reason` stays null.
75
75
 
76
76
  | Field | Type | Required | Notes |
77
77
  |-------|------|----------|-------|
78
78
  | `task_id` | uuid | yes | |
79
79
  | `message` | string (1–1000) | yes | Why the task needs a human; written to the activity feed as a `note` |
80
80
 
81
- Returns `{ "ok": true, "task": {...} }` with the updated task record.
81
+ Returns `{ "ok": true, "task": {...}, "settled_sessions": <n> }` with the updated task record and the count of sessions settled (`0` when none were running).
82
82
 
83
83
  ---
84
84
 
package/lib/AGENTS.md CHANGED
@@ -8,6 +8,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
8
8
  - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/status/post-deploy over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving.
9
9
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight) on a scratch repo
10
10
  - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \u2014 escape; extracts the shipped block by anchor)
11
+ - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
11
12
  - `orphan-sweep.sh` — find and clean stale worktrees and merge locks
12
13
  - `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
13
14
  - `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
@@ -150,11 +150,28 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
150
150
  rm -f "$TGZ"
151
151
 
152
152
  # 12. Verify: ground truth is the registry, not any agent's summary.
153
- REG="$(npm view "$PKG" version 2>/dev/null)" \
154
- || fail "verify" "npm view $PKG failed"
153
+ # The registry is eventually consistent: a publish followed by an
154
+ # immediate read can observe the pre-publish version (canary 5a027278
155
+ # hit a stale read replica ~6s after publish). A single stale read must
156
+ # never park a landed publish, so this block retries with backoff and
157
+ # client cache-busting instead of failing on the first mismatch.
158
+ # STEP-12-ANCHOR: publish verification (retry-tolerant)
159
+ VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
160
+ VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
161
+ REG=""
162
+ for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
163
+ # --prefer-online busts npm's client-side packument cache; the retry
164
+ # loop absorbs server-side replica lag. A non-zero exit is a miss,
165
+ # not an immediate failure.
166
+ REG="$(npm view "$PKG" version --prefer-online 2>/dev/null || true)"
167
+ [ "$REG" = "$TARGET_VERSION" ] && break
168
+ echo "PUBLISH_VERIFY_RETRY=$attempt registry=$REG"
169
+ [ "$attempt" -lt "$VERIFY_ATTEMPTS" ] && sleep "$VERIFY_SLEEP_SECS"
170
+ done
155
171
  [ "$REG" = "$TARGET_VERSION" ] \
156
- || fail "verify" "registry=$REG"
172
+ || fail "verify" "registry=$REG after $VERIFY_ATTEMPTS attempts"
157
173
  echo "PUBLISH_VERIFIED=$TARGET_VERSION"
174
+ # STEP-12-END
158
175
  fi
159
176
 
160
177
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # test-publish-verify.sh — regression test for the retry-tolerant step-12
3
+ # publish verification in publish-npm.sh (canary 5a027278).
4
+ #
5
+ # Self-contained: extracts the shipped step-12 block from lib/publish-npm.sh
6
+ # by anchor and runs it against a fake `npm` on PATH whose read replica
7
+ # lags — non-zero exits, then the old version, then the target version —
8
+ # and requires the block to converge on success. Also asserts fail-closed
9
+ # exhaustion when the replica never converges.
10
+ #
11
+ # Anchors: `# STEP-12-ANCHOR` ... `# STEP-12-END` in lib/publish-npm.sh.
12
+
13
+ set -uo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ PUBLISH="$SCRIPT_DIR/publish-npm.sh"
17
+
18
+ PKG="muse-crew"
19
+ OLD_VERSION="9.9.8"
20
+ TARGET_VERSION="9.9.9"
21
+
22
+ pass=0
23
+ fail_count=0
24
+ ok() { echo "PASS: $1"; pass=$((pass + 1)); }
25
+ no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
26
+
27
+ TMPBASE="$(mktemp -d)"
28
+ trap 'rm -rf "$TMPBASE"' EXIT
29
+
30
+ # --- Extract the shipped step-12 block by anchor ---
31
+ BLOCK="$TMPBASE/step12.sh"
32
+ sed -n '/# STEP-12-ANCHOR/,/# STEP-12-END/p' "$PUBLISH" > "$BLOCK"
33
+ if grep -q 'PUBLISH_VERIFIED' "$BLOCK" && grep -q 'fail "verify"' "$BLOCK"; then
34
+ ok "extracted step-12 block from publish-npm.sh"
35
+ else
36
+ no "step-12 extraction failed (anchors missing)"
37
+ fi
38
+
39
+ # --- Runner: fake npm first in PATH, behavior baked in at generation time ---
40
+ FAKEBIN="$TMPBASE/fakebin"
41
+ mkdir -p "$FAKEBIN"
42
+
43
+ # write_fake_npm <behavior>
44
+ # laggy: exit 1 twice (npm failure), old version twice (stale replica),
45
+ # then the target version (replica converged)
46
+ # stale: old version forever (replica never converges)
47
+ write_fake_npm() {
48
+ case "$1" in
49
+ laggy) BEHAVIOR='
50
+ if [ "$n" -le 2 ]; then exit 1; fi
51
+ if [ "$n" -le 4 ]; then echo "'"$OLD_VERSION"'"; exit 0; fi
52
+ echo "'"$TARGET_VERSION"'"; exit 0' ;;
53
+ stale) BEHAVIOR='echo "'"$OLD_VERSION"'"; exit 0' ;;
54
+ *) echo "unknown behavior: $1" >&2; exit 1 ;;
55
+ esac
56
+ cat > "$FAKEBIN/npm" <<EOF
57
+ #!/usr/bin/env bash
58
+ # Records its own argv (for the cache-busting assertion) and its call count.
59
+ echo "\$*" >> "$TMPBASE/argv"
60
+ n=\$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)
61
+ n=\$((n + 1))
62
+ echo "\$n" > "$TMPBASE/calls"
63
+ $BEHAVIOR
64
+ EOF
65
+ chmod +x "$FAKEBIN/npm"
66
+ }
67
+
68
+ # run_step12: runs the shipped block; env: PKG TARGET_VERSION
69
+ # VERIFY_ATTEMPTS VERIFY_SLEEP_SECS; returns the block's exit code and
70
+ # captures its stdout in $OUT.
71
+ run_step12() {
72
+ local runner="$TMPBASE/run-$RANDOM.sh"
73
+ {
74
+ echo 'fail() { echo "PUBLISH_FAILED=$1"; shift; echo "$1"; exit 1; }'
75
+ cat "$BLOCK"
76
+ } > "$runner"
77
+ chmod +x "$runner"
78
+ rm -f "$TMPBASE/calls" "$TMPBASE/argv"
79
+ OUT="$(PATH="$FAKEBIN:$PATH" \
80
+ PKG="$PKG" TARGET_VERSION="$TARGET_VERSION" \
81
+ VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}" \
82
+ VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-0}" \
83
+ bash "$runner" 2>&1)"
84
+ local code=$?
85
+ echo "$OUT"
86
+ return "$code"
87
+ }
88
+
89
+ # --- Case 1: lagging replica converges — block must succeed ---
90
+ write_fake_npm laggy
91
+ OUT="$(run_step12)"
92
+ CODE=$?
93
+ if [ "$CODE" -eq 0 ]; then
94
+ ok "laggy replica converges: block exits 0"
95
+ else
96
+ no "laggy replica converges: block exited $CODE (output: $OUT)"
97
+ fi
98
+ case "$OUT" in
99
+ *"PUBLISH_VERIFIED=$TARGET_VERSION"*)
100
+ ok "prints PUBLISH_VERIFIED=$TARGET_VERSION on convergence" ;;
101
+ *) no "missing PUBLISH_VERIFIED=$TARGET_VERSION (output: $OUT)" ;;
102
+ esac
103
+ case "$OUT" in
104
+ *"PUBLISH_FAILED"*) no "PUBLISH_FAILED printed despite convergence" ;;
105
+ *) ok "no PUBLISH_FAILED on convergence" ;;
106
+ esac
107
+ CALLS="$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)"
108
+ if [ "$CALLS" = "5" ]; then
109
+ ok "converged after exactly 5 reads (2 failures, 2 stale, 1 good)"
110
+ else
111
+ no "expected 5 npm calls on convergence, saw $CALLS"
112
+ fi
113
+ RETRIES="$(grep -c 'PUBLISH_VERIFY_RETRY=' <<< "$OUT" || true)"
114
+ if [ "$RETRIES" = "4" ]; then
115
+ ok "prints a PUBLISH_VERIFY_RETRY progress line per miss"
116
+ else
117
+ no "expected 4 PUBLISH_VERIFY_RETRY lines, saw $RETRIES"
118
+ fi
119
+
120
+ # --- Case 2: replica never converges — fail closed with the verify marker ---
121
+ write_fake_npm stale
122
+ VERIFY_ATTEMPTS=3 OUT="$(run_step12)"
123
+ CODE=$?
124
+ if [ "$CODE" -ne 0 ]; then
125
+ ok "stale replica: block fails closed (non-zero exit)"
126
+ else
127
+ no "stale replica: block exited 0 despite persistent mismatch"
128
+ fi
129
+ case "$OUT" in
130
+ *"PUBLISH_FAILED=verify"*)
131
+ ok "exhaustion keeps the PUBLISH_FAILED=verify marker contract" ;;
132
+ *) no "missing PUBLISH_FAILED=verify (output: $OUT)" ;;
133
+ esac
134
+ case "$OUT" in
135
+ *"registry=$OLD_VERSION after 3 attempts"*)
136
+ ok "exhaustion detail names registry value and attempt count" ;;
137
+ *) no "missing exhaustion detail (output: $OUT)" ;;
138
+ esac
139
+ CALLS="$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)"
140
+ if [ "$CALLS" = "3" ]; then
141
+ ok "bounded: stops after VERIFY_ATTEMPTS reads"
142
+ else
143
+ no "expected 3 npm calls on exhaustion, saw $CALLS"
144
+ fi
145
+
146
+ # --- Case 3: every read goes through --prefer-online (client cache-bust) ---
147
+ write_fake_npm laggy
148
+ run_step12 >/dev/null
149
+ if grep -q -- '--prefer-online' "$TMPBASE/argv"; then
150
+ ok "every npm view read passes --prefer-online"
151
+ else
152
+ no "npm view reads missing --prefer-online"
153
+ fi
154
+
155
+ echo ""
156
+ echo "$pass passed, $fail_count failed."
157
+ [ "$fail_count" -eq 0 ]
@@ -16,6 +16,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
16
  REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
17
17
  PUBLISH="$SCRIPT_DIR/publish-npm.sh"
18
18
 
19
+ # Target the next patch after the repo's current version. (A hardcoded target
20
+ # goes stale the moment the repo releases it: writing the version that's
21
+ # already there produces no diff and the preservation check fails closed.)
22
+ TARGET_VERSION="$(node -e 'const fs=require("fs");const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).version.split(".");v[v.length-1]=String(Number(v[v.length-1])+1);console.log(v.join("."))' "$REPO_DIR/package.json")"
23
+
19
24
  pass=0
20
25
  fail_count=0
21
26
  ok() { echo "PASS: $1"; pass=$((pass + 1)); }
@@ -58,7 +63,7 @@ RUNNER="$TMPBASE/run.sh"
58
63
  } > "$RUNNER"
59
64
  chmod +x "$RUNNER"
60
65
 
61
- if REPO_PATH="$FIX" TARGET_VERSION="0.4.4" bash "$RUNNER" >/dev/null 2>&1; then
66
+ if REPO_PATH="$FIX" TARGET_VERSION="$TARGET_VERSION" bash "$RUNNER" >/dev/null 2>&1; then
62
67
  ok "shipped step-8 block exits 0 on escape-carrying package.json"
63
68
  else
64
69
  no "shipped step-8 block failed closed on escape-carrying package.json"
@@ -66,10 +71,10 @@ fi
66
71
 
67
72
  # --- Assertions on the result ---
68
73
  VER="$(node -p "JSON.parse(require('fs').readFileSync('$FIX/package.json','utf8')).version")"
69
- if [ "$VER" = "0.4.4" ]; then
70
- ok "package.json version is 0.4.4 after write"
74
+ if [ "$VER" = "$TARGET_VERSION" ]; then
75
+ ok "package.json version is $TARGET_VERSION after write"
71
76
  else
72
- no "package.json version is $VER, expected 0.4.4"
77
+ no "package.json version is $VER, expected $TARGET_VERSION"
73
78
  fi
74
79
 
75
80
  NUMSTAT="$(git -C "$FIX" diff --numstat -- package.json | tr '\t' ' ')"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -36,6 +36,9 @@ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
36
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
37
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
38
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
39
+ // The four basenames the pin step must materialize — asserted mechanically
40
+ // by workflow code from the verbatim listing, never from agent prose.
41
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
39
42
 
40
43
  // Project config — passed by dispatcher, falls back to dashboard defaults
41
44
  const projectConfig = inputs.project_config || {};
@@ -172,6 +175,27 @@ function workRetryKey(stepName, reworkSuffix, attempt) {
172
175
  function attemptKey(base, reworkCount) {
173
176
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
174
177
  }
178
+ // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
179
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
180
+ // basenames; the agent cannot self-certify. (The pin step was the one place
181
+ // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
182
+ // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
183
+ // tests/pin-location.test.js.
184
+ function pinLifecycle(key) {
185
+ return agent(
186
+ "Snapshot lifecycle scripts for version pinning.\n" +
187
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
188
+ "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
189
+ { key: key, label: "Pinning lifecycle scripts",
190
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
191
+ );
192
+ }
193
+ // parsePinListing(result) — basenames from a pin/verify listing.
194
+ // Byte-identical across standard/bugfix/chore — pinned by
195
+ // tests/pin-location.test.js.
196
+ function parsePinListing(result) {
197
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
198
+ }
175
199
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
176
200
  // reason: "discarded" (the runtime threw the output away — it could not be
177
201
  // machine-read) or "empty" (agent() returned without throwing but produced
@@ -474,12 +498,17 @@ async function parkTask(reason) {
474
498
  }
475
499
  let i = startStepIndex;
476
500
 
477
- // Pin lifecycle scripts
478
- await agent(
479
- "Snapshot lifecycle scripts for version pinning.\n" +
480
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
481
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
482
- );
501
+ // ── Pin lifecycle scripts ────────────────────────────────────────────
502
+ // Copy lifecycle scripts into a per-task temp dir so this run is immune
503
+ // to upgrades that land while it's in flight. Verified mechanically:
504
+ // workflow code asserts the four basenames from the verbatim listing
505
+ // the agent cannot self-certify. Any miss parks the task before Triage.
506
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
507
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
508
+ if (missingInitialPins.length > 0) {
509
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
510
+ }
511
+ log("Lifecycle scripts pinned to " + RUN_LIB);
483
512
 
484
513
  while (i < STEPS.length) {
485
514
  const step = STEPS[i];
@@ -525,6 +554,33 @@ while (i < STEPS.length) {
525
554
  }
526
555
  }
527
556
 
557
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
558
+ // Verify the pinned lifecycle scripts still exist on every Publish
559
+ // pass (first pass and rework passes): the pin step trusted agent
560
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
561
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
562
+ // still missing parks the task. Keys are attemptKey-scoped so a
563
+ // rework Publish never re-mints a first-pass key.
564
+ if (step.name === "Publish") {
565
+ let publishPins = parsePinListing(await agent(
566
+ "Verify the pinned lifecycle scripts are present.\n" +
567
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
568
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
569
+ { key: attemptKey("pins-verify-publish", totalReworkCount), label: "Verifying pinned lifecycle scripts",
570
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
571
+ ));
572
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
573
+ if (missingAtPublish.length > 0) {
574
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
575
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", totalReworkCount)));
576
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
577
+ }
578
+ if (missingAtPublish.length > 0) {
579
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
580
+ }
581
+ log("Pin guard: all four lifecycle scripts present at Publish");
582
+ }
583
+
528
584
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
529
585
  // Unknown target (incl. legacy "repo") = config error: block.
530
586
  if (step.name === "Publish" && !PUBLISH_TYPE) {
@@ -34,6 +34,9 @@ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
34
34
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
35
35
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
36
36
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
37
+ // The four basenames the pin step must materialize — asserted mechanically
38
+ // by workflow code from the verbatim listing, never from agent prose.
39
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
37
40
 
38
41
  // Project config — passed by dispatcher, falls back to dashboard defaults
39
42
  const projectConfig = inputs.project_config || {};
@@ -170,6 +173,27 @@ function workRetryKey(stepName, reworkSuffix, attempt) {
170
173
  function attemptKey(base, reworkCount) {
171
174
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
172
175
  }
176
+ // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
177
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
178
+ // basenames; the agent cannot self-certify. (The pin step was the one place
179
+ // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
180
+ // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
181
+ // tests/pin-location.test.js.
182
+ function pinLifecycle(key) {
183
+ return agent(
184
+ "Snapshot lifecycle scripts for version pinning.\n" +
185
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
186
+ "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
187
+ { key: key, label: "Pinning lifecycle scripts",
188
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
189
+ );
190
+ }
191
+ // parsePinListing(result) — basenames from a pin/verify listing.
192
+ // Byte-identical across standard/bugfix/chore — pinned by
193
+ // tests/pin-location.test.js.
194
+ function parsePinListing(result) {
195
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
196
+ }
173
197
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
174
198
  // reason: "discarded" (the runtime threw the output away — it could not be
175
199
  // machine-read) or "empty" (agent() returned without throwing but produced
@@ -432,12 +456,17 @@ async function parkTask(reason) {
432
456
  }
433
457
  let i = startStepIndex;
434
458
 
435
- // Pin lifecycle scripts
436
- await agent(
437
- "Snapshot lifecycle scripts for version pinning.\n" +
438
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
439
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
440
- );
459
+ // ── Pin lifecycle scripts ────────────────────────────────────────────
460
+ // Copy lifecycle scripts into a per-task temp dir so this run is immune
461
+ // to upgrades that land while it's in flight. Verified mechanically:
462
+ // workflow code asserts the four basenames from the verbatim listing
463
+ // the agent cannot self-certify. Any miss parks the task before Triage.
464
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
465
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
466
+ if (missingInitialPins.length > 0) {
467
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
468
+ }
469
+ log("Lifecycle scripts pinned to " + RUN_LIB);
441
470
 
442
471
  while (i < STEPS.length) {
443
472
  const step = STEPS[i];
@@ -483,6 +512,33 @@ while (i < STEPS.length) {
483
512
  }
484
513
  }
485
514
 
515
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
516
+ // Verify the pinned lifecycle scripts still exist on every Publish
517
+ // pass (first pass and rework passes): the pin step trusted agent
518
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
519
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
520
+ // still missing parks the task. Keys are attemptKey-scoped so a
521
+ // rework Publish never re-mints a first-pass key.
522
+ if (step.name === "Publish") {
523
+ let publishPins = parsePinListing(await agent(
524
+ "Verify the pinned lifecycle scripts are present.\n" +
525
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
526
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
527
+ { key: attemptKey("pins-verify-publish", reworkCount), label: "Verifying pinned lifecycle scripts",
528
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
529
+ ));
530
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
531
+ if (missingAtPublish.length > 0) {
532
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
533
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", reworkCount)));
534
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
535
+ }
536
+ if (missingAtPublish.length > 0) {
537
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
538
+ }
539
+ log("Pin guard: all four lifecycle scripts present at Publish");
540
+ }
541
+
486
542
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
487
543
  // Unknown target (incl. legacy "repo") = config error: block.
488
544
  if (step.name === "Publish" && !PUBLISH_TYPE) {
@@ -36,6 +36,9 @@ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
36
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
37
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
38
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
39
+ // The four basenames the pin step must materialize — asserted mechanically
40
+ // by workflow code from the verbatim listing, never from agent prose.
41
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
39
42
 
40
43
  // Project config — passed by dispatcher, falls back to dashboard defaults
41
44
  const projectConfig = inputs.project_config || {};
@@ -172,6 +175,27 @@ function workRetryKey(stepName, reworkSuffix, attempt) {
172
175
  function attemptKey(base, reworkCount) {
173
176
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
174
177
  }
178
+ // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
179
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
180
+ // basenames; the agent cannot self-certify. (The pin step was the one place
181
+ // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
182
+ // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
183
+ // tests/pin-location.test.js.
184
+ function pinLifecycle(key) {
185
+ return agent(
186
+ "Snapshot lifecycle scripts for version pinning.\n" +
187
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
188
+ "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
189
+ { key: key, label: "Pinning lifecycle scripts",
190
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
191
+ );
192
+ }
193
+ // parsePinListing(result) — basenames from a pin/verify listing.
194
+ // Byte-identical across standard/bugfix/chore — pinned by
195
+ // tests/pin-location.test.js.
196
+ function parsePinListing(result) {
197
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
198
+ }
175
199
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
176
200
  // reason: "discarded" (the runtime threw the output away — it could not be
177
201
  // machine-read) or "empty" (agent() returned without throwing but produced
@@ -475,19 +499,14 @@ let i = startStepIndex;
475
499
 
476
500
  // ── Pin lifecycle scripts ────────────────────────────────────────────
477
501
  // Copy lifecycle scripts into a per-task temp dir so this run is immune
478
- // to upgrades that land while it's in flight.
479
- await agent(
480
- "Snapshot lifecycle scripts for version pinning.\n" +
481
- "Run these shell commands:\n" +
482
- " mkdir -p " + RUN_LIB + "\n" +
483
- " cp " + LIFECYCLE_SRC + " " + LIFECYCLE + "\n" +
484
- " cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + "\n" +
485
- " cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + "\n" +
486
- " cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + "\n" +
487
- " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + "\n" +
488
- "Confirm the files exist by listing " + RUN_LIB + ".",
489
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
490
- );
502
+ // to upgrades that land while it's in flight. Verified mechanically:
503
+ // workflow code asserts the four basenames from the verbatim listing —
504
+ // the agent cannot self-certify. Any miss parks the task before Triage.
505
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
506
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
507
+ if (missingInitialPins.length > 0) {
508
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
509
+ }
491
510
  log("Lifecycle scripts pinned to " + RUN_LIB);
492
511
 
493
512
  while (i < STEPS.length) {
@@ -534,6 +553,33 @@ while (i < STEPS.length) {
534
553
  }
535
554
  }
536
555
 
556
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
557
+ // Verify the pinned lifecycle scripts still exist on every Publish
558
+ // pass (first pass and rework passes): the pin step trusted agent
559
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
560
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
561
+ // still missing parks the task. Keys are attemptKey-scoped so a
562
+ // rework Publish never re-mints a first-pass key.
563
+ if (step.name === "Publish") {
564
+ let publishPins = parsePinListing(await agent(
565
+ "Verify the pinned lifecycle scripts are present.\n" +
566
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
567
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
568
+ { key: attemptKey("pins-verify-publish", totalReworkCount), label: "Verifying pinned lifecycle scripts",
569
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
570
+ ));
571
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
572
+ if (missingAtPublish.length > 0) {
573
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
574
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", totalReworkCount)));
575
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
576
+ }
577
+ if (missingAtPublish.length > 0) {
578
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
579
+ }
580
+ log("Pin guard: all four lifecycle scripts present at Publish");
581
+ }
582
+
537
583
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
538
584
  // Unknown target (incl. legacy "repo") = config error: block.
539
585
  if (step.name === "Publish" && !PUBLISH_TYPE) {