muse-crew 0.14.3 → 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 (40) hide show
  1. package/AGENTS.md +1 -1
  2. package/docs/decisions/AGENTS.md +1 -0
  3. package/docs/decisions/publish-path.md +43 -3
  4. package/docs/publish-verification.md +9 -1
  5. package/docs/release-integrity.md +60 -0
  6. package/docs/reviews/critic-0144.md +83 -0
  7. package/lib/AGENTS.md +10 -3
  8. package/lib/advance-publish-base.js +8 -0
  9. package/lib/append-ooda-step.js +12 -3
  10. package/lib/build-readback-request.js +10 -0
  11. package/lib/build-registry.js +8 -3
  12. package/lib/classify-publish-absence.js +11 -0
  13. package/lib/classify-surface.js +12 -2
  14. package/lib/commit-scaffold.js +22 -6
  15. package/lib/compose-evidence-caption.js +15 -4
  16. package/lib/compute-publish-diff.js +9 -0
  17. package/lib/crew-api.js +54 -21
  18. package/lib/crew-release.sh +233 -1
  19. package/lib/gitignore.js +23 -5
  20. package/lib/package.json +1 -0
  21. package/lib/publish-note-vocabulary.js +44 -0
  22. package/lib/read-ooda-verdict.js +11 -3
  23. package/lib/readback-disk.js +9 -0
  24. package/lib/render-html.js +17 -7
  25. package/lib/repo-orchestration.js +21 -5
  26. package/lib/retry-publish.js +77 -45
  27. package/lib/sample-project.js +22 -6
  28. package/lib/scaffold-crew.js +11 -2
  29. package/lib/see-act.js +17 -8
  30. package/lib/serve-artifact.js +12 -6
  31. package/lib/setup-project-repo.js +26 -7
  32. package/lib/update-watch.js +34 -17
  33. package/lib/ux-doctrine.js +31 -6
  34. package/lib/verify-publish.js +38 -2
  35. package/lib/write-ooda-verdict.js +12 -3
  36. package/package.json +1 -1
  37. package/workflows/bugfix.js +33 -35
  38. package/workflows/chore.js +33 -35
  39. package/workflows/standard.js +33 -16
  40. package/workflows/upgrade.js +4 -2
package/lib/crew-api.js CHANGED
@@ -30,6 +30,13 @@ import { execFileSync } from "node:child_process";
30
30
  import { DatabaseSync } from "node:sqlite";
31
31
  import { homedir } from "node:os";
32
32
  import { classifySurface } from "./classify-surface.js";
33
+ // Terminal-note vocabulary (D7, 2026-09-19): the scan's terminal branch
34
+ // consults this closed registry instead of an inline pattern list, so a
35
+ // recognized terminal note is skipped as terminal with its meaning —
36
+ // never as unrecognized. Pin-adjacent: every runtime-relative dependency
37
+ // of crew-api.js is pinned in the workflows' pinLifecycle (PIN_BASENAMES),
38
+ // mechanically verified by tests/pin-closure.test.js.
39
+ import { matchTerminalPublishNote, TERMINAL_NOTE_MEANINGS } from "./publish-note-vocabulary.js";
33
40
 
34
41
  // ---------------------------------------------------------------------------
35
42
  // Errors
@@ -1721,22 +1728,34 @@ function latestPublishNote(db, taskId) {
1721
1728
  ).get(taskId) || null;
1722
1729
  }
1723
1730
 
1724
- // The trigger anchor for an unknown attempt: the OLDEST "submitted" ledger
1725
- // entry for the same attempt (the trigger issuance). Oldest binds the trigger
1726
- // instant — verify-publish.js and the decision doc bind oldest; binding newest
1727
- // misclassifies builds that complete between issuance and receipt (A2/R2/O4).
1728
- // Returns the entry or null. A null means the trigger instant is unknowable
1729
- // (or the trigger was never issued) — classification cannot run; the task
1730
- // stays parked for human attention. Fail closed, never guess the anchor.
1731
+ // The trigger anchor for an unknown attempt: the issuance-kind "submitted"
1732
+ // ledger entry for the same attempt (the trigger issuance), falling back to
1733
+ // the oldest by ts for pre-D1 ledgers. The issuance entry binds the trigger
1734
+ // instant — verify-publish.js and the decision doc prefer it the same way;
1735
+ // binding newest misclassifies builds that complete between issuance and
1736
+ // receipt — binding newest misclassifies builds that complete between
1737
+ // issuance and receipt (see docs/reviews/critic-0143.md findings A2/R2/O4).
1738
+ // Returns the entry or null. A null means the trigger
1739
+ // instant is unknowable (or the trigger was never issued) — classification
1740
+ // cannot run; the task stays parked for human attention. Fail closed, never
1741
+ // guess the anchor.
1731
1742
  function findTriggerEntry(entries, commit, attempt) {
1743
+ // D1 (2026-09-19; entry_kind cut 2026-09-20): the anchor is the OLDEST
1744
+ // submitted entry by ts and the bound instant is its issued_at || ts —
1745
+ // the same fallback verify-publish.js uses. The retired entry_kind
1746
+ // preference filter could never change the bound anchor (issuance and
1747
+ // receipt entries carried the same captured issued_at), so oldest-by-ts
1748
+ // is the whole mechanism.
1749
+ const matches = [];
1732
1750
  for (let i = 0; i < entries.length; i++) {
1733
1751
  const e = entries[i];
1734
1752
  if (e.outcome === "submitted" && e.commit === commit &&
1735
1753
  (e.attempt || null) === (attempt || null)) {
1736
- return e;
1754
+ matches.push(e);
1737
1755
  }
1738
1756
  }
1739
- return null;
1757
+ matches.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
1758
+ return matches[0] || null;
1740
1759
  }
