muse-crew 0.14.2 → 0.14.4

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.
Files changed (46) hide show
  1. package/AGENTS.md +1 -1
  2. package/docs/decisions/AGENTS.md +4 -0
  3. package/docs/decisions/publish-path.md +229 -0
  4. package/docs/publish-verification.md +14 -3
  5. package/docs/release-integrity.md +60 -0
  6. package/docs/reviews/critic-0143-redo.md +94 -0
  7. package/docs/reviews/critic-0143.md +108 -0
  8. package/docs/reviews/critic-0144.md +83 -0
  9. package/lib/AGENTS.md +14 -6
  10. package/lib/advance-publish-base.js +8 -0
  11. package/lib/append-ooda-step.js +12 -3
  12. package/lib/build-readback-request.js +10 -0
  13. package/lib/build-registry.js +8 -3
  14. package/lib/classify-publish-absence.js +11 -0
  15. package/lib/classify-surface.js +12 -2
  16. package/lib/commit-scaffold.js +22 -6
  17. package/lib/compose-evidence-caption.js +15 -4
  18. package/lib/compute-publish-diff.js +9 -0
  19. package/lib/crew-api.js +55 -20
  20. package/lib/crew-release.sh +233 -1
  21. package/lib/gitignore.js +23 -5
  22. package/lib/package.json +1 -0
  23. package/lib/publish-note-vocabulary.js +44 -0
  24. package/lib/publish-npm.sh +39 -10
  25. package/lib/read-ooda-verdict.js +11 -3
  26. package/lib/readback-disk.js +9 -0
  27. package/lib/render-html.js +17 -7
  28. package/lib/repo-orchestration.js +21 -5
  29. package/lib/retry-publish.js +79 -44
  30. package/lib/sample-project.js +22 -6
  31. package/lib/scaffold-crew.js +11 -2
  32. package/lib/see-act.js +17 -8
  33. package/lib/serve-artifact.js +12 -6
  34. package/lib/setup-project-repo.js +26 -7
  35. package/lib/test-detached-integrate.sh +79 -3
  36. package/lib/test-publish-preflight.sh +94 -5
  37. package/lib/update-watch.js +34 -17
  38. package/lib/ux-doctrine.js +31 -6
  39. package/lib/verify-publish.js +41 -6
  40. package/lib/worktree-lifecycle.sh +67 -25
  41. package/lib/write-ooda-verdict.js +12 -3
  42. package/package.json +1 -1
  43. package/workflows/bugfix.js +60 -61
  44. package/workflows/chore.js +60 -61
  45. package/workflows/standard.js +60 -42
  46. package/workflows/upgrade.js +4 -2
@@ -22,9 +22,11 @@
22
22
  // every path except a missing --crew-home (exit 2).
23
23
  "use strict";
24
24
 
25
- const fs = require("fs");
26
- const path = require("path");
27
- const { execFileSync } = require("child_process");
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { execFileSync } from "node:child_process";
28
+ import { realpathSync } from "node:fs";
29
+ import { fileURLToPath } from "node:url";
28
30
 
29
31
  const STATE_FILE = ".update-watch.json";
30
32
  const LOG_FILE = "update-watch.log";
