muse-crew 0.8.0 → 0.9.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.
package/lib/crew-api.js CHANGED
@@ -54,6 +54,16 @@ function validateVisualProtocol(value) {
54
54
  throw usageError("visual_protocol must be true, false, or null.");
55
55
  }
56
56
 
57
+ // environment_type validation: 'artifact' | 'terminal' | null. undefined
58
+ // means "not provided" (create: defaults to null; update: leaves the field
59
+ // alone). null is the explicit clear operation — it returns the project to
60
+ // unclassified (no experiential QA). Anything else is a usage error.
61
+ function validateEnvironmentType(value) {
62
+ if (value === undefined || value === null) return null;
63
+ if (value === "artifact" || value === "terminal") return value;
64
+ throw usageError("environment_type must be 'artifact', 'terminal', or null.");
65
+ }
66
+
57
67
  // ---------------------------------------------------------------------------
58
68
  // Crew home resolution (fail closed)
59
69
  // ---------------------------------------------------------------------------
@@ -102,11 +112,11 @@ function openDb(crewHome) {
102
112
  if (!/duplicate column name/i.test(e.message)) throw e;
103
113
  }
104
114
  }
115
+ const projectCols = db.prepare("PRAGMA table_info(projects)").all();
105
116
  // Migration: add visual_protocol column if missing (per-project visual
106
117
  // protocol toggle, 2026-09-12). Same idempotent PRAGMA-check pattern;
107
118
  // ADD COLUMN without a default leaves existing rows NULL, which is the
108
119
  // inherit state by design (no backfill step).
109
- const projectCols = db.prepare("PRAGMA table_info(projects)").all();
110
120
  if (!projectCols.some((c) => c.name === "visual_protocol")) {
111
121
  try {
112
122
  db.exec("ALTER TABLE projects ADD COLUMN visual_protocol INTEGER CHECK (visual_protocol IN (0, 1))");
@@ -115,6 +125,19 @@ function openDb(crewHome) {
115
125
  if (!/duplicate column name/i.test(e.message)) throw e;
116
126
  }
117
127
  }
128
+ // Migration: add environment_type column if missing (per-project
129
+ // user-facing surface for experiential QA routing, 2026-09-17). Same
130
+ // idempotent PRAGMA-check pattern; ADD COLUMN without a default leaves
131
+ // existing rows NULL, which is the unclassified state by design
132
+ // (no backfill step).
133
+ if (!projectCols.some((c) => c.name === "environment_type")) {
134
+ try {
135
+ db.exec("ALTER TABLE projects ADD COLUMN environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal'))");
136
+ } catch (e) {
137
+ // Another process may have added it concurrently; ignore duplicate-column errors.
138
+ if (!/duplicate column name/i.test(e.message)) throw e;
139
+ }
140
+ }
118
141
  }
119
142
  return db;
120
143
  }
@@ -152,6 +175,9 @@ function mapProject(row) {
152
175
  quiesced: !!row.quiesced,
153
176
  // Tri-state: null (inherit) stays null, never coerced to false.
154
177
  visual_protocol: row.visual_protocol == null ? null : !!row.visual_protocol,
178
+ // environment_type: 'artifact' | 'terminal' | null (unclassified).
179
+ // Null stays null — no coercion, no default.
180
+ environment_type: row.environment_type == null ? null : row.environment_type,
155
181
  created_at: row.created_at,
156
182
  updated_at: row.updated_at,
157
183
  };
@@ -1599,6 +1625,7 @@ commands["create-project"] = (db, args, ctx) => {
1599
1625
  }
1600
1626
  const timestamp = now();
1601
1627
  const visualProtocol = validateVisualProtocol(args.visual_protocol);