1741
1760
 
1742
1761
  // The unknown ledger entry's detail says whether the trigger went out.
@@ -1887,10 +1906,9 @@ commands["resolve-publish-unknown"] = (db, args, ctx) => {
1887
1906
  // "publish: retry-issued" (not-before = intended+20m); NEVER re-trigger here
1888
1907
  // "publish: retry-issued" w/o mirrored → mirror the missing publish: verification-requested
1889
1908
  // verification-requested
1890
- // "publish: ambiguous …" / "publish: → terminal; ignored
1891
- // retry-superseded" / "publish:
1892
- // retry-refused" / "publish: verified" /
1893
- // "publish: superseded"
1909
+ // terminal notes (the closed registry in → terminal; skipped with the
1910
+ // lib/publish-note-vocabulary.js — the reason named, never
1911
+ // scan names the recognized note) "unrecognized"
1894
1912
  // "publish: verification-requested" → already routed; ignored (the verification scan owns it)
1895
1913
  //
1896
1914
  // Unknown parks whose trigger was never issued (preflight/toolcheck
@@ -1925,7 +1943,9 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1925
1943
  return;
1926
1944
  }
1927
1945
  const trigger = findTriggerEntry(derived.entries, derived.commit, derived.attempt);
1928
- if (!trigger || !trigger.ts) {
1946
+ // D1 (2026-09-19): the anchor instant is the trigger-issuance instant
1947
+ // (issued_at), not the ledger-write instant (ts) — issued_at || ts.
1948
+ if (!trigger || !(trigger.issued_at || trigger.ts)) {
1929
1949
  skip(taskId, "no-trigger-anchor",
1930
1950
  "no submitted ledger entry for this attempt — the trigger instant is unknowable; stays parked for human attention");
1931
1951
  return;
@@ -1959,7 +1979,7 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1959
1979
  }
1960
1980
  due.push({
1961
1981
  task_id: taskId, commit: derived.commit, base,
1962
- trigger_ts: trigger.ts, park_ts: parkNote.timestamp,
1982
+ trigger_ts: trigger.issued_at || trigger.ts, park_ts: parkNote.timestamp,
1963
1983
  slug: derived.slug, repo_path: derived.repoPath,
1964
1984
  project_id: derived.projectId, claim_expiry: expiry,
1965
1985
  ledger_path: derived.ledgerPath,
@@ -1989,11 +2009,13 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
1989
2009
  const has = (s) => msg.includes(s);
1990
2010
  const historyHas = (s) => notes.some((n) => n.message.includes(s));
1991
2011
 
1992
- // Terminal states — the machine never revisits these.
1993
- if (has("publish: ambiguous") || has("publish: retry-superseded") ||
1994
- has("publish: retry-refused") || has("publish: verified") ||
1995
- has("publish: superseded")) {
1996
- skip(taskId, "terminal");
2012
+ // Terminal states — the machine never revisits these. The vocabulary is
2013
+ // explicit state (lib/publish-note-vocabulary.js); a recognized terminal
2014
+ // note is skipped as terminal with its meaning, never as unrecognized.
2015
+ const terminalNote = matchTerminalPublishNote(msg);
2016
+ if (terminalNote) {
2017
+ skip(taskId, "terminal",
2018
+ `recognized terminal note '${terminalNote}' — ${TERMINAL_NOTE_MEANINGS[terminalNote]}`);
1997
2019
  continue;
1998
2020
  }
1999
2021
  // Already routed into the verification pipeline — the verification
@@ -2065,7 +2087,7 @@ commands["scan-publish-unknown"] = (db, args, ctx) => {
2065
2087
  task_id: taskId, commit: derived.commit, attempt: derived.attempt,
2066
2088
  slug: derived.slug, repo_path: derived.repoPath, project_id: derived.projectId,
2067
2089
  ledger_path: derived.ledgerPath, base: retryBase,
2068
- trigger_ts: (retryTrigger && retryTrigger.ts) || null,
2090
+ trigger_ts: (retryTrigger && (retryTrigger.issued_at || retryTrigger.ts)) || null,
2069
2091
  manifest_before: (retryTrigger && retryTrigger.manifest_before &&
2070
2092
  typeof retryTrigger.manifest_before === "object") ? retryTrigger.manifest_before : null,
2071
2093
  });
@@ -2802,6 +2824,17 @@ function main() {
2802
2824
  }
2803
2825
  }
2804
2826
 
2827
+ const CREW_API_USAGE =
2828
+ "usage: node crew-api.js --crew-home <path> <command> [--json <json>] [flags...]\n" +
2829
+ "the crew-owned task-service API (API.md): every state-machine invariant lives in the schema.\n" +
2830
+ "run with a command and no --help for command-specific usage; exit 0 ok · 2 usage · 3 not found · 4 conflict/guard";
2831
+
2832
+ // --help: before required-arg parsing (shebang⇔CLI contract).
2833
+ if (process.argv.slice(2).includes("--help")) {
2834
+ console.log(CREW_API_USAGE);
2835
+ process.exit(0);
2836
+ }
2837
+
2805
2838
  try {
2806
2839
  main();
2807
2840
  } catch (e) {
@@ -81,7 +81,8 @@ _validate_workflows() {
81
81
  # --- Parse gate (restored 2026-09-18, finding B2) ---
82
82
  # The workflow runtime accepts top-level `export`, `return`, and `await`
83
83
  # (it wraps scripts in an async function), so neither `node --check` on the
84
- # raw .js (vacuous for ESM — exits 0 even on blatant syntax errors) nor a
84
+ # raw .js (goal confusion: parsed as a module it masks breakage the
85
+ # async-wrapped loader rejects — a false negative, not a true check) nor a
85
86
  # .mjs check (rejects the runtime-legal top-level `return`) is correct.
86
87
  # Emulate the runtime instead: strip `export`, wrap the script in an async
87
88
  # function, then node --check the result. Tokenizer errors (e.g. an
@@ -210,6 +211,210 @@ _link_dep_node_modules() {
210
211
  echo "DEP-NODE-MODULES: $link -> $src"
211
212
  }
212
213
 
214
+ # ── lib entry gate (blocker 21) ─────────────────────────────────────
215
+ # Every shipped lib entry must actually EXECUTE (JS) — node --check is
216
+ # banned from this gate. The true reason, not the folklore: node --check
217
+ # can check a file under a different PARSE GOAL than the real loader uses
218
+ # (blocker 21: checked as a script, loaded as a module), and V8's
219
+ # preparser skips function bodies — a parse check proves nothing about the
220
+ # loader's path. Real execution is the only check that uses the loader's
221
+ # goal. Parse goal: whether Node reads a .js file as a module
222
+ # (import/export, no top-level return) or as a script — set by the nearest
223
+ # package.json's `type` field (shipped lib/package.json pins
224
+ # {"type":"module"}).
225
+ # What runs how:
226
+ # lib/*.js with a first line carrying a node shebang token
227
+ # (#!/usr/bin/env node, #!/bin/node, env -S variants) →
228
+ # `node <file> --help` through the $tmp/liblink symlink, which
229
+ # mirrors production's $CREW_HOME/current path shape (the
230
+ # ff3fb53 realpath main-guard exists exactly for this shape; the
231
+ # gate runs through the link so it can tell the broken guard
232
+ # from the fixed one). shebang⇔CLI contract: exit 0, non-empty
233
+ # stdout.
234
+ # lib/*.js without a shebang → bare `node <file>` through the link
235
+ # (import-safe module contract: exit 0, no side effects).
236
+ # Residual, documented: import-safety itself is unchecked — an
237
+ # import-dirty-but-exit-0 module passes. No cheap mechanism.
238
+ # lib/*.sh (except test-*.sh) → `bash -n <file>` — parse-only by
239
+ # necessity: real .sh execution risks side effects. Test scripts
240
+ # are exercised by tests/run.sh, not shipped as library entries
241
+ # (they get a `skipped` evidence row, not silence).
242
+ # lib/*.py → `python3 -m py_compile <file>` (bytecode goes to a temp
243
+ # PYTHONPYCACHEPREFIX, never into the staging dir) — parse-only
244
+ # by the same necessity.
245
+ # So the headline is honest: the gate EXECUTES every JS entry and
246
+ # PARSE-CHECKS sh/py.
247
+ # Boundary: `--help` short-circuits before argument parsing — the gate
248
+ # proves an entry LOADS, not that its main path behaves. The suite covers
249
+ # behavior. workflows/*.js are not covered by this gate at all; their true
250
+ # gate is the suite's loader emulation (publish-verdict-first.test.js —
251
+ # see root AGENTS.md).
252
+ # Every entry gets one ENTRY-GATE pass/FAIL line and one row in
253
+ # $staging_dir/entry-gate.json {release, node, entries:[{file,kind,verdict,exit,reason}]}.
254
+ # A FAIL reason folds the first 10 stderr lines in, so the row names the
255
+ # actual error instead of a bare exit code.
256
+ # On gate failure cmd_deploy copies entry-gate.json to $CREW_HOME before
257
+ # removing the staging dir, and the rejection names the failing entries —
258
+ # the evidence survives the burn.
259
+ # All verdicts are aggregated (no early stop) so the evidence names every
260
+ # failure. Returns 30 when any entry failed; any other exit is a gate
261
+ # mechanism failure (fail closed — the release is rejected either way).
262
+ _validate_lib_entries() {
263
+ local dir="$1" release="$2"
264
+ local tmp base f kind st reason verdict
265
+ tmp="$(mktemp -d)"
266
+ local rows="$tmp/rows.jsonl"
267
+ : > "$rows"
268
+ local failed=0
269
+ local node_version
270
+ node_version="$(node --version 2>/dev/null || echo "node-missing")"
271
+ # Invoke JS entries through a symlink mirroring production's
272
+ # $CREW_HOME/current path shape — the ff3fb53 realpath main-guard exists
273
+ # exactly for that shape, and the gate can only tell the broken guard
274
+ # from the fixed one when it exercises the symlinked path.
275
+ local link="$tmp/liblink"
276
+ if ! ln -s "$dir/lib" "$link" 2>/dev/null || [ ! -L "$link" ]; then
277
+ echo "ENTRY-GATE FAIL: cannot create lib invocation symlink ($link) — failing closed" >&2
278
+ return 31
279
+ fi
280
+
281
+ _gate_row() { # file kind verdict exit reason
282
+ node -e 'console.log(JSON.stringify({file:process.argv[1],kind:process.argv[2],verdict:process.argv[3],exit:+process.argv[4],reason:process.argv[5]}))' \
283
+ "$1" "$2" "$3" "$4" "$5" >> "$rows"
284
+ }
285
+
286
+ _gate_js() { # $1 = real file path; invoked through $link (the $CREW_HOME/current-shaped symlink)
287
+ local first
288
+ first="$(head -n 1 "$1")"
289
+ local cli=0
290
+ # Shebang match on a node token: #!/usr/bin/env node, #!/bin/node,
291
+ # env -S variants all classify as CLI.
292
+ case "$first" in
293
+ "#!"*"node"*) cli=1 ;;
294
+ esac
295
+ st=0
296
+ reason="ok"
297
+ local target="$link/$base"
298
+ if [ "$cli" = 1 ]; then
299
+ timeout 30 node "$target" --help >"$tmp/out" 2>"$tmp/err" || st=$?
300
+ kind="js-cli"
301
+ if [ "$st" -eq 0 ] && [ ! -s "$tmp/out" ]; then
302
+ reason="empty-stdout"
303
+ st=1
304
+ fi
305
+ else
306
+ timeout 30 node "$target" >"$tmp/out" 2>"$tmp/err" || st=$?
307
+ kind="js-module"
308
+ fi
309
+ if [ "$st" -eq 0 ]; then
310
+ reason="ok"
311
+ else
312
+ if grep -q "SyntaxError" "$tmp/err" 2>/dev/null; then
313
+ reason="syntax-error"
314
+ elif [ "$reason" != "empty-stdout" ]; then
315
+ reason="exit=$st"
316
+ fi
317
+ # Fold the first 10 stderr lines into the reason: a bare exit code
318
+ # names nothing, and the staging dir (with $tmp/err) is removed on
319
+ # failure — the row is the surviving evidence.
320
+ local errhead
321
+ errhead="$(head -n 10 "$tmp/err" 2>/dev/null | tr '\n' ';' | cut -c1-500)"
322
+ [ -n "$errhead" ] && reason="$reason | $errhead"
323
+ fi
324
+ verdict="pass"
325
+ [ "$st" -eq 0 ] || { verdict="FAIL"; failed=1; }
326
+ if [ "$verdict" = "pass" ]; then
327
+ echo "ENTRY-GATE pass $base ($kind)"
328
+ else
329
+ echo "ENTRY-GATE FAIL $base ($kind): $reason" >&2
330
+ fi
331
+ _gate_row "$base" "$kind" "$verdict" "$st" "$reason"
332
+ }
333
+
334
+ for f in "$dir"/lib/*.js; do
335
+ [ -f "$f" ] || continue
336
+ base="$(basename "$f")"
337
+ if ! command -v node >/dev/null 2>&1; then
338
+ echo "ENTRY-GATE FAIL $base (js): node missing" >&2
339
+ _gate_row "$base" "js" "FAIL" 127 "node-missing"
340
+ failed=1
341
+ continue
342
+ fi
343
+ _gate_js "$f"
344
+ done
345
+
346
+ for f in "$dir"/lib/*.sh; do
347
+ [ -f "$f" ] || continue
348
+ base="$(basename "$f")"
349
+ case "$base" in
350
+ test-*)
351
+ # Out of scope for the gate — but never silent: a skipped row, so
352
+ # "0 rows" is distinguishable from "all skipped".
353
+ _gate_row "$base" "sh" "skipped" 0 "test scripts are exercised by tests/run.sh, not shipped as library entries"
354
+ echo "ENTRY-GATE skipped $base (sh)"
355
+ continue ;;
356
+ esac
357
+ st=0
358
+ bash -n "$f" 2>"$tmp/err" || st=$?
359
+ reason="ok"; verdict="pass"
360
+ if [ "$st" -ne 0 ]; then
361
+ reason="bash-n exit=$st"
362
+ # Same evidence rule as the JS gate: the row names the actual error.
363
+ local errhead
364
+ errhead="$(head -n 10 "$tmp/err" 2>/dev/null | tr '\n' ';' | cut -c1-500)"
365
+ [ -n "$errhead" ] && reason="$reason | $errhead"
366
+ verdict="FAIL"; failed=1
367
+ echo "ENTRY-GATE FAIL $base (sh): $reason" >&2
368
+ else
369
+ echo "ENTRY-GATE pass $base (sh)"
370
+ fi
371
+ _gate_row "$base" "sh" "$verdict" "$st" "$reason"
372
+ done
373
+
374
+ for f in "$dir"/lib/*.py; do
375
+ [ -f "$f" ] || continue
376
+ base="$(basename "$f")"
377
+ st=0
378
+ if ! command -v python3 >/dev/null 2>&1; then
379
+ st=127; reason="python3-missing"
380
+ else
381
+ # Bytecode goes to a temp prefix — never into the staging dir, or it
382
+ # would be shipped inside the release.
383
+ PYTHONPYCACHEPREFIX="$tmp/pyc" python3 -m py_compile "$f" 2>"$tmp/err" || st=$?
384
+ reason="ok"
385
+ [ "$st" -eq 0 ] || reason="py-compile exit=$st"
386
+ fi
387
+ verdict="pass"
388
+ if [ "$st" -ne 0 ]; then
389
+ local errhead
390
+ errhead="$(head -n 10 "$tmp/err" 2>/dev/null | tr '\n' ';' | cut -c1-500)"
391
+ [ -n "$errhead" ] && reason="$reason | $errhead"
392
+ verdict="FAIL"; failed=1
393
+ fi
394
+ if [ "$verdict" = "pass" ]; then
395
+ echo "ENTRY-GATE pass $base (py)"
396
+ else
397
+ echo "ENTRY-GATE FAIL $base (py): $reason" >&2
398
+ fi
399
+ _gate_row "$base" "py" "$verdict" "$st" "$reason"
400
+ done
401
+
402
+ # Evidence: one row per enumerated entry.
403
+ node -e '
404
+ const fs = require("fs");
405
+ const rows = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean).map(l => JSON.parse(l));
406
+ fs.writeFileSync(process.argv[4], JSON.stringify({ release: process.argv[2], node: process.argv[3], entries: rows }, null, 2) + "\n");
407
+ ' "$rows" "$release" "$node_version" "$dir/entry-gate.json"
408
+ rm -rf "$tmp"
409
+
410
+ if [ "$failed" -ne 0 ]; then
411
+ echo "ENTRY-GATE: $failed entr(y/ies) failed — release rejected" >&2
412
+ return 30
413
+ fi
414
+ echo "ENTRY-GATE: all lib entries executed cleanly"
415
+ return 0
416
+ }
417
+
213
418
  # ── deploy ────────────────────────────────────────────────────────────
214
419
  # Build, validate, and atomically activate a release from repo HEAD.
215
420
  # Single command — no cross-step lock needed.
@@ -302,6 +507,29 @@ cmd_deploy() {
302
507
  *) die "release $hash rejected: workflow validation failed (unexpected exit $_vw_status)" ;;
303
508
  esac
304
509
  fi
510
+ # Gate: every shipped lib entry must actually execute (blocker 21).
511
+ # _validate_lib_entries returns 30 when any entry failed, anything else
512
+ # on a gate mechanism failure; either way the release is rejected. The
513
+ # rejection names the failing entries (not just the gate) and points at
514
+ # the preserved evidence — the evidence is copied to $CREW_HOME before
515
+ # the staging dir is removed, so it survives the burn.
516
+ _le_status=0
517
+ _validate_lib_entries "$staging_dir" "$hash" || _le_status=$?
518
+ if [ "$_le_status" -ne 0 ]; then
519
+ # The staging dir (and its entry-gate.json) is about to be removed —
520
+ # preserve the evidence first, then name the failing entries in the
521
+ # rejection instead of a bare exit code.
522
+ local _le_evidence="$CREW_HOME/entry-gate-$hash.json"
523
+ [ -f "$staging_dir/entry-gate.json" ] && cp "$staging_dir/entry-gate.json" "$_le_evidence"
524
+ local _le_fails="(no entry rows — gate mechanism failure)"
525
+ if [ -f "$staging_dir/entry-gate.json" ]; then
526
+ _le_fails="$(node -e 'const j=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));const f=j.entries.filter(e=>e.verdict==="FAIL").map(e=>e.file+" ("+e.kind+")");console.log(f.length?f.join(", "):"(no failing entries — gate mechanism failure)")' "$staging_dir/entry-gate.json" 2>/dev/null)"
527
+ fi
528
+ local _le_meaning="gate mechanism failure"
529
+ [ "$_le_status" = 30 ] && _le_meaning="at least one entry failed"
530
+ rm -rf "$staging_dir"
531
+ die "release $hash rejected: lib entry gate failed (exit $_le_status = $_le_meaning) — failing entries: $_le_fails; evidence: $_le_evidence"
532
+ fi
305
533
  # Atomic rename into place
306
534
  mv "$staging_dir" "$release_dir"
307
535
  echo "INSTALLED: $hash"
@@ -404,6 +632,9 @@ _prune_releases() {
404
632
  }
405
633
 
406
634
  # ── dispatch ──────────────────────────────────────────────────────────
635
+ # Sourced (never executed) by tests/entry-gate.test.js to drive
636
+ # _validate_lib_entries directly — dispatch only when run as the main script.
637
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
407
638
  case "${1:-}" in
408
639
  init) shift; cmd_init "$@" ;;
409
640
  deploy) shift; cmd_deploy "$@" ;;
@@ -412,3 +643,4 @@ case "${1:-}" in
412
643
  list) shift; cmd_list "$@" ;;
413
644
  *) die "usage: crew-release.sh {init|deploy|rollback|current|list} [args...]" ;;
414
645
  esac
646
+ fi
package/lib/gitignore.js CHANGED
@@ -16,8 +16,10 @@
16
16
  // - Never removes, reorders, or reformats existing content.
17
17
  // - Returns { created, appended[], skipped[] }.
18
18
 
19
- const fs = require("fs");
20
- const path = require("path");
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { realpathSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
21
23
 
22
24
  const CREW_GITIGNORE_ENTRIES = [".worktrees/", ".orchestration/user/"];
23
25
 
@@ -118,21 +120,37 @@ function describeGitignoreChange(repoPath, entries) {
118
120
  };
119
121
  }
120
122
 
121
- module.exports = {
123
+ export {
122
124
  CREW_GITIGNORE_ENTRIES,
123
125
  ensureGitignoreEntries,
124
126
  describeGitignoreChange,
125
127
  };
126
128
 
129
+ const USAGE = "Usage: node lib/gitignore.js --repo <path> [--dry-run]";
130
+
127
131
  // CLI: node lib/gitignore.js --repo <path> [--dry-run]
128
132
  // Used by crew-init and by tests. --dry-run prints the diff without writing.
129
- if (require.main === module) {
133
+ // --help (and the shebang⇔CLI contract): prints usage, exits 0, before
134
+ // required-arg parsing.
135
+ const isMainModule = (() => {
136
+ try {
137
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
138
+ } catch {
139
+ return false;
140
+ }
141
+ })();
142
+
143
+ if (isMainModule) {
130
144
  const argv = process.argv.slice(2);
145
+ if (argv.includes("--help")) {
146
+ console.log(USAGE);
147
+ process.exit(0);
148
+ }
131
149
  const repoIdx = argv.indexOf("--repo");
132
150
  const repoPath = repoIdx >= 0 ? argv[repoIdx + 1] : null;
133
151
  const dryRun = argv.includes("--dry-run");
134
152
  if (!repoPath) {
135
- console.error("Usage: node lib/gitignore.js --repo <path> [--dry-run]");
153
+ console.error(USAGE);
136
154
  process.exit(2);
137
155
  }
138
156
  if (dryRun) {
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,44 @@
1
+ // Terminal-note vocabulary registry (D7, 2026-09-19). Explicit state, not
2
+ // prose: every `publish: …` note the state machine treats as terminally
3
+ // parked. Readers (scan-publish-unknown in lib/crew-api.js) consult this;
4
+ // writers assert against it (lib/verify-publish.js's terminal() fails loud
5
+ // before emitting a verb that is not in the registry). Add a note here when
6
+ // a new terminal verb is introduced — the scan recognizes it with no other
7
+ // change.
8
+ //
9
+ // This module is the closed-enum pattern's first instance: the audit
10
+ // (room23-design/blanket-call-audit.md §3) lists the other state-carrying
11
+ // string families (the full `publish: <transition>` note shapes, the
12
+ // publish-ledger `outcome` vocabulary, worker-report marker lines) that a
13
+ // later unification pass extends the enum-guard pattern to. The contract
14
+ // for any new family: one registry module, readers consult it, writers
15
+ // assert membership before writing, closed by default and open by edit.
16
+ //
17
+ // ESM, no shebang, no side effects on import (import-safe module — bare
18
+ // `node publish-note-vocabulary.js` exits 0).
19
+ export const TERMINAL_PUBLISH_NOTES = [
20
+ "publish: ambiguous",
21
+ "publish: retry-superseded",
22
+ "publish: retry-refused",
23
+ "publish: verified",
24
+ "publish: superseded",
25
+ // Verification pipeline's terminal content verdict (verify-publish.js).
26
+ // The task stays parked for human attention; unknown-recovery never
27
+ // re-enters it. Added 2026-09-19 (Room #23 J1/J3: was mislogged as
28
+ // unrecognized-publish-note).
29
+ "publish: verification-failed",
30
+ ];
31
+ export const TERMINAL_NOTE_MEANINGS = {
32
+ "publish: ambiguous": "unknown-recovery terminal: could not prove the drop; parked for human attention",
33
+ "publish: retry-superseded": "retry protocol terminal: superseded; parked for human attention",
34
+ "publish: retry-refused": "retry protocol terminal: platform refused the re-issued edit; parked for human attention",
35
+ "publish: verified": "publish verified and stamped; task re-queued (terminal for the scan)",
36
+ "publish: superseded": "HEAD moved past the attempt; recovery aborted; parked for human attention",
37
+ "publish: verification-failed": "verification pipeline's terminal content verdict — parked for human attention",
38
+ };
39
+ export function matchTerminalPublishNote(message) {
40
+ for (const note of TERMINAL_PUBLISH_NOTES) {
41
+ if (message.includes(note)) return note; // same substring semantics the scan already uses
42
+ }
43
+ return null;
44
+ }
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // read-ooda-verdict.js — deterministic cross-checker for the OODA terminal
2
3
  // verdict (2026-09-15).
3
4
  //
@@ -50,10 +51,9 @@
50
51
  // Determinism: no wall-clock reads, no randomness. The script never judges
51
52
  // report content — the agent states the reason; the machine enforces its
52
53
  // presence and its agreement with the prose verdict.
53
- "use strict";
54
54
 
55
- const { readFileSync, existsSync } = require("node:fs");
56
- const { join, resolve } = require("node:path");
55
+ import { readFileSync, existsSync } from "node:fs";
56
+ import { join, resolve } from "node:path";
57
57
 
58
58
  function fail(code, error) {
59
59
  process.stdout.write(JSON.stringify({ ok: false, code: code, error: error }) + "\n");
@@ -72,6 +72,14 @@ function parseArgs(argv) {
72
72
  }
73
73
 
74
74
  function main() {
75
+ // --help: before required-arg parsing (shebang⇔CLI contract).
76
+ if (process.argv.slice(2).includes("--help")) {
77
+ console.log(
78
+ "usage: node read-ooda-verdict.js --dir <phase-dir> --expect <PASS|FAIL>\n" +
79
+ "cross-checks the prose VERDICT: line against <phase-dir>/verdict.json"
80
+ );
81
+ process.exit(0);
82
+ }
75
83
  const args = parseArgs(process.argv.slice(2));
76
84
  if (!args.dir) fail("bad_input", "missing --dir <phase-dir>");
77
85
  if (!args.expect) fail("bad_input", "missing --expect <PASS|FAIL>");
@@ -53,6 +53,15 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
53
53
  import { homedir } from "node:os";
54
54
  import { join, resolve } from "node:path";
55
55
 
56
+ // --help: before required-arg parsing (shebang⇔CLI contract).
57
+ if (process.argv.slice(2).includes("--help")) {
58
+ console.log(
59
+ "usage: node readback-disk.js --repo-path <path> --commit <sha> --base <sha> --task-id <uuid> --slug <artifact-slug> [--spaces-root <dir>]\n" +
60
+ "the deterministic disk read-back SENSOR: emits the machine-readable findings block (FILE:/ADDED:/REMOVED:/END_FILE)"
61
+ );
62
+ process.exit(0);
63
+ }
64
+
56
65
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
57
66
 
58
67
  function arg(name) {
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // render-html.js — render an HTML evidence layout to PNG via headless Chromium.
2
3
  //
3
4
  // The reef-qa pattern (2026-09-14): the agent composes evidence as HTML —
@@ -26,11 +27,13 @@
26
27
  // The rendered composition is evidence: log it with append-ooda-step.js
27
28
  // (--action compose) and READ it — a composition you did not read is not
28
29
  // evidence.
29
- "use strict";
30
30
 
31
- const { existsSync, readFileSync } = require("node:fs");
32
- const { resolve, dirname } = require("node:path");
33
- const { pathToFileURL } = require("node:url");
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import { resolve, dirname } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import { createRequire } from "node:module";
35
+
36
+ const cjsRequire = createRequire(import.meta.url);
34
37
 
35
38
  const DEFAULT_WIDTH = 1200;
36
39
 
@@ -54,16 +57,17 @@ function parseArgs(argv) {
54
57
  function loadPlaywright() {
55
58
  // playwright-core is a declared dependency (package.json), so a normal
56
59
  // npm install resolves it from the package's own node_modules.
60
+ // createRequire keeps the synchronous resolution the CLI relies on.
57
61
  try {
58
- return require("playwright-core");
62
+ return cjsRequire("playwright-core");
59
63
  } catch (e) { /* fall through */ }
60
64
  const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
61
65
  if (envDir) {
62
66
  try {
63
- return require(envDir + "/playwright-core");
67
+ return cjsRequire(envDir + "/playwright-core");
64
68
  } catch (e) { /* fall through */ }
65
69
  try {
66
- return require(envDir);
70
+ return cjsRequire(envDir);
67
71
  } catch (e) { /* fall through */ }
68
72
  }
69
73
  fail(3, {
@@ -86,6 +90,12 @@ function findChromium() {
86
90
  }
87
91
 
88
92
  async function main() {
93
+ // --help: before required-arg parsing (shebang⇔CLI contract).
94
+ if (process.argv.slice(2).includes("--help")) {
95
+ console.log("usage: node render-html.js --in <page.html> --out <shot.png> [--width <px>]\n" +
96
+ "renders a local HTML evidence layout to PNG via headless Chromium");
97
+ process.exit(0);
98
+ }
89
99
  const args = parseArgs(process.argv.slice(2));
90
100
  if (!args.in) fail(2, { ok: false, error: "missing --in <page.html>" });
91
101
  if (!args.out) fail(2, { ok: false, error: "missing --out <shot.png>" });
@@ -14,8 +14,10 @@
14
14
  // Seeding uses no-clobber semantics: existing project customizations are
15
15
  // never overwritten. Idempotent: re-running is a no-op.
16
16
 
17
- const fs = require("fs");
18
- const path = require("path");
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { realpathSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
19
21
 
20
22
  function ensureDir(dirPath) {
21
23
  if (!fs.existsSync(dirPath)) {
@@ -106,19 +108,33 @@ function scaffoldRepoOrchestration(repoPath, crewRepoPath) {
106
108
  return result;
107
109
  }
108
110
 
109
- module.exports = {
111
+ export {
110
112
  scaffoldRepoOrchestration,
111
113
  };
112
114
 
115
+ const USAGE = "Usage: node lib/repo-orchestration.js --repo <path> --crew-repo <path>";
116
+
117
+ const isMainModule = (() => {
118
+ try {
119
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
120
+ } catch {
121
+ return false;
122
+ }
123
+ })();
124
+
113
125
  // CLI: node lib/repo-orchestration.js --repo <path> --crew-repo <path>
114
- if (require.main === module) {
126
+ if (isMainModule) {
115
127
  const argv = process.argv.slice(2);
128
+ if (argv.includes("--help")) {
129
+ console.log(USAGE);
130
+ process.exit(0);
131
+ }
116
132
  const repoIdx = argv.indexOf("--repo");
117
133
  const crewRepoIdx = argv.indexOf("--crew-repo");
118
134
  const repoPath = repoIdx >= 0 ? argv[repoIdx + 1] : null;
119
135
  const crewRepoPath = crewRepoIdx >= 0 ? argv[crewRepoIdx + 1] : null;
120
136
  if (!repoPath || !crewRepoPath) {
121
- console.error("Usage: node lib/repo-orchestration.js --repo <path> --crew-repo <path>");
137
+ console.error(USAGE);
122
138
  process.exit(2);
123
139
  }
124
140
  const r = scaffoldRepoOrchestration(repoPath, crewRepoPath);