@@ -653,6 +655,11 @@ function buildRealDeps(crewHome) {
653
655
 
654
656
  function main() {
655
657
  const args = process.argv.slice(2);
658
+ // --help: before required-arg parsing (entry-gate contract).
659
+ if (args.includes("--help")) {
660
+ console.log(USAGE);
661
+ process.exit(0);
662
+ }
656
663
  let crewHome = null;
657
664
  let enrollSha = null;
658
665
  let enrollForce = false;
@@ -693,19 +700,29 @@ function main() {
693
700
  );
694
701
  }
695
702
 
696
- module.exports = {
697
- parseSemver: parseSemver,
698
- compareSemver: compareSemver,
699
- channelAllows: channelAllows,
700
- crewDecision: crewDecision,
701
- dashboardDecision: dashboardDecision,
702
- crewVersionOrdering: crewVersionOrdering,
703
- fileCrewUpgrade: fileCrewUpgrade,
704
- enrollDashboardBase: enrollDashboardBase,
705
- readState: readState,
706
- writeState: writeState,
707
- appendLog: appendLog,
708
- run: run
703
+ export {
704
+ parseSemver,
705
+ compareSemver,
706
+ channelAllows,
707
+ crewDecision,
708
+ dashboardDecision,
709
+ crewVersionOrdering,
710
+ fileCrewUpgrade,
711
+ enrollDashboardBase,
712
+ readState,
713
+ writeState,
714
+ appendLog,
715
+ run
709
716
  };
710
717
 
711
- if (require.main === module) main();
718
+ const USAGE = "usage: node update-watch.js --crew-home <path> [--record-dashboard-sha <sha> [--force]]";
719
+
720
+ const isMainModule = (() => {
721
+ try {
722
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
723
+ } catch {
724
+ return false;
725
+ }
726
+ })();
727
+
728
+ if (isMainModule) main();
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // ux-doctrine.js — UX-surface doctrine page resolution.
2
3
  //
3
4
  // One canonical doctrine page per environment_type value: the crew reads
@@ -11,10 +12,16 @@
11
12
  // against this file, so the two can never silently diverge.
12
13
  //
13
14
  // Pure and deterministic: no clock, no randomness, no I/O.
14
- "use strict";
15
+ //
16
+ // Module shape (blocker 21): this file is an import-safe shared module —
17
+ // it has NO shebang, so the entry gate bare-loads it (`node ux-doctrine.js`
18
+ // exits 0). The CLI below runs only when the file itself is the process
19
+ // entrypoint.
15
20
 
16
- const { existsSync } = require("node:fs");
17
- const { join } = require("node:path");
21
+ import { existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { realpathSync } from "node:fs";
24
+ import { fileURLToPath } from "node:url";
18
25
 
19
26
  // THE MAP. environment_type value -> doctrine page filename, relative to
20
27
  // the release docs dir ($CREW_HOME/current/docs/). null is unclassified —
@@ -44,12 +51,30 @@ function doctrinePageExists(crewHome, environmentType) {
44
51
  return p ? existsSync(p) : false;
45
52
  }
46
53
 
47
- module.exports = { DOCTRINE_PAGES, doctrinePage, doctrinePath, doctrinePageExists };
54
+ export { DOCTRINE_PAGES, doctrinePage, doctrinePath, doctrinePageExists };
55
+
56
+ // realpath: production spawns through $CREW_HOME/current/lib (a symlink),
57
+ // so argv[1] must be resolved before comparison with import.meta.url.
58
+ const isMainModule = (() => {
59
+ try {
60
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
61
+ } catch {
62
+ return false;
63
+ }
64
+ })();
48
65
 
49
- if (require.main === module) {
66
+ const USAGE = "usage: node ux-doctrine.js --page <environment_type|null> | --path <crewHome> <environment_type|null>";
67
+
68
+ if (isMainModule) {
50
69
  // CLI: node ux-doctrine.js --page <environment_type>
51
70
  // node ux-doctrine.js --path <crewHome> <environment_type>
52
71
  const args = process.argv.slice(2);
72
+ // Import-safe module contract: a bare invocation (no args) is a load
73
+ // check, not a usage error — it exits 0. --help always exits 0.
74
+ if (args.length === 0 || args.includes("--help")) {
75
+ console.log(USAGE);
76
+ process.exit(0);
77
+ }
53
78
  if (args[0] === "--page" && args.length === 2) {
54
79
  const page = doctrinePage(args[1] === "null" ? null : args[1]);
55
80
  console.log(page === null ? "null" : page);
@@ -57,7 +82,7 @@ if (require.main === module) {
57
82
  const p = doctrinePath(args[1], args[2] === "null" ? null : args[2]);
58
83
  console.log(p === null ? "null" : p);
59
84
  } else {
60
- console.error("usage: node ux-doctrine.js --page <environment_type|null> | --path <crewHome> <environment_type|null>");
85
+ console.error(USAGE);
61
86
  process.exit(2);
62
87
  }
63
88
  }
@@ -51,6 +51,19 @@ import {
51
51
  makeOldCounter,
52
52
  discriminatingLines,
53
53
  } from "./publish-content.js";
54
+ // Terminal-note vocabulary (D7, 2026-09-19): the writer-side guard.
55
+ // terminal() asserts its emitted verb is a registry member before writing,
56
+ // so no future terminal verb ships unrecognized by the scans.
57
+ import { matchTerminalPublishNote } from "./publish-note-vocabulary.js";
58
+
59
+ // --help: before required-arg parsing (shebang⇔CLI contract).
60
+ if (process.argv.slice(2).includes("--help")) {
61
+ console.log(
62
+ "usage: node verify-publish.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> --project-id <id> [--spaces-root <dir>]\n" +
63
+ "the mechanical publish verification JUDGE: certifies read-back findings against git diff, checks manifest freshness, stamps provenance"
64
+ );
65
+ process.exit(0);
66
+ }
54
67
 
55
68
  function arg(name, required = true) {
56
69
  const i = process.argv.indexOf(name);
@@ -89,10 +102,25 @@ function api(command, json) {
89
102
  });
90
103
  return JSON.parse(out);
91
104
  }
105
+ // The terminal verdict this writer emits. A single const (not an inline
106
+ // literal) so the emitted verb and the asserted verb can never drift apart.
107
+ const TERMINAL_VERB = "publish: verification-failed";
92
108
  function terminal(reason, detail) {
93
109
  // Always lands a terminal parent verdict, then exits 1 (failed) — the
94
110
  // task stays parked for human attention. Only the verified path exits 0.
95
- const msg = `publish: verification-failed ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
111
+ // Writer-side registry guard (D7): the verb is in the terminal-note
112
+ // registry before the note is written — a writer can never introduce a
113
+ // terminal verb the scans don't recognize.
114
+ if (!matchTerminalPublishNote(TERMINAL_VERB)) {
115
+ // Registry and writer disagree — fail loud, never emit an unrecognized
116
+ // terminal note.
117
+ process.stderr.write(JSON.stringify({
118
+ ok: false, verdict: "vocabulary-mismatch",
119
+ message: `terminal verb '${TERMINAL_VERB}' is not in the terminal-note registry`,
120
+ }) + "\n");
121
+ process.exit(2);
122
+ }
123
+ const msg = `${TERMINAL_VERB} ${commit} ${reason}${detail ? ` — ${detail}` : ""} (read-back ${inspectionId})`;
96
124
  api("log-event", { task_id: taskId, type: "note", message: msg.slice(0, 1000) });
97
125
  process.stderr.write(JSON.stringify({ ok: false, verdict: reason, message: msg }) + "\n");
98
126
  process.exit(1);
@@ -298,8 +326,17 @@ if (head !== commit) {
298
326
  // 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
299
327
  // attempt is known — the original and retry attempts share a commit, and
300
328
  // positional selection is luck. Within one attempt, select the OLDEST
301
- // submitted entry (the trigger issuance; per blocker 2, audit observations
302
- // are now "build-observed", not "submitted").
329
+ // submitted entry (the trigger issuance; a receipt-bearing second "submitted"
330
+ // may follow it — the oldest binds the trigger instant, the latest carries
331
+ // the observed agent_id).
332
+ // D1 (2026-09-19; entry_kind cut 2026-09-20): the trigger instant is the
333
+ // ISSUANCE instant, not the ledger-write instant — a slow ledger write must
334
+ // not make a fresh build look stale (room #23 J1). The anchor is the OLDEST
335
+ // submitted entry by ts and the bound instant is its issued_at (falling back
336
+ // to its ts when the capture was unobserved). One anchor, one field — the
337
+ // retired entry_kind preference filter could never change the bound anchor
338
+ // (issuance and receipt entries carried the same captured issued_at), so the
339
+ // oldest-by-ts selection is the whole mechanism.
303
340
  let triggerTs = null;
304
341
  let manifestBefore = null;
305
342
  try {
@@ -313,11 +350,9 @@ try {
313
350
  (attempt == null || e.attempt === attempt)
314
351
  );
315
352
  if (candidates.length > 0) {
316
- // Oldest first: the trigger issuance is the first submitted entry for
317
- // the attempt (observations are "build-observed" per blocker 2).
318
353
  candidates.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
319
354
  const e = candidates[0];
320
- triggerTs = e.ts || null;
355
+ triggerTs = e.issued_at || e.ts || null;
321
356
  if (e.manifest_before && typeof e.manifest_before === "object") manifestBefore = e.manifest_before;
322
357
  }
323
358
  } catch (e) {
@@ -150,6 +150,24 @@ integration_target() {
150
150
  git rev-parse --abbrev-ref HEAD 2>/dev/null || { echo "ERROR: cannot resolve HEAD"; return 1; }
151
151
  }
152
152
 
153
+ # Resolve the remote branch a detached HEAD pushes to: the remote's default
154
+ # branch (origin/HEAD). Prints the bare branch name (no origin/ prefix).
155
+ # Attached checkouts never need this (their target IS the branch name).
156
+ # Fails when the destination is genuinely unknowable -- the caller fails
157
+ # closed (never a silent skip, never a refusal of a knowable push).
158
+ # (2026-09-19 detached-HEAD audit REDO: the old code treated detached+remote
159
+ # as PUSH_SKIPPED->PASS "by policy", hiding merged work that never reached
160
+ # the remote. The contract is explicit HEAD:<destination> semantics.)
161
+ resolve_push_destination() {
162
+ cd "$REPO"
163
+ local sym
164
+ sym=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) || {
165
+ echo "ERROR: detached HEAD with an origin remote, but origin/HEAD is not set -- the push destination is genuinely unknowable; set it (git remote set-head origin -a) and retry -- not pushing"
166
+ return 1
167
+ }
168
+ printf '%s\n' "${sym#origin/}"
169
+ }
170
+
153
171
  # (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
154
172
  # zero call sites and cmd_verify_merge reimplemented tip resolution inline
155
173
  # with different semantics, a drift hazard. Tip resolution lives in exactly
@@ -323,8 +341,15 @@ acquire_merge_lock() {
323
341
  # refresh needed) and by cmd_push_target for the R5 manual-conflict path
324
342
  # (which refreshes the lock first). Never force-pushes.
325
343
  #
326
- # The caller must hold the merge lock. Prints one of PUSHED / PUSH_SKIPPED /
327
- # NO_REMOTE_PUSH and returns 0, or prints an ERROR line and returns 1.
344
+ # The caller must hold the merge lock. Prints one of PUSHED /
345
+ # NO_REMOTE_PUSH and returns 0, or prints PUSH_FAILED / CONFLICT / ERROR
346
+ # and returns 1 as labeled. (PUSH_SKIPPED belongs to cmd_push_target's
347
+ # nothing-merged path only -- F-R3.) The push
348
+ # refspec is always explicit: a branch pushes as <branch>, a detached HEAD
349
+ # pushes as HEAD:<destination> where the destination is the remote's
350
+ # default branch resolved by resolve_push_destination() -- never a silent
351
+ # skip (2026-09-19 detached-HEAD audit REDO: PUSH_SKIPPED->PASS on
352
+ # detached+remote hid merged work that never reached the remote).
328
353
  do_push() {
329
354
  local task_id="$1"
330
355
  cd "$REPO"
@@ -332,17 +357,6 @@ do_push() {
332
357
  local target
333
358
  target=$(integration_target) || return 1
334
359
 
335
- # Detached HEAD: by design the integration line is local to this checkout
336
- # (clean-room staging pins the reviewed commit; no remote ref is claimed
337
- # for the detached line), so there is nothing to push. This is a policy
338
- # statement, not a git limitation — `git push origin HEAD:<ref>` would
339
- # work; we choose not to push the detached line. Room evidence is
340
- # preserved in room state; releases ship through the loop's Publish phase.
341
- if [ "$target" = "HEAD" ]; then
342
- echo "PUSH_SKIPPED: detached HEAD -- the integration line is local by design; nothing to push"
343
- return 0
344
- fi
345
-
346
360
  # No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
347
361
  # code ran `git push origin` unconditionally, which exits 128 with no
348
362
  # origin, got mislabeled PUSH_REJECTED, and failed the phase — while
@@ -354,8 +368,20 @@ do_push() {
354
368
  return 0
355
369
  fi
356
370
 
357
- if git push origin "$target" 2>&1; then
358
- echo "PUSHED: origin/$target"
371
+ # Attached: branch name; detached: remote default branch via resolve_push_destination() (see header).
372
+ local dest refspec
373
+ if [ "$target" = "HEAD" ]; then
374
+ # Surface the resolver's reason: the caller cannot remediate a swallowed
375
+ # failure, and the retry-promise below must not lie about it (F-R1).
376
+ dest=$(resolve_push_destination) || { echo "$dest"; return 1; }
377
+ refspec="HEAD:$dest"
378
+ else
379
+ dest="$target"
380
+ refspec="$target"
381
+ fi
382
+
383
+ if git push origin "$refspec" 2>&1; then
384
+ echo "PUSHED: origin/$dest (refspec $refspec)"
359
385
  return 0
360
386
  fi
361
387
 
@@ -363,17 +389,17 @@ do_push() {
363
389
  # "non-fast-forward" — auth errors got the same label). Recover
364
390
  # mechanically while the lock is held: fetch, merge, retry once.
365
391
  echo "PUSH_FAILED: push did not complete -- reconciling once while the lock is held"
366
- if ! git fetch origin "$target" 2>&1; then
367
- echo "ERROR: git fetch origin $target failed -- push not completed"
392
+ if ! git fetch origin "$dest" 2>&1; then
393
+ echo "ERROR: git fetch origin $dest failed -- push not completed"
368
394
  return 1
369
395
  fi
370
- if ! git merge --no-edit "origin/$target" -m "merge: push-time reconcile ($task_id)" 2>&1; then
371
- echo "CONFLICT: origin/$target diverged with conflicting changes -- aborting"
396
+ if ! git merge --no-edit "origin/$dest" -m "merge: push-time reconcile ($task_id)" 2>&1; then
397
+ echo "CONFLICT: origin/$dest diverged with conflicting changes -- aborting"
372
398
  git merge --abort 2>/dev/null || true
373
399
  return 1
374
400
  fi
375
- if git push origin "$target" 2>&1; then
376
- echo "PUSHED: origin/$target (after reconcile)"
401
+ if git push origin "$refspec" 2>&1; then
402
+ echo "PUSHED: origin/$dest (refspec $refspec, after reconcile)"
377
403
  return 0
378
404
  fi
379
405
  echo "ERROR: push failed again after reconcile -- not pushing"
@@ -473,10 +499,12 @@ cmd_integrate() {
473
499
  # the Integrate phase fails closed. Fetch and merge the remote branch now
474
500
  # so the subsequent push is a fast-forward.
475
501
  # Fail-soft when there is no remote; fail closed on a real conflict.
476
- # A detached HEAD is local by design (no remote ref is claimed for the
477
- # detached line), so there is nothing to reconcile.
502
+ # Detached: there is no static branch target to reconcile against inline;
503
+ # do_push resolves the destination explicitly and reconciles on push
504
+ # failure, so the inline reconcile is skipped here (F-R2 -- the old
505
+ # "local by design" rationale is the rejected 0.14.2 policy).
478
506
  if [ "$target" = "HEAD" ]; then
479
- echo "NO_REMOTE_RECONCILE: detached HEAD -- the integration line is local by design; skipping origin reconcile"
507
+ echo "NO_REMOTE_RECONCILE: detached HEAD -- destination resolved explicitly by do_push, skipping inline reconcile"
480
508
  elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
481
509
  if git fetch origin "$target" 2>&1; then
482
510
  if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
@@ -512,7 +540,7 @@ cmd_integrate() {
512
540
  # agent run added only a lock refresh). R5's manual-conflict path uses the
513
541
  # push-target command instead.
514
542
  if ! do_push "$task_id"; then
515
- echo "ERROR: push failed after merge $merged_commit -- merge is recorded; retry will recover the push"
543
+ echo "ERROR: push failed after merge $merged_commit -- merge is recorded; a re-run of push-target retries transient failures, but an unknowable destination needs 'git remote set-head origin -a' (see the ERROR line above -- F-R1)"
516
544
  return 1
517
545
  fi
518
546
 
@@ -834,6 +862,19 @@ cmd_integration_target() {
834
862
  integration_target
835
863
  }
836
864
 
865
+ # Agent-side one-liner: prints the remote branch a push updates. Attached:
866
+ # the branch name. Detached: the resolved destination from
867
+ # resolve_push_destination() (fails when genuinely unknowable).
868
+ cmd_push_destination() {
869
+ local target
870
+ target=$(integration_target) || exit 1
871
+ if [ "$target" = "HEAD" ]; then
872
+ resolve_push_destination
873
+ else
874
+ printf '%s\n' "$target"
875
+ fi
876
+ }
877
+
837
878
  # Deterministic push of the integration target. Thin wrapper over do_push
838
879
  # for the R5 manual-conflict path (re-running `integrate` there misfires on
839
880
  # ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
@@ -888,6 +929,7 @@ case "$cmd" in
888
929
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
889
930
  verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
890
931
  integration-target) cmd_integration_target ;;
932
+ push-destination) cmd_push_destination ;;
891
933
  push-target) cmd_push_target "${1:?task_id required}" ;;
892
934
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
893
935
  terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // write-ooda-verdict.js — deterministic writer for the OODA report's terminal state.
2
3
  //
3
4
  // After the see-act loop, the QA/repro agent records its verdict as
@@ -34,10 +35,9 @@
34
35
  //
35
36
  // Exit 0 on success, 2 on bad input. Determinism: no wall-clock reads, no
36
37
  // randomness; ts comes only from --ts and is omitted when not passed.
37
- "use strict";
38
38
 
39
- const { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } = require("node:fs");
40
- const { join, resolve } = require("node:path");
39
+ import { writeFileSync, appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
40
+ import { join, resolve } from "node:path";
41
41
 
42
42
  const VERDICTS = { PASS: 1, FAIL: 1, NOT_POSSIBLE: 1 };
43
43
 
@@ -85,6 +85,15 @@ function ledgerSeq(ledgerPath) {
85
85
  }
86
86
 
87
87
  function main() {
88
+ // --help: before required-arg parsing (shebang⇔CLI contract).
89
+ if (process.argv.slice(2).includes("--help")) {
90
+ console.log(
91
+ "usage: node write-ooda-verdict.js --dir <phase-dir> --attempt <id> --verdict <PASS|FAIL|NOT_POSSIBLE>\n" +
92
+ " [--summary <text>] [--expected <text>] [--actual <text>] [--missing <json-array>] [--reason <text>] [--ts <iso>]\n" +
93
+ "--reason is REQUIRED and must be non-empty for FAIL and NOT_POSSIBLE"
94
+ );
95
+ process.exit(0);
96
+ }
88
97
  const args = parseArgs(process.argv.slice(2));
89
98
  if (!args.dir) fail("missing --dir <phase-dir>");
90
99
  if (args.attempt === undefined || String(args.attempt).trim() === "") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -70,8 +70,10 @@ const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
70
70
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
71
71
  const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
72
72
  const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
73
+ const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
74
+ const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
73
75
  // See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
74
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
76
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
75
77
 
76
78
  // Project config — passed by dispatcher, falls back to dashboard defaults
77
79
  const projectConfig = inputs.project_config || {};
@@ -191,19 +193,12 @@ async function reaskVerdict(stepName, reworkSuffix, workerText) {
191
193
  return verdict;
192
194
  }
193
195
  // Transport retry: the work-agent agent() call can throw even when the agent
194
- // did the work. Stochastic envelope non-compliance (bare prose instead of
195
- // the native {"status":"ok","result":"..."} envelope) trips the runtime's
196
- // JSON-candidate heuristic when the prose contains a {...}-looking
197
- // substring — canary 39457ee9's QA report quoted the change's own
198
- // {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
199
- // and the workflow discarded a complete VERDICT: PASS report as "no output".
200
- // The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
201
- // this covers the report never arriving. The assignment is retried boundedly
202
- // with fresh keys (never a cached replay) before failing closed. Re-entry is
203
- // safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
204
- // retry trailer tells the agent to check existing state first and report
205
- // rather than duplicate completed side effects, and the rework path already
206
- // re-runs Build after rejection — Build re-entry is an established pattern.
196
+ // did the work — the runtime's JSON-candidate heuristic trips on brace-shaped
197
+ // prose. Retry the assignment boundedly with fresh keys (never a cached
198
+ // replay) before failing closed. Re-entry is safe: lifecycle scripts answer
199
+ // REUSED for existing worktrees/branches, the retry trailer tells the agent
200
+ // to check existing state first, and Build re-entry after rejection is an
201
+ // established pattern. Full history: docs/decisions/workflow-core.md.
207
202
  function workRetryKey(stepName, reworkSuffix, attempt) {
208
203
  return "work-" + stepName + reworkSuffix + "-t" + attempt;
209
204
  }
@@ -215,7 +210,7 @@ function attemptKey(base, reworkCount) {
215
210
  function pinLifecycle(key) {
216
211
  return agent(
217
212
  "Snapshot lifecycle scripts for version pinning.\n" +
218
- "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 + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
213
+ "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 + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
219
214
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
220
215
  { key: key, label: "Pinning lifecycle scripts",
221
216
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -396,27 +391,11 @@ function hydrateReleaseDecision(rec) {
396
391
  return null;
397
392
  }
398
393
 
399
- // Publish read-back request (currently unavailable): the verbatim_request
400
- // the parent protocol (docs/publish-verification.md) would hand to an
401
- // independent read-back tool after the artifact build lands. artifact_inspect
402
- // was removed by the platform (2026-09-14); artifact.inspect is malfunction
403
- // diagnosis, not a substitute — so no agent-callable read-back tool exists
404
- // and this request cannot currently be issued. Pure function — no I/O, no
405
- // clock. The request carries the merged diff as the expected change and asks
406
- // for an independent read of the artifact's actual source: for each file, the
407
- // exact current text of the changed regions plus a per-line present/absent
408
- // finding. Until a read-back path exists, the parent cannot independently
409
- // confirm content and verification parks at "publish: verification-requested"
410
- // (see docs/publish-verification.md). This preserves the circularity break
411
- // that hollowed canary run 8 (2026-09-11): the old verifyAppliedChanges
412
- // compared the builder's applied-report against the diff the report was
413
- // derived from — a fabricated report passed by construction. The report
414
- // itself is gone now (2026-09-16 fire-and-forget trigger). Independent
415
- // read-back cannot be
416
- // fabricated from the diff; it must match the artifact's real content.
417
-
418
394
  // See docs/decisions/publish-path.md#publish-attempt-ledger: every trigger outcome is recorded in the durable ledger.
419
- async function recordPublishLedger(entry, rework) {
395
+ // keyTag (optional): same-outcome ledger writes in one pass need distinct
396
+ // agent-call keys — the runtime replays cached calls by key, so a shared key
397
+ // silently drops the second write (2026-09-19, submitted-on-issuance).
398
+ async function recordPublishLedger(entry, rework, keyTag) {
420
399
  try {
421
400
  var ledgerDir = crewHome + "/.publish-ledger";
422
401
  var line = JSON.stringify({
@@ -430,7 +409,10 @@ async function recordPublishLedger(entry, rework) {
430
409
  applied_report: entry.applied_report || null,
431
410
  manifest_before: entry.manifest_before || null,
432
411
  outcome: entry.outcome,
433
- detail: entry.detail || ""
412
+ detail: entry.detail || "",
413
+ // D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
414
+ // instant (null when no trigger); ts = ledger-write instant.
415
+ issued_at: entry.issued_at || null,
434
416
  });
435
417
  var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
436
418
  var res = await agent(
@@ -438,7 +420,7 @@ async function recordPublishLedger(entry, rework) {
438
420
  "Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
439
421
  " | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
440
422
  "Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
441
- { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
423
+ { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome + (keyTag ? "-" + keyTag : ""), rework),
442
424
  label: "Recording publish attempt in ledger",
443
425
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
444
426
  );
@@ -1289,7 +1271,7 @@ while (i < STEPS.length) {
1289
1271
  "The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
1290
1272
  "- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
1291
1273
  "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1292
- "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash, VERDICT: PASS; PUSH_SKIPPED — the detached line is room-local (the merge commit + record are the evidence), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1274
+ "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1293
1275
  "- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
1294
1276
  "- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
1295
1277
  "RESOLUTION:\n" +
@@ -1302,7 +1284,7 @@ while (i < STEPS.length) {
1302
1284
  "ESCALATE — 'conflict needs human resolution', VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
1303
1285
  "- ERROR: report it. VERDICT: FAIL.\n\n" +
1304
1286
  "R5-PUSH (manual R5 resolution only — the normal path pushed inline). Run: " + LIFECYCLE_ENV + LIFECYCLE + " push-target " + taskId + "\n" +
1305
- "PUSHED — report the merged hash, VERDICT: PASS; PUSH_SKIPPED or NO_REMOTE_PUSH — nothing to push, VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1287
+ "PUSHED — report the merged hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; PUSH_SKIPPED — no push attempted (no record + no lock); NO_REMOTE_PUSH — no remote; VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1306
1288
  "NEVER force-push.\n\n" +
1307
1289
  "Report what happened at each step, ending with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1308
1290
 
@@ -1640,7 +1622,7 @@ while (i < STEPS.length) {
1640
1622
  log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1641
1623
  }
1642
1624
  // See docs/decisions/publish-path.md#trigger-await: the artifact_edit call is awaited.
1643
- var rebuildTrigger = null;
1625
+ var rebuildTrigger = null, triggerThrew = false;
1644
1626
  try {
1645
1627
  var triggerText = String(await agent(rebuildPrompt,
1646
1628
  { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
@@ -1660,7 +1642,36 @@ while (i < STEPS.length) {
1660
1642
  }
1661
1643
  } catch (triggerErr) {
1662
1644
  log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
1645
+ triggerThrew = true;
1663
1646
  }
1647
+ // D1 (2026-09-19; F-A1 upper-bound 2026-09-20): issued_at is an UPPER
1648
+ // bound on issuance — captured AFTER the trigger-agent call returns,
1649
+ // ferried from one shell date call (workflows have no clock) and
1650
+ // validated mechanically; null when unobserved (fallback: ts).
1651
+ // See docs/decisions/publish-path.md#d1-issuance-time.
1652
+ var triggerIssuedAt = null;
1653
+ try {
1654
+ var issuedAtFerry = await agent(
1655
+ "Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
1656
+ "Return JSON { \"ts\": \"<the date output, verbatim>\" } and nothing else.",
1657
+ { key: attemptKey("publish-issued-at-" + taskId, totalReworkCount), label: "Capturing post-trigger instant (upper bound on issuance)",
1658
+ schema: { type: "object", properties: { ts: { type: "string" } }, required: ["ts"] } }
1659
+ );
1660
+ var issuedAtRaw = String((issuedAtFerry && issuedAtFerry.ts) || "").trim();
1661
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(issuedAtRaw)) triggerIssuedAt = issuedAtRaw;
1662
+ } catch (issuedAtErr) { /* issued_at stays null: the verifier falls back to ts */ }
1663
+ if (!triggerIssuedAt) log("Publish issuance instant unobserved for task " + taskId + " — issued_at null; the freshness gate falls back to ledger-write ts");
1664
+ // (2026-09-19, room #22) Submitted-on-issuance: see docs/decisions/publish-path.md#submitted-on-issuance.
1665
+ await recordPublishLedger({
1666
+ commit: mergeCommitForPublish,
1667
+ attempt: rebuildAttemptKey,
1668
+ agent_id: null,
1669
+ applied_report: publishAppliedObservation,
1670
+ manifest_before: preTriggerManifest,
1671
+ outcome: "submitted",
1672
+ issued_at: triggerIssuedAt,
1673
+ detail: "trigger issuance: rebuild-trigger agent invoked; receipt/observation pending. Invocation only - not evidence of artifact_edit or build acceptance." + (triggerThrew ? "; trigger-threw" : "; trigger-returned")
1674
+ }, totalReworkCount, "issuance");
1664
1675
  // Post-trigger observation (primary, not fallback): the workflow
1665
1676
  // attributes the edit itself. First the in-flight build state — a
1666
1677
  // build whose agent_id is new relative to the pre-trigger baseline
@@ -1780,8 +1791,9 @@ while (i < STEPS.length) {
1780
1791
  applied_report: publishAppliedObservation,
1781
1792
  manifest_before: preTriggerManifest,
1782
1793
  outcome: "submitted",
1783
- detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff)"
1784
- }, totalReworkCount);
1794
+ issued_at: triggerIssuedAt,
1795
+ detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff); observation, not re-issuance"
1796
+ }, totalReworkCount, "receipt");
1785
1797
  } else {
1786
1798
  var newAuditDirs = [];
1787
1799
  try {
@@ -1912,10 +1924,6 @@ while (i < STEPS.length) {
1912
1924
  // its outcome — submitted, rejected, or unknown (unknown is recorded
1913
1925
  // at the park site above). A later run or human matches commit hash +
1914
1926
  // attempt key against the builder's eventual completion.
1915
- // (2026-09-16) The trigger is fire-and-forget: the observation above
1916
- // already recorded the ledger's submitted line on both positive paths
1917
- // and parked on unknown — there is no applied report to observe and
1918
- // no rejection signal to record.
1919
1927
  // (2026-09-18, H2 verdict-first) The verdict was decided exactly
1920
1928
  // once above; dispatch on it. landed bypasses the receipt poll
1921
1929
  // (the audit evidence already proved completion); an explicit
@@ -2049,21 +2057,12 @@ while (i < STEPS.length) {
2049
2057
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
2050
2058
  } else {
2051
2059
  // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
2052
- // the poll above only observes IN-FLIGHT builds. A build that
2053
- // finished between the receipt capture and the poll's first check
2054
- // leaves no in-flight trace — but the platform's audit harness
2055
- // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
2056
- // <timestamp>-<id>/ per completed build). Diff the audit-dir
2057
- // listing against the pre-trigger snapshot: a timestamped dir
2058
- // that appeared during the attempt window is evidence a build
2059
- // completed. Attribution is by window, not by build identity:
2060
- // the poll's saw_stranger signal only catches stranger builds in
2061
- // flight AT a check — a stranger that finished entirely inside
2062
- // the window is indistinguishable, so any observed stranger
2063
- // blocks attribution and the outcome stays unknown. This never
2064
- // re-issues the edit and never stamps provenance — ok=true only
2065
- // routes to the parent's independent content read-back, which
2066
- // remains the real verification.
2060
+ // the poll observes only in-flight builds; a build that finished
2061
+ // inside the window leaves a durable audit dir. Diff the audit
2062
+ // listing against the pre-trigger snapshot: a new timestamped dir
2063
+ // is evidence a build completed. Attribution is by window, not by build identity: any
2064
+ // observed stranger blocks it. Never re-issues, never stamps;
2065
+ // ok=true routes to the parent's read-back (the verification).
2067
2066
  //
2068
2067
  // The poll end-state is read from the poll's own observations,
2069
2068
  // not from build_done alone: a build in flight at the last check