1628
+ const environmentType = validateEnvironmentType(args.environment_type);
1602
1629
  const row = {
1603
1630
  id, display_name: displayName, repo_path: repoPath, deploy_type: deployType,
1604
1631
  deploy_slug: args.deploy_slug ?? null,
@@ -1606,6 +1633,7 @@ commands["create-project"] = (db, args, ctx) => {
1606
1633
  simultaneity: args.simultaneity ?? 2,
1607
1634
  quiesced: args.quiesced ? 1 : 0,
1608
1635
  visual_protocol: visualProtocol == null ? null : (visualProtocol ? 1 : 0),
1636
+ environment_type: environmentType,
1609
1637
  created_at: timestamp, updated_at: timestamp,
1610
1638
  };
1611
1639
  if (!Number.isInteger(row.simultaneity) || row.simultaneity < 1 || row.simultaneity > 100) {
@@ -1613,9 +1641,9 @@ commands["create-project"] = (db, args, ctx) => {
1613
1641
  }
1614
1642
  db.prepare(
1615
1643
  `INSERT INTO projects (id, display_name, repo_path, deploy_type, deploy_slug,
1616
- description, simultaneity, quiesced, visual_protocol, created_at, updated_at)
1644
+ description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
1617
1645
  VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
1618
- @description, @simultaneity, @quiesced, @visual_protocol, @created_at, @updated_at)`).run(row);
1646
+ @description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
1619
1647
  const initialProvenance = tryStampInitialProvenance(db, ctx.crewHome, repoPath, deployType);
1620
1648
  return { project: mapProject(row), initial_provenance: initialProvenance };
1621
1649
  };
@@ -1654,13 +1682,16 @@ commands["update-project"] = (db, args) => {
1654
1682
  patch.simultaneity = args.simultaneity;
1655
1683
  }
1656
1684
  if (args.quiesced !== undefined) patch.quiesced = args.quiesced ? 1 : 0;
1657
- // NOT covered by the context-change guard below: visual_protocol is read
1658
- // once from launch args at dispatch time, so a mid-run change only affects
1659
- // future launches.
1685
+ // NOT covered by the context-change guard below: visual_protocol and
1686
+ // environment_type are read once from launch args at dispatch time, so a
1687
+ // mid-run change only affects future launches.
1660
1688
  if (args.visual_protocol !== undefined) {
1661
1689
  const vp = validateVisualProtocol(args.visual_protocol);
1662
1690
  patch.visual_protocol = vp == null ? null : (vp ? 1 : 0);
1663
1691
  }
1692
+ if (args.environment_type !== undefined) {
1693
+ patch.environment_type = validateEnvironmentType(args.environment_type);
1694
+ }
1664
1695
 
1665
1696
  const contextChanged =
1666
1697
  (args.repo_path !== undefined && args.repo_path !== current.repo_path) ||
@@ -8,7 +8,7 @@
8
8
  #
9
9
  # Layout:
10
10
  # ~/.jarvis/
11
- # releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/ + seed/workflows/ docs)
11
+ # releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/ + seed/workflows/ + docs/)
12
12
  # current -> releases/<hash> # active release
13
13
  # workflows -> current/workflows # convenience (cron/dispatcher reads this)
14
14
  # lib -> current/lib # convenience
@@ -231,12 +231,18 @@ cmd_deploy() {
231
231
  # Extract from committed tree (not working directory)
232
232
  _seed=""
233
233
  git cat-file -e "HEAD:seed/workflows" 2>/dev/null && _seed="seed/workflows" || true
234
+ # docs/ ships the shared crew references the workflow prompts read at
235
+ # runtime ($CREW_HOME/current/docs/terminal-ux.md) — the prompts are
236
+ # not the mechanism; the file they point at must exist in the release.
237
+ _docs=""
238
+ git cat-file -e "HEAD:docs" 2>/dev/null && _docs="docs" || true
234
239
  # shellcheck disable=SC2086
235
- git archive HEAD -- workflows lib $_seed | tar -xC "$staging_dir"
240
+ git archive HEAD -- workflows lib $_seed $_docs | tar -xC "$staging_dir"
236
241
  else
237
242
  # Plain directory (npm install): copy directly
238
243
  [ -d workflows ] && cp -r workflows "$staging_dir/"
239
244
  [ -d lib ] && cp -r lib "$staging_dir/"
245
+ [ -d docs ] && cp -r docs "$staging_dir/"
240
246
  if [ -d seed/workflows ]; then
241
247
  mkdir -p "$staging_dir/seed"
242
248
  cp -r seed/workflows "$staging_dir/seed/"
@@ -212,7 +212,13 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
212
212
 
213
213
  # 11. Publish. "previously published versions" means a retried Publish
214
214
  # already landed this version (the merge lock guarantees no other task
215
- # picked it) — continue. Any other failure is fatal.
215
+ # picked it) — continue. A "staged version" 409 means npm accepted the
216
+ # publish but staged it: the PUT returned 2xx yet the version is
217
+ # invisible in the packument until it finalizes (~7 min observed
218
+ # 2026-09-17). Never re-PUT a staged version — the registry rejects it
219
+ # and the re-PUT proves nothing. Poll the registry at step 12 until it
220
+ # finalizes or the budget exhausts (fail closed). Any other failure is
221
+ # fatal.
216
222
  if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
217
223
  echo "PUBLISHED=$TARGET_VERSION"
218
224
  else
@@ -220,6 +226,10 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
220
226
  *"previously published versions"*)
221
227
  echo "PUBLISHED_ALREADY=$TARGET_VERSION"
222
228
  ;;
229
+ *"staged version"*)
230
+ echo "PUBLISHED_STAGED=$TARGET_VERSION"
231
+ echo "publish staged by registry — polling for visibility, not re-PUTting"
232
+ ;;
223
233
  *)
