muse-crew 0.7.19 → 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.
@@ -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
  };