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
@@ -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
+ }
@@ -82,7 +82,42 @@ if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
82
82
  fi
83
83
  # PREFLIGHT-END
84
84
 
85
- # 3. Dry run: markers only, no lock, no deploy, no mutation.
85
+ # PUSH-TARGET-ANCHOR: resolve the push destination before ANY mutation.
86
+ # The destination is the integration target resolved by the lifecycle, not a
87
+ # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
88
+ # stale clone of the same hardcoding the integrate fix removed). A detached
89
+ # HEAD pushes as HEAD:<destination>, with the destination resolved explicitly
90
+ # by the lifecycle's push-destination command (2026-09-19 detached-HEAD audit
91
+ # REDO: the old code refused every detached checkout; detached publication
92
+ # is legitimate and must work). Fail here, before the version-bump commit
93
+ # and the registry publish, only when the destination is genuinely
94
+ # unknowable -- never after a side effect you cannot roll back. (The old
95
+ # position of this check -- step 13, after the npm publish -- left a shipped
96
+ # version with a failed phase and a retry loop.)
97
+ # No-origin is checked for BOTH shapes (2026-09-19 critic finding F-A1):
98
+ # step 13 pushes unconditionally, so an attached checkout with no origin
99
+ # would otherwise fail PUBLISH_FAILED=push after the version-bump commit
100
+ # and the registry publish. A version-bump commit with nowhere to record it
101
+ # would ship an npm version with no git provenance, so no-remote fails
102
+ # closed here rather than skipping like the integrate path (F-F6).
103
+ [ -n "${LIFECYCLE:-}" ] || fail "env" "LIFECYCLE is not set -- cannot resolve the push destination before mutation (F-R4)"
104
+ PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" integration-target 2>&1)" \
105
+ || fail "integration-target" "$PUSH_TARGET"
106
+ if ! git -C "$REPO_PATH" remote get-url origin >/dev/null 2>&1; then
107
+ fail "env" "no origin remote configured: the version-bump commit could not be pushed anywhere -- no version-bump commit was created and nothing was published"
108
+ fi
109
+ PUSH_REFSPEC="$PUSH_TARGET"
110
+ if [ "$PUSH_TARGET" = "HEAD" ]; then
111
+ PUSH_DEST="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" push-destination 2>&1)" \
112
+ || fail "env" "$PUSH_DEST"
113
+ PUSH_REFSPEC="HEAD:$PUSH_DEST"
114
+ fi
115
+ # PUSH-TARGET-END
116
+
117
+ # 3. Dry run: markers only, no lock, no deploy, no mutation. The push
118
+ # preflight above already ran, so even a dry run fails closed on a
119
+ # genuinely unknowable push destination (see case 7 in
120
+ # lib/test-publish-preflight.sh -- F-R9).
86
121
  if [ "${DRY_RUN:-0}" = "1" ]; then
87
122
  echo "PUBLISH_DRY_RUN=1"
88
123
  exit 0
@@ -281,15 +316,9 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
281
316
  || fail "lock-refresh" "$LOCK_OUT2"
282
317
 
283
318
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
284
- # The destination is the integration target resolved by the lifecycle, not a
285
- # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
286
- # stale clone of the same hardcoding the integrate fix removed).
287
- PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" integration-target 2>&1)" \
288
- || fail "integration-target" "$PUSH_TARGET"
289
- if [ "$PUSH_TARGET" = "HEAD" ]; then
290
- fail "push" "detached HEAD: publish-npm cannot push a version-bump commit from a detached checkout"
291
- fi
292
- PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_TARGET" 2>&1)" \
319
+ # PUSH_REFSPEC was resolved in the preflight above -- a branch name, or
320
+ # HEAD:<destination> on a detached checkout (never a bare HEAD).
321
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_REFSPEC" 2>&1)" \
293
322
  || fail "push" "$PUSH_OUT"
294
323
  echo "PUBLISH_PUSHED=1"
295
324
 
@@ -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);