224
234
  rm -f "$TGZ"
225
235
  fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
@@ -231,12 +241,20 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
231
241
  # 12. Verify: ground truth is the registry, not any agent's summary.
232
242
  # The registry is eventually consistent: a publish followed by an
233
243
  # immediate read can observe the pre-publish version (canary 5a027278
234
- # hit a stale read replica ~6s after publish). A single stale read must
235
- # never park a landed publish, so this block retries with backoff and
236
- # client cache-busting instead of failing on the first mismatch.
244
+ # hit a stale read replica ~6s after publish). Worse, npm may STAGE a
245
+ # publish: the PUT returns 2xx but the version stays invisible in the
246
+ # packument until it finalizes (~7 min observed 2026-09-17) — a
247
+ # re-PUT then 409s as "previously staged". A short budget would park
248
+ # a landed publish, so the default budget covers staged finalization
249
+ # with headroom (30 x 30s = 15 min, still bounded). A single stale
250
+ # read must never park a landed publish, so this block retries with
251
+ # backoff and client cache-busting instead of failing on the first
252
+ # mismatch. Budget exhaustion fails closed: a publish that never
253
+ # becomes visible is not claimed — a human with 2FA investigates
254
+ # (e.g. `npm stage approve`) and the version is never re-PUT blind.
237
255
  # STEP-12-ANCHOR: publish verification (retry-tolerant)
238
- VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
239
- VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
256
+ VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-30}"
257
+ VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-30}"
240
258
  REG=""
241
259
  for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
242
260
  # --prefer-online busts npm's client-side packument cache; the retry
@@ -13,8 +13,9 @@
13
13
  // Reads <phase-dir>/verdict.json and prints exactly one JSON line to stdout.
14
14
  //
15
15
  // Exit 0 with {ok:true, verdict, reason, summary, expected, actual, attempt,
16
- // visual_loop_unavailable} when the record exists, parses, has a verdict
17
- // field, the verdict equals --expect, and a FAIL carries a non-empty reason.
16
+ // visual_loop_unavailable, terminal_loop_unavailable} when the record exists,
17
+ // parses, has a verdict field, the verdict equals --expect, and a FAIL
18
+ // carries a non-empty reason.
18
19
  //
