muse-crew 0.7.20 → 0.8.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.
@@ -53,13 +53,14 @@ var registryResult = inputs.registry;
53
53
  if (!registryResult) {
54
54
  log("WARNING: no registry in args — falling back to slow registry load");
55
55
  registryResult = await agent(
56
- "Read the following 4 workflow files using the read tool and extract the `steps` array and `reworkTarget` string from each file's `export const meta` block at the top of the file.\n\n" +
56
+ "Read the following 5 workflow files using the read tool and extract the `steps` array and `reworkTarget` string from each file's `export const meta` block at the top of the file.\n\n" +
57
57
  "Files:\n" +
58
58
  "1. " + WORKFLOW_DIR + "/standard.js\n" +
59
59
  "2. " + WORKFLOW_DIR + "/bugfix.js\n" +
60
60
  "3. " + WORKFLOW_DIR + "/chore.js\n" +
61
- "4. " + WORKFLOW_DIR + "/docs.js\n\n" +
62
- "Return an object with keys: standard, bugfix, chore, docs. Each value has { steps: [{name, identity}], reworkTarget: string }.",
61
+ "4. " + WORKFLOW_DIR + "/docs.js\n" +
62
+ "5. " + WORKFLOW_DIR + "/upgrade.js\n\n" +
63
+ "Return an object with keys: standard, bugfix, chore, docs, upgrade. Each value has { steps: [{name, identity}], reworkTarget: string }.",
63
64
  {
64
65
  key: "load-registry",
65
66
  label: "Loading workflow step registry",
@@ -69,9 +70,10 @@ if (!registryResult) {
69
70
  standard: { type: "object" },
70
71
  bugfix: { type: "object" },
71
72
  chore: { type: "object" },
72
- docs: { type: "object" }
73
+ docs: { type: "object" },
74
+ upgrade: { type: "object" }
73
75
  },
74
- required: ["standard", "bugfix", "chore", "docs"]
76
+ required: ["standard", "bugfix", "chore", "docs", "upgrade"]
75
77
  }
76
78
  }
77
79
  );
@@ -80,7 +82,7 @@ if (!registryResult) {
80
82
  // Derive lookup tables from canonical definitions
81
83
  const WORKFLOWS = {};
82
84
  const BUILD_STEPS = {};
83
- var wfNames = ["standard", "bugfix", "chore", "docs"];
85
+ var wfNames = ["standard", "bugfix", "chore", "docs", "upgrade"];
84
86
  for (var wi = 0; wi < wfNames.length; wi++) {
85
87
  var wfName = wfNames[wi];
86
88
  var wfData = registryResult[wfName];
@@ -927,6 +929,41 @@ await agent(
927
929
  { key: "ack", label: "Acknowledging poll" }
928
930
  );
929
931
 
932
+ // ── 4b. Tick release-identity log ──────────────────────────────────
933
+ // Observable proof of which release each scheduler tick ran on, for the
934
+ // self-upgrade proof: one append-only line per dispatcher run in
935
+ // $CREW_HOME/.tick-releases.jsonl: {"seq": <n>, "release": "<active release id>"}.
936
+ // seq is the non-empty line count + 1. The file's ordering is the proof,
937
+ // not timestamps — no wall-clock calls (see tests/determinism.test.js).
938
+ // Fire-and-forget: the script swallows its own errors and exits 0, the
939
+ // agent call carries no schema, and the whole call is wrapped in
940
+ // try/catch — a failed write can never fail the tick. Placement: every run
941
+ // that completes the poll (dispatching, NO_DISPATCH, partial-cap) reaches
942
+ // this point. Concurrency edge: overlapping ticks could duplicate seq
943
+ // (count+append is not atomic) but appends are atomic, so lines never
944
+ // interleave — seq is advisory, file order is the proof.
945
+ var tickReleaseScript = [
946
+ "var crewHome=process.argv[1];",
947
+ "try{",
948
+ "var fs=require(\"fs\"),path=require(\"path\");",
949
+ "var release=path.basename(path.resolve(crewHome,fs.readlinkSync(path.join(crewHome,\"current\"))));",
950
+ "var file=path.join(crewHome,\".tick-releases.jsonl\");",
951
+ "var count=0;",
952
+ "try{var lines=fs.readFileSync(file,\"utf8\").split(\"\\n\");for(var i=0;i<lines.length;i++){if(lines[i].trim()!==\"\"){count++;}}}catch(e){}",
953
+ "fs.appendFileSync(file,JSON.stringify({seq:count+1,release:release})+\"\\n\");",
954
+ "}catch(e){}",
955
+ "process.exit(0);"
956
+ ].join("");
957
+ var tickReleaseCmd = "node -e '" + tickReleaseScript + "' '" + crewHome.replace(/'/g, "'\\''") + "'";
958
+ try {
959
+ await agent(
960
+ "Record this tick's release identity.\nRun in shell and return the stdout verbatim:\n" + tickReleaseCmd,
961
+ { key: "tick-release", label: "Recording tick release identity" }
962
+ );
963
+ } catch (e) {
964
+ log("WARNING: tick release-identity log write failed (tick continues): " + e.message);
965
+ }
966
+
930
967
  var recommended = [];
931
968
  var seenClaims = {};
932
969
  results.forEach(function(r) {
@@ -1,11 +1,12 @@
1
1
  export const meta = {
2
2
  name: "crew-init",
3
- description: "Initialize Muse Crew: bootstrap release, scaffold orchestration, register the first project, set up cron jobs. Idempotent — safe to re-run. The dashboard is optional: omit dashboardSlug/dashboardRepoPath for a CLI-only install. Takes an optional crewName (the human's chosen name for the crew), stored at $CREW_HOME/crew-name.",
3
+ description: "Initialize Muse Crew: bootstrap release, scaffold orchestration, register the first project, set up cron jobs, and persist the automatic-update policy. Idempotent — safe to re-run. The dashboard is optional: omit dashboardSlug/dashboardRepoPath for a CLI-only install. Takes an optional crewName (the human's chosen name for the crew), stored at $CREW_HOME/crew-name. Takes optional autoUpdateCrew/autoUpdateDashboard/updateChannel (defaults true/true/latest) for the automatic update watcher policy.",
4
4
  phases: [
5
5
  { name: "release", title: "Bootstrap release system" },
6
6
  { name: "scaffold", title: "Scaffold orchestration directory" },
7
7
  { name: "project", title: "Register first project" },
8
- { name: "crons", title: "Create and converge cron jobs" }
8
+ { name: "crons", title: "Create and converge cron jobs" },
9
+ { name: "policy", title: "Persist update policy" }
9
10
  ]
10
11
  };
11
12
 
@@ -19,6 +20,28 @@ const dashboardName = inputs.dashboardName || "Muse Crew";
19
20
  const crewName = inputs.crewName || null;
20
21
  const cronIds = inputs.cronIds || {};
21
22
 
23
+ // ── Automatic-update policy inputs ──────────────────────────────────
24
+ // All optional. autoUpdateCrew/autoUpdateDashboard default to true (the
25
+ // update watcher files upgrade tasks through the loop); updateChannel
26
+ // defaults to "latest" ("patch" narrows crew upgrades to patch releases).
27
+ // Explicit inputs win on every run; existing config values survive a
28
+ // re-init that passes no inputs; defaults apply only on first init —
29
+ // never silently resurrect a human's deliberate opt-out.
30
+ const autoUpdateCrew = inputs.autoUpdateCrew !== undefined ? !!inputs.autoUpdateCrew : true;
31
+ const autoUpdateDashboard = inputs.autoUpdateDashboard !== undefined ? !!inputs.autoUpdateDashboard : true;
32
+ const updateChannel = inputs.updateChannel || "latest";
33
+
34
+ // Validation in workflow code, before any agent call — fail closed.
35
+ if (updateChannel !== "latest" && updateChannel !== "patch") {
36
+ return {
37
+ __hatchWorkflowControl: "blocked",
38
+ result: {
39
+ blocked_reason: "Invalid updateChannel",
40
+ message: "updateChannel must be \"latest\" or \"patch\" — got " + JSON.stringify(updateChannel) + ". Re-run init with a valid channel."
41
+ }
42
+ };
43
+ }
44
+
22
45
  if (!crewRepoPath) throw new Error("crewRepoPath is required — path to muse-crew (git checkout or npm install)");
23
46
  if (!crewHome) throw new Error("crewHome is required — e.g. ~/workspace/.crew (must be inside the workspace)");
24
47
  // The dashboard is optional. Provide both dashboardSlug and dashboardRepoPath
@@ -367,19 +390,76 @@ try {
367
390
  log("Project: " + dashboardSlug + " " + projectResult.action + " (repo_path=" + gateFacts.dashboardRepoExpanded + ")");
368
391
  } // end dashboard-mode project registration
369
392
 
393
+ // ── Scheduler identity (2026-09-16) ──────────────────────────────────
394
+ // The crew's scheduler identity is dashboard-independent and chosen once.
395
+ // First init uses the crew-home basename; a re-run keeps the instanceId
396
+ // recorded in .cron-registry.json. A registry that exists but is corrupt,
397
+ // or parses but carries no usable instanceId, fails closed — init will not
398
+ // guess the previous identity, because a wrong guess would orphan the old
399
+ // cron jobs and create duplicates. Attaching or removing a dashboard
400
+ // is a project registration, never an identity change, so it can neither
401
+ // orphan nor duplicate the polling loop. The loop is cli-owned: deleting a
402
+ // dashboard artifact must not stop the crew (the crew never requires a
403
+ // dashboard); crew removal is crew-uninstall's job (registry + discovery).
404
+ // The sensor reports raw; the decision below is pure JS.
405
+ function priorInstanceId(cronRegistryJson, registryPath) {
406
+ if (cronRegistryJson == null) return null;
407
+ var reg;
408
+ try {
409
+ reg = JSON.parse(cronRegistryJson);
410
+ } catch (e) {
411
+ throw new Error("existing " + registryPath + " is not valid JSON (" + String(e.message || e) + "). Inspect or remove it, then re-run init — init will not guess the previous scheduler identity.");
412
+ }
413
+ var id = reg && reg.instanceId;
414
+ if (typeof id !== "string" || id.length === 0) {
415
+ throw new Error("existing " + registryPath + " has no usable instanceId. Repair the registry or remove it, then re-run init — init will not guess the previous scheduler identity.");
416
+ }
417
+ return id;
418
+ }
419
+
370
420
  // ── Phase 4: Cron jobs (declarative manifest) ──────────────────────────
371
421
  // The manifest lives in the repo at seed/crons.json. Body templates live
372
422
  // in seed/ with {crewHome} placeholders. Missing jobs are
373
423
  // created from the manifest; existing jobs converge to it — except `enabled`,
374
424
  // which is a creation-time default only and is never touched on update.
375
- // Instance identity: in dashboard mode the dashboard slug scopes cron ids
376
- // and ownership (space:<slug>); in CLI-only mode the crew-home basename
377
- // scopes them instead, so two crew instances never share a cron id and no
378
- // dashboard is required.
425
+ // Live ids are entry.id + '-' + instanceId (instance-safe by construction —
426
+ // two crew instances never share a cron id) unless overridden via cronIds.
379
427
  phase("crons");
380
- var crewHomeBase = gateFacts.crewHomeExpanded.split("/").pop().replace(/^\./, "") || "crew";
381
- var instanceId = dashboardSlug || crewHomeBase;
382
- var cronOwner = inputs.cronOwner || (dashboardSlug ? "space:" + dashboardSlug : "cli:" + instanceId);
428
+ var registryPath = gateFacts.crewHomeExpanded + "/.cron-registry.json";
429
+ var identityFacts;
430
+ try {
431
+ identityFacts = await agent(
432
+ "Read this crew's existing scheduler identity, if any. Report raw facts.\\n\\n" +
433
+ "Crew home: " + gateFacts.crewHomeExpanded + "\\n\\n" +
434
+ "Steps (run in shell):\\n" +
435
+ "1. If " + registryPath + " exists, print its exact bytes with cat.\\n" +
436
+ "2. If it does not exist, print nothing.\\n" +
437
+ "3. Return JSON { registryJson }: registryJson is the file's exact text, or null when the file does not exist. Do not judge, summarize, or repair — report raw.",
438
+ {
439
+ key: "crons-identity-1",
440
+ label: "Read existing scheduler identity",
441
+ schema: {
442
+ type: "object",
443
+ properties: {
444
+ registryJson: { type: ["string", "null"] }
445
+ },
446
+ required: ["registryJson"]
447
+ }
448
+ }
449
+ );
450
+ } catch (e) {
451
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Scheduler identity read failed", message: String(e.message || e) } };
452
+ }
453
+ var instanceId;
454
+ try {
455
+ instanceId = priorInstanceId(identityFacts.registryJson, registryPath) ||
456
+ gateFacts.crewHomeExpanded.split("/").pop().replace(/^\./, "") || "crew";
457
+ } catch (e) {
458
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Scheduler identity unreadable", message: String(e.message || e) } };
459
+ }
460
+ var cronOwner = inputs.cronOwner || ("cli:" + instanceId);
461
+ log("Scheduler identity: instanceId=" + instanceId + " owner=" + cronOwner +
462
+ (identityFacts.registryJson ? " (kept from existing registry)" : " (first init; crew-home basename)"));
383
463
  var cronsResult;
384
464
  try {
385
465
  cronsResult = await agent(
@@ -480,6 +560,83 @@ log("Crons: " + cronsResult.summary.crons.map(function (c) { return c.id + "=" +
480
560
  "; registry: " + cronsResult.summary.registry.path + " (" + cronsResult.summary.registry.ids.join(", ") + ")");
481
561
 
482
562
 
563
+ // ── Phase 5: Update policy ────────────────────────────────────────────
564
+ // Persists the automatic-update policy (auto_update_crew,
565
+ // auto_update_dashboard, update_channel) via the Crew API's update-config,
566
+ // and seeds $CREW_HOME/.update-watch.json as {} only when it does not
567
+ // exist. Re-init semantics mirror the crons manifest rule: explicit inputs
568
+ // win; existing config values are kept; defaults apply only on first init —
569
+ // a re-init with no inputs never silently resurrects a human's opt-out.
570
+ // No wall-clock reads anywhere in this phase or its prompt.
571
+ phase("policy");
572
+ var policyInputCrew = inputs.autoUpdateCrew !== undefined ? (!!inputs.autoUpdateCrew ? "true" : "false") : null;
573
+ var policyInputDashboard = inputs.autoUpdateDashboard !== undefined ? (!!inputs.autoUpdateDashboard ? "true" : "false") : null;
574
+ var policyInputChannel = inputs.updateChannel !== undefined ? updateChannel : null;
575
+ var safeExpandedHome = gateFacts.crewHomeExpanded.split('"').join('\\"');
576
+ var policyResult;
577
+ try {
578
+ policyResult = await agent(
579
+ "Persist the automatic-update policy for the crew's update watcher.\n\n" +
580
+ "Crew home (expanded): " + gateFacts.crewHomeExpanded + "\n" +
581
+ "Crew API: " + gateFacts.crewHomeExpanded + "/current/lib/crew-api.js\n\n" +
582
+ "Explicit inputs for this run (null means the human gave no input):\n" +
583
+ "- auto_update_crew: " + policyInputCrew + " (default true)\n" +
584
+ "- auto_update_dashboard: " + policyInputDashboard + " (default true)\n" +
585
+ "- update_channel: " + policyInputChannel + " (default latest)\n\n" +
586
+ "Steps:\n" +
587
+ "1. Read the current config:\n" +
588
+ " node " + gateFacts.crewHomeExpanded + "/current/lib/crew-api.js --crew-home " + gateFacts.crewHomeExpanded + " get-config\n" +
589
+ " The response carries { config: { key: value, ... } }.\n" +
590
+ "2. For each key in (auto_update_crew, auto_update_dashboard, update_channel):\n" +
591
+ " - When an explicit input is present for the key, write the input value.\n" +
592
+ " - Otherwise, when the config already holds a value for the key, keep it —\n" +
593
+ " do NOT overwrite it with the default. A human's deliberate opt-out\n" +
594
+ " (e.g. auto_update_crew=false) must survive a re-init that passes no inputs.\n" +
595
+ " - Otherwise write the default (true / true / latest).\n" +
596
+ " Write via:\n" +
597
+ " node " + gateFacts.crewHomeExpanded + "/current/lib/crew-api.js --crew-home " + gateFacts.crewHomeExpanded + " update-config --json '{\"key\": \"<key>\", \"value\": \"<value>\"}'\n" +
598
+ " Values are the strings \"true\"/\"false\" for the booleans and \"latest\"/\"patch\"\n" +
599
+ " for the channel. Only issue update-config for a key whose final value\n" +
600
+ " differs from the current config value.\n" +
601
+ "3. Seed the watcher state file only when absent — never overwrite it,\n" +
602
+ " it is the idempotency record for filed upgrade tasks:\n" +
603
+ " test -f \"" + safeExpandedHome + "/.update-watch.json\" && echo STATE_EXISTS || (printf '%s' '{}' > \"" + safeExpandedHome + "/.update-watch.json\" && echo STATE_SEEDED)\n" +
604
+ "4. Return JSON { persisted: { auto_update_crew: \"<value>\", auto_update_dashboard: \"<value>\", update_channel: \"<value>\" }, state_file: \"seeded\" | \"exists\" }\n" +
605
+ " with the values that are in effect after this run (explicit input,\n" +
606
+ " kept config value, or default — whichever applied).",
607
+ {
608
+ key: "policy-1",
609
+ label: "Persist update policy",
610
+ schema: {
611
+ type: "object",
612
+ properties: {
613
+ persisted: {
614
+ type: "object",
615
+ properties: {
616
+ auto_update_crew: { type: "string" },
617
+ auto_update_dashboard: { type: "string" },
618
+ update_channel: { type: "string" }
619
+ },
620
+ required: ["auto_update_crew", "auto_update_dashboard", "update_channel"]
621
+ },
622
+ state_file: { type: "string", enum: ["seeded", "exists"] }
623
+ },
624
+ required: ["persisted", "state_file"]
625
+ }
626
+ }
627
+ );
628
+ } catch (e) {
629
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Update policy persist failed", message: String(e.message || e) } };
630
+ }
631
+ var persistedPolicy = policyResult.persisted || {};
632
+ if (!persistedPolicy.auto_update_crew || !persistedPolicy.auto_update_dashboard || !persistedPolicy.update_channel) {
633
+ return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Update policy incomplete", message: "policy phase returned no persisted policy values" } };
634
+ }
635
+ log("Update policy: auto_update_crew=" + persistedPolicy.auto_update_crew +
636
+ " auto_update_dashboard=" + persistedPolicy.auto_update_dashboard +
637
+ " update_channel=" + persistedPolicy.update_channel +
638
+ " state_file=" + policyResult.state_file);
639
+
483
640
  // ── Summary ───────────────────────────────────────────────────────────
484
641
  return {
485
642
  message: "Muse Crew initialized.",
@@ -492,5 +649,11 @@ return {
492
649
  scaffold: { created: scaffoldCreated, skipped: scaffoldSkipped },
493
650
  project: projectResult.action,
494
651
  crons: cronsResult.summary.crons,
495
- registry: cronsResult.summary.registry
652
+ registry: cronsResult.summary.registry,
653
+ autoUpdate: {
654
+ crew: persistedPolicy.auto_update_crew === "true",
655
+ dashboard: persistedPolicy.auto_update_dashboard === "true",
656
+ channel: persistedPolicy.update_channel
657
+ },
658
+ announcement: "Your crew and dashboard will update themselves automatically; say the word if you'd rather approve each one."
496
659
  };
@@ -57,7 +57,10 @@ if (!inputs.crewHome) throw new Error("crewHome is required — pass the crew ho
57
57
  const crewHome = inputs.crewHome;
58
58
  // Crew API: the workflow calls the crew-owned CLI, not the dashboard.
59
59
  // The CLI implements the API.md contract against $CREW_HOME/crew-state.db.
60
- const CREW_API = crewHome + "/current/lib/crew-api.js";
60
+ const CREW_API_SRC = crewHome + "/current/lib/crew-api.js";
61
+ // Pinned at pinLifecycle: after the pin, CREW_API points into RUN_LIB so a
62
+ // mid-flight release swap cannot change the CLI under a running workflow.
63
+ let CREW_API = CREW_API_SRC;
61
64
  // Build a shell command invoking the CLI. Args are JSON-encoded and
62
65
  // single-quote-wrapped for safe shell passing. The agent runs this and
63
66
  // returns the stdout verbatim (the CLI emits JSON on stdout).
@@ -83,9 +86,12 @@ const LIFECYCLE = RUN_LIB + "/worktree-lifecycle.sh";
83
86
  const MERGE_LOCK = RUN_LIB + "/merge-lock.sh";
84
87
  const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
85
88
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
86
- // The three basenames the pin step must materialize — asserted mechanically
89
+ const CREW_API_PINNED = RUN_LIB + "/crew-api.js";
90
+ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
91
+ const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
92
+ // The five basenames the pin step must materialize — asserted mechanically
87
93
  // by workflow code from the verbatim listing, never from agent prose.
88
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM].map(function (p) { return p.split("/").pop(); });
94
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED].map(function (p) { return p.split("/").pop(); });
89
95
 
90
96
  // Project config — passed by dispatcher, falls back to dashboard defaults
91
97
  const projectConfig = inputs.project_config || {};
@@ -231,7 +237,7 @@ function attemptKey(base, reworkCount) {
231
237
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
232
238
  }
233
239
  // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
234
- // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
240
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the five pinned
235
241
  // basenames; the agent cannot self-certify. (The pin step was the one place
236
242
  // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
237
243
  // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
@@ -239,7 +245,7 @@ function attemptKey(base, reworkCount) {
239
245
  function pinLifecycle(key) {
240
246
  return agent(
241
247
  "Snapshot lifecycle scripts for version pinning.\n" +
242
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
248
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
243
249
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
244
250
  { key: key, label: "Pinning lifecycle scripts",
245
251
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -534,7 +540,7 @@ function extractMarkerLines(workerText) {
534
540
  var markers = [];
535
541
  for (var i = 0; i < lines.length; i++) {
536
542
  var line = lines[i].trim();
537
- if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|capture_targets:|worktree:)/i.test(line)) {
543
+ if (/^(repo_diff:|release:|version_bump:|VERDICT:|TARGET_VERSION=|published:|experiential:|layer:|capture_targets:|worktree:)/i.test(line)) {
538
544
  markers.push(line);
539
545
  }
540
546
  }
@@ -805,7 +811,7 @@ let i = startStepIndex;
805
811
  // ── Pin lifecycle scripts ────────────────────────────────────────────
806
812
  // Copy lifecycle scripts into a per-task temp dir so this run is immune
807
813
  // to upgrades that land while it's in flight. Verified mechanically:
808
- // workflow code asserts the four basenames from the verbatim listing —
814
+ // workflow code asserts the five basenames from the verbatim listing —
809
815
  // the agent cannot self-certify. Any miss parks the task before Triage.
810
816
  const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
811
817
  const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
@@ -813,6 +819,10 @@ if (missingInitialPins.length > 0) {
813
819
  return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
814
820
  }
815
821
  log("Lifecycle scripts pinned to " + RUN_LIB);
822
+ // From here on, every crew-api.js invocation uses the pinned copy: immune
823
+ // to a release swap landing mid-flight.
824
+ CREW_API = CREW_API_PINNED;
825
+ log("Crew API pinned to " + CREW_API);
816
826
 
817
827
  // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
818
828
  // minted at this run's first claim (never a PID — short-lived agent PIDs
@@ -1115,7 +1125,9 @@ while (i < STEPS.length) {
1115
1125
  var mapBaselineRefs = "";
1116
1126
  var mapBaselineNone = false;
1117
1127
  if (step.name === "Map") {
1118
- if ((await resolveExperiential()) === "yes") {
1128
+ // Must match Capture's run condition (experiential + artifact publish):
1129
+ // when Capture skips, no baseline notes exist, so the gate must not apply.
1130
+ if ((await resolveExperiential()) === "yes" && PUBLISH_TYPE === "artifact") {
1119
1131
  var gateStatus = await baselineStatus();
1120
1132
  if (!gateStatus.baseline_found) {
1121
1133
  log("Map gate: no baseline evidence for experiential task " + taskId + " — bouncing to Capture");
@@ -1283,6 +1295,10 @@ while (i < STEPS.length) {
1283
1295
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1284
1296
  "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)\n" +
1285
1297
  "VERDICT: PASS\n\n" +
1298
+ "If the script's output contains PUBLISH_SKIPPED=no-npm-publish, the publish was skipped gracefully: npm publish is not configured on this machine (helper or credential absent) — the merge stands, the version was not cut, nothing was shipped. Paste the marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1299
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1300
+ "skipped: no-npm-publish (npm publish not configured — helper or credential missing; nothing versioned or published)\n" +
1301
+ "VERDICT: PASS\n\n" +
1286
1302
  "If it exits zero, paste the script's COMPLETE marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
1287
1303
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1288
1304
  "published: muse-crew@" + publishTarget.target + "\n" +
@@ -1377,9 +1393,17 @@ while (i < STEPS.length) {
1377
1393
  } else if (!/^[0-9a-f]{40}$/.test(publishBase)) {
1378
1394
  return await parkTask("Publish base '" + publishBase + "' is not a valid commit SHA — cannot compute the publish diff. Human attention needed.");
1379
1395
  }
1396
+ // The empty tree is not a commit: git merge-base --is-ancestor fails on it.
1397
+ // The workflow knows publishBase == EMPTY_TREE_SHA (set above), so it
1398
+ // hardcodes ANCESTOR=yes for a first publish instead of asking the agent
1399
+ // to execute the conditional (clean-room 2026-09-16: the agent ran
1400
+ // merge-base on the empty tree directly and parked).
1401
+ var ancestorShell = (publishBase === EMPTY_TREE_SHA)
1402
+ ? "ANCESTOR=yes && "
1403
+ : "git merge-base --is-ancestor \"$BASE\" \"$HEAD\" && ANCESTOR=yes || ANCESTOR=no && ";
1380
1404
  var diffResult = await agent(
1381
1405
  "Run: cd " + REPO_PATH + " && BASE='" + publishBase + "' && HEAD=$(git rev-parse HEAD) && " +
1382
- "if [ \"$BASE\" = '" + EMPTY_TREE_SHA + "' ]; then ANCESTOR=yes; else git merge-base --is-ancestor \"$BASE\" \"$HEAD\" && ANCESTOR=yes || ANCESTOR=no; fi && " +
1406
+ ancestorShell +
1383
1407
  "echo '---COMMIT---' && echo \"$HEAD\" && echo '---BASE---' && echo \"$BASE\" && echo '---ANCESTOR---' && echo \"$ANCESTOR\" && " +
1384
1408
  "if [ \"$ANCESTOR\" = yes ]; then echo '---DIFF---' && git diff \"$BASE\" \"$HEAD\" && echo '---NAMES---' && git diff-tree --no-commit-id --name-only -r \"$BASE\" \"$HEAD\"; fi\n" +
1385
1409
  "Return JSON { \"commit\": \"<HEAD trimmed>\", \"base\": \"<BASE trimmed>\", \"ancestor\": \"<yes|no>\", \"diff\": \"<raw unified diff, may be multi-line>\", \"files\": \"<newline-separated paths>\" } and nothing else.",
@@ -2526,16 +2550,24 @@ while (i < STEPS.length) {
2526
2550
  // Skip-aware (park 2026-09-11): when the deterministic publish script found
2527
2551
  // no merge lock held (empty-diff Integrate), it skips the publish path
2528
2552
  // gracefully and emits the machine-readable PUBLISH_SKIPPED=no-lock-held
2529
- // marker. Verification is then vacuous — nothing was shipped, and the
2553
+ // marker. The preflight (bugfix 2026-09-17) emits
2554
+ // PUBLISH_SKIPPED=no-npm-publish when npm publish is not configured on
2555
+ // this machine (helper or credential absent) — also before any mutation.
2556
+ // Verification is then vacuous — nothing was shipped, and the
2530
2557
  // registry must NOT have moved. The marker is script-emitted explicit state
2531
2558
  // (pasted verbatim per the Publish agent instructions), not agent prose; a
2532
2559
  // report without the marker still runs the full verification fail-closed.
2533
2560
  var publishVerified = false;
2534
2561
  var npmPublishSkipped = false;
2535
2562
  if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
2536
- if (/^PUBLISH_SKIPPED=no-lock-held$/m.test(workerText || "")) {
2563
+ var publishSkipMatch = /^PUBLISH_SKIPPED=(no-lock-held|no-npm-publish)$/m.exec(workerText || "");
2564
+ if (publishSkipMatch) {
2537
2565
  npmPublishSkipped = true;
2538
- log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): publish verification vacuous, nothing was shipped");
2566
+ if (publishSkipMatch[1] === "no-npm-publish") {
2567
+ log("Publish skipped for task " + taskId + " (no-npm-publish — npm publish not configured): publish verification vacuous, nothing was shipped");
2568
+ } else {
2569
+ log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): publish verification vacuous, nothing was shipped");
2570
+ }
2539
2571
  }
2540
2572
  if (!npmPublishSkipped) {
2541
2573
  try {