19
20
  // visual_loop_unavailable (2026-09-16): a PASS verdict with missing
20
21
  // experiential evidence must never be terminal — the QA closeout parks
@@ -29,6 +30,16 @@
29
30
  // (playwright, see-act, not possible, not installed, unavailable,
30
31
  // could not run/drive/launch, no browser).
31
32
  //
33
+ // terminal_loop_unavailable (2026-09-17): the terminal-surface counterpart.
34
+ // A PASS with a terminal loop that never ran (the CLI would not execute)
35
+ // must also park instead of stamping done. Same shape, terminal signals:
36
+ // (1) the OODA log has a terminal-action step with exit 3 and NOT
37
+ // POSSIBLE in the observation — the terminal QA contract for
38
+ // "the CLI will not run";
39
+ // (2) verdict.json's missing_evidence names CLI tool-unavailability
40
+ // (command not found, not recognized, no runtime, not installed,
41
+ // not possible, unavailable, could not run/execute/launch).
42
+ //
32
43
  // Exit 2 with {ok:false, code, error} when:
33
44
  // missing — verdict.json is absent (the agent never wrote one)
34
45
  // corrupt — unparseable JSON, or parsed but no verdict field
@@ -103,6 +114,7 @@ function main() {
103
114
  actual: record.actual === undefined ? "" : record.actual,
104
115
  attempt: record.attempt === undefined ? "" : record.attempt,
105
116
  visual_loop_unavailable: visualLoopUnavailable(dir, record),
117
+ terminal_loop_unavailable: terminalLoopUnavailable(dir, record),
106
118
  }) + "\n");
107
119
  }
108
120
 
@@ -152,4 +164,50 @@ function visualLoopUnavailable(dir, record) {
152
164
  return oodaLogUnavailable(dir) || missingEvidenceUnavailable(record);
153
165
  }
154
166
 
167
+ // Terminal-loop availability: true when the experiential terminal loop
168
+ // could not run. Signal 1 — a terminal-action OODA step exited 3 with NOT
169
+ // POSSIBLE in its observation (the terminal QA contract for "the CLI will
170
+ // not run"). Signal 2 — the verdict's own missing_evidence names CLI
171
+ // tool-unavailability. Same string-match shape as the visual version, never
172
+ // a judgment about report prose.
173
+ const TERMINAL_ACTIONS = { terminal: true };
174
+ const TERMINAL_TOOL_UNAVAILABLE = /(command not found|not recognized|no runtime|not possible|not installed|unavailable|could not (run|execute|launch)|no cli)/i;
175
+
176
+ function oodaLogTerminalUnavailable(dir) {
177
+ let text;
178
+ try {
179
+ text = readFileSync(join(dir, "ooda-log.jsonl"), "utf8");
180
+ } catch (e) {
181
+ return false;
182
+ }
183
+ const lines = text.split("\n");
184
+ for (let k = 0; k < lines.length; k++) {
185
+ const line = lines[k].trim();
186
+ if (!line) continue;
187
+ let step;
188
+ try {
189
+ step = JSON.parse(line);
190
+ } catch (e) {
191
+ continue;
192
+ }
193
+ if (!step || !TERMINAL_ACTIONS[step.action]) continue;
194
+ if (step.exit === 3 && /not possible/i.test(String(step.observation || ""))) return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ function missingEvidenceTerminalUnavailable(record) {
200
+ let me = record.missing_evidence;
201
+ if (me === undefined) me = record.missing;
202
+ if (!Array.isArray(me)) return false;
203
+ for (let k = 0; k < me.length; k++) {
204
+ if (TERMINAL_TOOL_UNAVAILABLE.test(String(me[k]))) return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ function terminalLoopUnavailable(dir, record) {
210
+ return oodaLogTerminalUnavailable(dir) || missingEvidenceTerminalUnavailable(record);
211
+ }
212
+
155
213
  main();
package/lib/schema.sql CHANGED
@@ -25,6 +25,15 @@ CREATE TABLE IF NOT EXISTS projects (
25
25
  simultaneity INTEGER NOT NULL DEFAULT 2 CHECK (simultaneity >= 0 AND simultaneity <= 100),
26
26
  quiesced INTEGER NOT NULL DEFAULT 0 CHECK (quiesced IN (0, 1)),
27
27
  visual_protocol INTEGER CHECK (visual_protocol IN (0, 1)),
28
+ -- environment_type: the project's user-facing surface for experiential QA.
29
+ -- 'artifact' = a rendered web UI served from a built artifact (Hazel drives
30
+ -- it with the see-act browser loop). 'terminal' = a command-line interface
31
+ -- (Hazel drives it herself and keeps attempt-scoped transcripts). NULL =
32
+ -- unclassified: no experiential QA — the same as today's non-artifact
33
+ -- behavior. Nullable by design: existing rows stay NULL (no backfill), and
34
+ -- deploy_type is NOT reused for this (it names the deployment target, not
35
+ -- the UX surface a user experiences).
36
+ environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal')),
28
37
  created_at TEXT NOT NULL,
29
38
  updated_at TEXT NOT NULL
30
39
  );
@@ -453,6 +453,20 @@ async function dashboardCheck(deps, ctx, summary) {
453
453
  "\n" +
454
454
  "Automatic dashboard upgrade: " + oldSha7 + " -> " + sha.slice(0, 7) + ".\n" +
455
455
  "Filed by the update watcher (policy auto_update_dashboard).\n" +
456
+ // Enrollment guidance: when the watcher never recorded a trusted base,
457
+ // the old sha is "unknown" and the ancestor check below parks
458
+ // fail-closed by design. A human establishes the base once with the
459
+ // enroll command, then re-queues this task.
460
+ (!recorded
461
+ ? "\nENROLLMENT: this crew has no trusted dashboard base — the watcher\n" +
462
+ "was never told which dashboard commit the live artifact was deployed\n" +
463
+ "from (fresh watcher state, or state reset). The ancestor check below\n" +
464
+ "cannot run against \"unknown\", so park this task and say so.\n" +
465
+ "A human establishes the base once, then re-queues:\n" +
466
+ " node " + crewHome + "/current/lib/update-watch.js --crew-home " + crewHome + " --record-dashboard-sha <sha>\n" +
467
+ "The sha must be the commit the live artifact was actually published\n" +
468
+ "from — verify it exists in " + proj.repo_path + ", never guess.\n"
469
+ : "") +
456
470
  "\n" +
457
471
  "Journey (execute mechanically, no judgment):\n" +
458
472
  "1. In " + proj.repo_path + ": git fetch origin && git rev-parse origin/HEAD — must\n" +
@@ -487,6 +501,47 @@ async function dashboardCheck(deps, ctx, summary) {
487
501
  }
488
502
  }
489
503
 
504
+ // ── Dashboard enrollment ─────────────────────────────────────────────
505
+ // Records the trusted dashboard base sha in the watcher state. This is the
506
+ // enrollment operation: a human (or crew-init on first install) declares
507
+ // "this crew's dashboard artifact was deployed from this commit". The
508
+ // watcher files dashboard upgrades as <recorded> -> <remote>; without a
509
+ // recorded base the old sha is "unknown" and the filed task parks
510
+ // fail-closed by design. Moving an existing base is a human decision —
511
+ // pass { force: true }.
512
+ // Returns { ok, projectId, sha, action: "enrolled" | "moved" | "already" }.
513
+ async function enrollDashboardBase(deps, sha, opts) {
514
+ opts = opts || {};
515
+ const crewHome = deps.crewHome;
516
+ if (!/^[0-9a-f]{40}$/i.test(sha || "")) {
517
+ throw new Error("--record-dashboard-sha needs a 40-hex commit sha");
518
+ }
519
+ const projects = await deps.listProjects();
520
+ const proj = (projects || []).find(function (p) {
521
+ return p && p.deploy_type === "artifact" && p.repo_path;
522
+ });
523
+ if (!proj) throw new Error("no artifact project registered — cannot enroll a dashboard base");
524
+ const projectId = proj.id || proj.project_id;
525
+ try {
526
+ await deps.gitCatFile(proj.repo_path, sha);
527
+ } catch (e) {
528
+ throw new Error("sha " + sha.slice(0, 7) + " not found in " + proj.repo_path + " — verify the commit the live artifact was deployed from, never guess");
529
+ }
530
+ const state = readState(crewHome);
531
+ state.dashboard = state.dashboard || {};
532
+ const existing = state.dashboard[projectId] && state.dashboard[projectId].last_filed_sha;
533
+ if (existing && existing.toLowerCase() === sha.toLowerCase()) {
534
+ return { ok: true, projectId: projectId, sha: existing, action: "already" };
535
+ }
536
+ if (existing && !opts.force) {
537
+ throw new Error("dashboard base already enrolled as " + existing.slice(0, 7) + " — pass --force to move it (a human decision)");
538
+ }
539
+ state.dashboard[projectId] = { last_filed_sha: sha.toLowerCase() };
540
+ writeState(crewHome, state);
541
+ appendLog(crewHome, "enroll", "dashboard base " + (existing ? "moved" : "enrolled") + ": " + projectId + " @ " + sha.slice(0, 7));
542
+ return { ok: true, projectId: projectId, sha: sha.toLowerCase(), action: existing ? "moved" : "enrolled" };
543
+ }
544
+
490
545
  // ── Pipeline ─────────────────────────────────────────────────────────
491
546
 
492
547
  async function run(deps) {
@@ -585,6 +640,11 @@ function buildRealDeps(crewHome) {
585
640
  // constant ".crew-version".
586
641
  return shOut("git", ["-C", repoPath, "show", sha + ":" + filePath]).trim();
587
642
  },
643
+ gitCatFile: function (repoPath, sha) {
644
+ // Throws when the object is missing or not a commit; the enrollment
645
+ // command fails closed on it.
646
+ return shOut("git", ["-C", repoPath, "cat-file", "-e", sha + "^{commit}"]).trim();
647
+ },
588
648
  createTask: function (args) { return apiCall(crewHome, "create-task", args).task; }
589
649
  };
590
650
  }
@@ -594,13 +654,30 @@ function buildRealDeps(crewHome) {
594
654
  function main() {
595
655
  const args = process.argv.slice(2);
596
656
  let crewHome = null;
657
+ let enrollSha = null;
658
+ let enrollForce = false;
597
659
  for (let i = 0; i < args.length; i++) {
598
660
  if (args[i] === "--crew-home" && i + 1 < args.length) crewHome = args[i + 1];
661
+ if (args[i] === "--record-dashboard-sha" && i + 1 < args.length) enrollSha = args[i + 1];
662
+ if (args[i] === "--force") enrollForce = true;
599
663
  }
600
664
  if (!crewHome) {
601
- process.stderr.write("usage: node update-watch.js --crew-home <path>\n");
665
+ process.stderr.write("usage: node update-watch.js --crew-home <path> [--record-dashboard-sha <sha> [--force]]\n");
602
666
  process.exit(2);
603
667
  }
668
+ if (enrollSha) {
669
+ enrollDashboardBase(buildRealDeps(crewHome), enrollSha, { force: enrollForce }).then(
670
+ function (res) {
671
+ process.stdout.write("update-watch: dashboard base " + res.action + ": " + res.projectId + " @ " + res.sha.slice(0, 7) + "\n");
672
+ process.exit(0);
673
+ },
674
+ function (e) {
675
+ process.stderr.write("update-watch: enrollment failed (" + firstLine(e) + ")\n");
676
+ process.exit(1);
677
+ }
678
+ );
679
+ return;
680
+ }
604
681
  run(buildRealDeps(crewHome)).then(
605
682
  function (res) {
606
683
  res.summary.forEach(function (line) { process.stdout.write(line + "\n"); });
@@ -624,6 +701,7 @@ module.exports = {
624
701
  dashboardDecision: dashboardDecision,
625
702
  crewVersionOrdering: crewVersionOrdering,
626
703
  fileCrewUpgrade: fileCrewUpgrade,
704
+ enrollDashboardBase: enrollDashboardBase,
627
705
  readState: readState,
628
706
  writeState: writeState,
629
707
  appendLog: appendLog,
@@ -0,0 +1,63 @@
1
+ // ux-doctrine.js — UX-surface doctrine page resolution.
2
+ //
3
+ // One canonical doctrine page per environment_type value: the crew reads
4
+ // the same page per surface, resolved mechanically from project config —
5
+ // never from prose, never hardcoded per-prompt. Adding a surface is one
6
+ // line in DOCTRINE_PAGES plus one docs page; the workflows resolve the
7
+ // page from environment_type and hand the path to every phase prompt.
8
+ //
9
+ // Workflows mirror this map inline (one line) because the workflow
10
+ // runtime's relative-import support is unverified — tests pin the mirror
11
+ // against this file, so the two can never silently diverge.
12
+ //
13
+ // Pure and deterministic: no clock, no randomness, no I/O.
14
+ "use strict";
15
+
16
+ const { existsSync } = require("node:fs");
17
+ const { join } = require("node:path");
18
+
19
+ // THE MAP. environment_type value -> doctrine page filename, relative to
20
+ // the release docs dir ($CREW_HOME/current/docs/). null is unclassified —
21
+ // it maps to no page, never to a guess.
22
+ const DOCTRINE_PAGES = {
23
+ artifact: "artifact-ux.md",
24
+ terminal: "terminal-ux.md",
25
+ };
26
+
27
+ function doctrinePage(environmentType) {
28
+ if (environmentType == null) return null;
29
+ return Object.prototype.hasOwnProperty.call(DOCTRINE_PAGES, environmentType)
30
+ ? DOCTRINE_PAGES[environmentType]
31
+ : null;
32
+ }
33
+
34
+ // Absolute path of the doctrine page for a crew home, or null when the
35
+ // surface is unclassified/unknown. Does not check the file exists —
36
+ // callers that need existence use doctrinePageExists.
37
+ function doctrinePath(crewHome, environmentType) {
38
+ const page = doctrinePage(environmentType);
39
+ return page ? join(crewHome, "current", "docs", page) : null;
40
+ }
41
+
42
+ function doctrinePageExists(crewHome, environmentType) {
43
+ const p = doctrinePath(crewHome, environmentType);
44
+ return p ? existsSync(p) : false;
45
+ }
46
+
47
+ module.exports = { DOCTRINE_PAGES, doctrinePage, doctrinePath, doctrinePageExists };
48
+
49
+ if (require.main === module) {
50
+ // CLI: node ux-doctrine.js --page <environment_type>
51
+ // node ux-doctrine.js --path <crewHome> <environment_type>
52
+ const args = process.argv.slice(2);
53
+ if (args[0] === "--page" && args.length === 2) {
54
+ const page = doctrinePage(args[1] === "null" ? null : args[1]);
55
+ console.log(page === null ? "null" : page);
56
+ } else if (args[0] === "--path" && args.length === 3) {
57
+ const p = doctrinePath(args[1], args[2] === "null" ? null : args[2]);
58
+ console.log(p === null ? "null" : p);
59
+ } else {
60
+ console.error("usage: node ux-doctrine.js --page <environment_type|null> | --path <crewHome> <environment_type|null>");
61
+ process.exit(2);
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,