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
@@ -31,7 +31,7 @@
31
31
  //
32
32
  // Exit codes: 0 = success, 1 = terminal stop (no retry), 2 = usage error.
33
33
 
34
- import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
34
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
35
35
  import { join } from "path";
36
36
  import { execFileSync } from "child_process";
37
37
  import { randomUUID as uuid } from "crypto";
@@ -50,6 +50,19 @@ function fail(code, msg, exitCode = 1) {
50
50
  process.exit(exitCode);
51
51
  }
52
52
 
53
+ // --help: handled before any required-arg parsing so
54
+ // `node retry-publish.js --help` always exits 0 (entry-gate contract).
55
+ if (args.includes("--help")) {
56
+ console.log(
57
+ "usage: node retry-publish.js --crew-home <path> --task-id <uuid> --phase <prepare|complete>\n" +
58
+ " prepare: acquire the merge lock, re-check HEAD, re-classify, journal intent, emit the diff\n" +
59
+ " complete: --edit-outcome <accepted|refused> [--refusal-text <text>] — write the outcome,\n" +
60
+ " append the ledger entry, mirror verification, and release the lock\n" +
61
+ "exit 0 = success, 1 = terminal stop, 2 = usage error"
62
+ );
63
+ process.exit(0);
64
+ }
65
+
53
66
  const crewHome = arg("--crew-home");
54
67
  const taskId = arg("--task-id");
55
68
  const phase = arg("--phase");
@@ -59,6 +72,18 @@ if (!["prepare", "complete"].includes(phase)) {
59
72
  process.exit(2);
60
73
  }
61
74
 
75
+ // Retry protocol: phase-1 state is a persistent-disk pin
76
+ // ($CREW_HOME/.pins/ — never /tmp: tmpfs is wiped on cell reboot, and the
77
+ // lock release depends on phase 2 reading this state).
78
+ function pinsDir() {
79
+ const dir = join(crewHome, ".pins");
80
+ mkdirSync(dir, { recursive: true });
81
+ return dir;
82
+ }
83
+ function retryStatePath() {
84
+ return join(pinsDir(), `retry-publish-${taskId}.json`);
85
+ }
86
+
62
87
  // --- Shared helpers ---------------------------------------------------------
63
88
 
64
89
  function nowISO() { return new Date().toISOString(); }
@@ -180,9 +205,17 @@ if (phase === "prepare") {
180
205
  }
181
206
 
182
207
  const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
183
- // Find the trigger entry for the manifest baseline.
184
- const trigger = [...entries].reverse().find((e) =>
208
+ // Find the trigger entry for the manifest baseline: the OLDEST submitted
209
+ // entry by ts (D1, 2026-09-19; entry_kind cut 2026-09-20 — the retired
210
+ // kind-preference could never change the bound anchor) — it binds
211
+ // issued_at (falling back to its ts when the capture was unobserved),
212
+ // the same anchor verify-publish.js and findTriggerEntry use.
213
+ // agent_id is null on the issuance entry (no receipt observed at trigger
214
+ // time) — the baseline comes from manifest_before, never agent_id.
215
+ const submitted = entries.filter((e) =>
185
216
  e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
217
+ submitted.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
218
+ const trigger = submitted[0] || null;
186
219
  const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
187
220
 
188
221
  // Find the park note timestamp for quiesce calculation.
@@ -201,7 +234,7 @@ if (phase === "prepare") {
201
234
  "--slug", slug,
202
235
  "--commit", commit,
203
236
  "--base", base,
204
- "--trigger-ts", trigger ? trigger.ts : parkNote.timestamp,
237
+ "--trigger-ts", trigger ? (trigger.issued_at || trigger.ts) : parkNote.timestamp,
205
238
  "--park-ts", parkNote.timestamp,
206
239
  "--repo-path", repoPath,
207
240
  ];
@@ -266,7 +299,7 @@ if (phase === "prepare") {
266
299
  // with the diff, then calls phase 2 (complete) to finish.
267
300
  // Persist the phase-1 state for phase 2 (crash recovery reads the notes,
268
301
  // but phase 2 needs the exact commit/attempt/baseline/ledger path).
269
- const statePath = `/tmp/retry-publish-${taskId}.json`;
302
+ const statePath = retryStatePath();
270
303
  writeFileSync(statePath, JSON.stringify({
271
304
  task_id: taskId, commit, attempt, slug, repoPath,
272
305
  project_id: project.id, ledger_path: ledgerPath,
@@ -301,7 +334,7 @@ if (phase === "complete") {
301
334
  }
302
335
  const refusalText = arg("--refusal-text", false, "");
303
336
 
304
- const statePath = `/tmp/retry-publish-${taskId}.json`;
337
+ const statePath = retryStatePath();
305
338
  if (!existsSync(statePath)) {
306
339
  fail("no-prepare-state", `no phase-1 state at ${statePath} — the prepare phase did not complete or the state was cleaned`);
307
340
  }
@@ -322,49 +355,51 @@ if (phase === "complete") {
322
355
  writePublishNote(db, taskId,
323
356
  `publish: retry-refused ${commit} — the platform refused the re-issued edit (${refusalText.split("\n")[0].slice(0, 200)}); not retrying again.`);
324
357
  process.stdout.write(JSON.stringify({ ok: true, phase: "complete", outcome: "refused", commit }) + "\n");
325
- return;
326
- }
327
-
328
- // The child ended after the edit call with no explicit refusal —
329
- // fire-and-forget, same shape as the first attempt.
330
- writePublishNote(db, taskId,
331
- `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
332
-
333
- // Mirror verification with a settle window.
334
- const notBefore = new Date(Date.now() + 20 * 60 * 1000).toISOString();
335
- const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
336
- writePublishNote(db, taskId,
337
- `publish: verification-requested ${commit} attempt=${retryAttempt} not-before=${notBefore} — unknown-recovery retried the dropped edit once; content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
358
+ } else {
359
+ // The child ended after the edit call with no explicit refusal —
360
+ // fire-and-forget, same shape as the first attempt.
361
+ writePublishNote(db, taskId,
362
+ `publish: retry-issued ${commit} — the dropped edit was re-issued once; no explicit refusal was observed.`);
363
+
364
+ // Mirror verification with a settle window.
365
+ const settleMs = Number(process.env.CREW_RETRY_SETTLE_MS) > 0
366
+ ? Number(process.env.CREW_RETRY_SETTLE_MS)
367
+ : 20 * 60 * 1000;
368
+ const notBefore = new Date(Date.now() + settleMs).toISOString();
369
+ const retryAttempt = attempt ? `${attempt}-retry1` : "retry1";
370
+ writePublishNote(db, taskId,
371
+ `publish: verification-requested ${commit} attempt=${retryAttempt} not-before=${notBefore} — unknown-recovery retried the dropped edit once; content verification is the arbiter. Waiting on the manual read-back in docs/publish-verification.md.`);
372
+
373
+ // Append the -retry1 ledger entry with the pre-retry baseline (required).
374
+ const ledgerEntry = {
375
+ ts: nowISO(),
376
+ task_id: taskId,
377
+ workflow: "unknown-recovery",
378
+ slug,
379
+ commit,
380
+ attempt: retryAttempt,
381
+ agent_id: null,
382
+ applied_report: null,
383
+ outcome: "submitted",
384
+ manifest_before: retryBaseline,
385
+ detail: "retry trigger re-issued after provably-dropped classification",
386
+ };
387
+ try {
388
+ appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
389
+ } catch (e) {
390
+ // Best-effort: the note journal is the source of truth, not the ledger.
391
+ console.error(`ledger append failed (non-fatal): ${e.message}`);
392
+ }
338
393
 
339
- // Append the -retry1 ledger entry with the pre-retry baseline (required).
340
- const ledgerEntry = {
341
- ts: nowISO(),
342
- task_id: taskId,
343
- workflow: "unknown-recovery",
344
- slug,
345
- commit,
346
- attempt: retryAttempt,
347
- agent_id: null,
348
- applied_report: null,
349
- outcome: "submitted",
350
- manifest_before: retryBaseline,
351
- detail: "retry trigger re-issued after provably-dropped classification",
352
- };
353
- try {
354
- appendFileSync(ledgerPath, JSON.stringify(ledgerEntry) + "\n");
355
- } catch (e) {
356
- // Best-effort: the note journal is the source of truth, not the ledger.
357
- console.error(`ledger append failed (non-fatal): ${e.message}`);
394
+ process.stdout.write(JSON.stringify({
395
+ ok: true, phase: "complete", outcome: "issued",
396
+ commit, attempt: retryAttempt, not_before: notBefore,
397
+ }) + "\n");
358
398
  }
359
-
360
- process.stdout.write(JSON.stringify({
361
- ok: true, phase: "complete", outcome: "issued",
362
- commit, attempt: retryAttempt, not_before: notBefore,
363
- }) + "\n");
364
399
  } finally {
365
400
  releaseLock();
366
401
  // Clean up the phase-1 state file (best-effort).
367
- try { (await import("fs")).unlinkSync(statePath); } catch {}
402
+ try { unlinkSync(statePath); } catch {}
368
403
  }
369
404
  process.exit(0);
370
405
  }
@@ -15,9 +15,11 @@
15
15
  // --name: directory/project name (e.g. "flashcards")
16
16
  // Prints JSON: { path, committed, commitSha }
17
17
 
18
- const fs = require("fs");
19
- const path = require("path");
20
- const { execFileSync } = require("child_process");
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { execFileSync } from "node:child_process";
21
+ import { realpathSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
21
23
 
22
24
  function run(cmd, args, cwd) {
23
25
  return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -60,10 +62,24 @@ function setupSampleProject(tarballPath, destParent, name) {
60
62
  return { path: destDir, committed: true, commitSha: sha };
61
63
  }
62
64
 
63
- module.exports = { setupSampleProject };
65
+ export { setupSampleProject };
64
66
 
65
- if (require.main === module) {
67
+ const USAGE = "Usage: node lib/sample-project.js --tarball <path> --dest <dir> --name <project-name>";
68
+
69
+ const isMainModule = (() => {
70
+ try {
71
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
72
+ } catch {
73
+ return false;
74
+ }
75
+ })();
76
+
77
+ if (isMainModule) {
66
78
  const argv = process.argv.slice(2);
79
+ if (argv.includes("--help")) {
80
+ console.log(USAGE);
81
+ process.exit(0);
82
+ }
67
83
  const get = (flag) => {
68
84
  const i = argv.indexOf(flag);
69
85
  return i >= 0 ? argv[i + 1] : null;
@@ -72,7 +88,7 @@ if (require.main === module) {
72
88
  const dest = get("--dest");
73
89
  const name = get("--name");
74
90
  if (!tarball || !dest || !name) {
75
- console.error("Usage: node lib/sample-project.js --tarball <path> --dest <dir> --name <project-name>");
91
+ console.error(USAGE);
76
92
  process.exit(2);
77
93
  }
78
94
  try {
@@ -18,8 +18,8 @@
18
18
  // source (fail-closed: never stamp an empty 0/0 receipt). Exit 2 on usage
19
19
  // errors. No wall-clock reads, no randomness.
20
20
 
21
- const fs = require("node:fs");
22
- const path = require("node:path");
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
23
 
24
24
  function usageError(msg) {
25
25
  process.stderr.write("scaffold-crew.js usage: --repo <crewRepoPath> --orch-dir <orchDir>\n" + msg + "\n");
@@ -27,6 +27,15 @@ function usageError(msg) {
27
27
  }
28
28
 
29
29
  const args = process.argv.slice(2);
30
+ // --help: before required-arg parsing (shebang⇔CLI contract).
31
+ if (args.includes("--help")) {
32
+ console.log(
33
+ "usage: node scaffold-crew.js --repo <crewRepoPath> --orch-dir <orchDir>\n" +
34
+ "copies the fixed scaffold file sets (no-clobber), verifies every file on disk,\n" +
35
+ "writes <orchDir>/.scaffold-receipt.json and prints {created, skipped}"
36
+ );
37
+ process.exit(0);
38
+ }
30
39
  let repo = null;
31
40
  let orchDir = null;
32
41
  for (let i = 0; i < args.length; i++) {
package/lib/see-act.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // see-act.js — single-step browser driver for experiential QA.
2
3
  //
3
4
  // The agent closes the OODA loop: run one step, READ the screenshot or aria
@@ -30,12 +31,14 @@
30
31
  // Determinism: no wall-clock reads, no randomness. Archive names are sequential.
31
32
  //
32
33
  // Dependencies: playwright-core (resolved below), a Chromium binary.
33
- "use strict";
34
34
 
35
- const { createServer, request: httpRequest } = require("node:http");
36
- const { request: httpsRequest } = require("node:https");
37
- const { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } = require("node:fs");
38
- const { join, dirname, resolve } = require("node:path");
35
+ import { createServer, request as httpRequest } from "node:http";
36
+ import { request as httpsRequest } from "node:https";
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
38
+ import { join, dirname, resolve } from "node:path";
39
+ import { createRequire } from "node:module";
40
+
41
+ const cjsRequire = createRequire(import.meta.url);
39
42
 
40
43
  const VIEWPORTS = {
41
44
  desktop: { width: 1440, height: 900 },
@@ -73,15 +76,15 @@ function loadPlaywright() {
73
76
  // playwright-core is a declared dependency (package.json), so a normal
74
77
  // npm install resolves it from the package's own node_modules.
75
78
  try {
76
- return require("playwright-core");
79
+ return cjsRequire("playwright-core");
77
80
  } catch (e) { /* fall through */ }
78
81
  const envDir = (process.env.PLAYWRIGHT_CORE_DIR || "").trim();
79
82
  if (envDir) {
80
83
  try {
81
- return require(join(envDir, "playwright-core"));
84
+ return cjsRequire(join(envDir, "playwright-core"));
82
85
  } catch (e) { /* fall through */ }
83
86
  try {
84
- return require(envDir);
87
+ return cjsRequire(envDir);
85
88
  } catch (e) { /* fall through */ }
86
89
  }
87
90
  fail(3, {
@@ -188,6 +191,12 @@ function startProxy() {
188
191
  }
189
192
 
190
193
  async function main() {
194
+ // --help: before required-arg parsing (shebang⇔CLI contract).
195
+ if (process.argv.slice(2).includes("--help")) {
196
+ console.log("usage: node see-act.js --url <url> [--viewport desktop|mobile] [--timeout-ms <n>] <action> [args]\n" +
197
+ "actions: aria | shot --out <png> [--full] | click --out <png> --selector <css> | scroll --out <png> --y <px|bottom> | type --out <png> --selector <css> --text <text>");
198
+ process.exit(0);
199
+ }
191
200
  const args = parseArgs(process.argv.slice(2));
192
201
  if (!args.url) {
193
202
  fail(2, { ok: false, error: "usage: node see-act.js --url <url> [--viewport desktop|mobile] <aria|shot|click|scroll|type> [args]" });
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  // serve-artifact.js — serve a built TS space locally for experiential QA.
2
3
  //
3
4
  // Serves <space-dir>/client/dist statically and dispatches POST */actions
@@ -20,13 +21,12 @@
20
21
  // what the artifact's server needs before starting this.
21
22
  //
22
23
  // Determinism: no wall-clock reads, no randomness.
23
- "use strict";
24
24
 
25
- const { createServer } = require("node:http");
26
- const { join, normalize, dirname, sep } = require("node:path");
27
- const { existsSync } = require("node:fs");
28
- const { readFile, writeFile, mkdir, mkdtemp } = require("node:fs/promises");
29
- const { tmpdir } = require("node:os");
25
+ import { createServer } from "node:http";
26
+ import { join, normalize, dirname, sep } from "node:path";
27
+ import { existsSync } from "node:fs";
28
+ import { readFile, writeFile, mkdir, mkdtemp } from "node:fs/promises";
29
+ import { tmpdir } from "node:os";
30
30
 
31
31
  function parseArgs(argv) {
32
32
  const out = { spaceDir: null, port: 0, tag: null };
@@ -57,6 +57,12 @@ const CONTENT_TYPES = {
57
57
  };
58
58
 
59
59
  async function main() {
60
+ // --help: before required-arg parsing (shebang⇔CLI contract).
61
+ if (process.argv.slice(2).includes("--help")) {
62
+ console.log("usage: node serve-artifact.js --space-dir <path> [--port <n>]\n" +
63
+ "serves <space-dir>/client/dist statically, dispatches POST */actions to the built server actions");
64
+ process.exit(0);
65
+ }
60
66
  const args = parseArgs(process.argv.slice(2));
61
67
  if (!args.spaceDir) {
62
68
  process.stderr.write("serve-artifact.js: --space-dir <path> is required\n");
@@ -30,14 +30,25 @@
30
30
  // Prints JSON: { orchestration: {...}, gitignore: {...} | { declined: true }, commit: {...} }
31
31
  // Exits non-zero on any failure (fail closed, loud).
32
32
 
33
- const fs = require("fs");
34
- const path = require("path");
35
- const { scaffoldRepoOrchestration } = require("./repo-orchestration");
36
- const { ensureGitignoreEntries, describeGitignoreChange } = require("./gitignore");
37
- const { commitScaffold, isPathDirty } = require("./commit-scaffold");
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { scaffoldRepoOrchestration } from "./repo-orchestration.js";
36
+ import { ensureGitignoreEntries, describeGitignoreChange } from "./gitignore.js";
37
+ import { commitScaffold, isPathDirty } from "./commit-scaffold.js";
38
+ import { realpathSync } from "node:fs";
39
+ import { fileURLToPath } from "node:url";
38
40
 
39
41
  function main() {
40
42
  const argv = process.argv.slice(2);
43
+ // --help: before required-arg parsing (shebang⇔CLI contract).
44
+ if (argv.includes("--help")) {
45
+ console.log(
46
+ "usage: node setup-project-repo.js --repo <path> --crew-repo <path> [--consent]\n" +
47
+ "--consent: user approved the .gitignore change (from the setup conversation)\n" +
48
+ "prints JSON: { orchestration, gitignore, commit }"
49
+ );
50
+ process.exit(0);
51
+ }
41
52
  const get = (flag) => {
42
53
  const i = argv.indexOf(flag);
43
54
  return i >= 0 ? argv[i + 1] : null;
@@ -91,6 +102,14 @@ function main() {
91
102
  }
92
103
  }
93
104
 
94
- if (require.main === module) main();
105
+ const isMainModule = (() => {
106
+ try {
107
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
108
+ } catch {
109
+ return false;
110
+ }
111
+ })();
112
+
113
+ if (isMainModule) main();
95
114
 
96
- module.exports = { main };
115
+ export { main };
@@ -22,7 +22,8 @@
22
22
  # 6. NOT move the main branch (it still points at A)
23
23
  # 7. write integration_target=HEAD in the merge record
24
24
  # 8. verify-merge → VERIFIED
25
- # 9. push-target → PUSH_SKIPPED (detached HEAD has no remote counterpart)
25
+ # 9. push-target → NO_REMOTE_PUSH (fixture A has no origin; a detached
26
+ # line with no remote has nowhere to push -- loud, not silent)
26
27
  #
27
28
  # Fixture B (branch — the old behavior is preserved):
28
29
  # repo on main with a local bare origin. Integrate merges, reconciles,
@@ -49,6 +50,14 @@
49
50
  # crash): re-running integrate takes the lock and pushes instead of
50
51
  # parking on MERGED_EMPTY.
51
52
  #
53
+ # Fixture H (detached HEAD with an origin — 2026-09-19 detached-HEAD audit
54
+ # REDO): H1 proves push-destination fails closed when origin/HEAD is unset
55
+ # (genuinely unknowable destination); H2-H5 prove the detached line pushes
56
+ # explicitly as HEAD:main — inline push reports
57
+ # `PUSHED: origin/main (refspec HEAD:main)`, the remote ref advances to the
58
+ # merge commit, local main stays at the reviewed base, HEAD stays detached,
59
+ # verify-merge VERIFIEDs, and push-target re-pushes idempotently.
60
+ #
52
61
  # Hermetic: scratch dirs under /tmp only, removed at start (and end).
53
62
  set -u
54
63
 
@@ -126,9 +135,9 @@ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing
126
135
  out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
127
136
  echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
128
137
 
129
- # 9. push-target skips loudly on a detached HEAD
138
+ # 9. push-target on a detached HEAD with no remote: loud NO_REMOTE_PUSH
130
139
  out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
131
- echo "$out" | grep -q '^PUSH_SKIPPED: detached HEAD' || fail "push-target: expected PUSH_SKIPPED, got: $out"
140
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "push-target: expected NO_REMOTE_PUSH, got: $out"
132
141
 
133
142
  echo "fixture A (detached HEAD): all checks passed"
134
143
 
@@ -336,5 +345,72 @@ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
336
345
 
337
346
  echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
338
347
 
348
+ # ---------------- Fixture H: detached HEAD with an origin ----------------
349
+ # (2026-09-19 detached-HEAD audit REDO: a detached line with a remote is
350
+ # pushed explicitly as HEAD:<destination> -- the remote's default branch --
351
+ # never silently skipped, never refused. The local branch the line
352
+ # descends from is NOT moved; only the remote ref advances.)
353
+ H_DIR="$T/fixture-h"
354
+ H_REMOTE="$T/remote-h.git"
355
+ git init -q --bare "$H_REMOTE"
356
+ mkdir -p "$H_DIR"
357
+ cd "$H_DIR"
358
+ git init -q -b main .
359
+ git config user.email test@test.t
360
+ git config user.name test
361
+ echo reviewed > f.txt
362
+ git add -A
363
+ git commit -qm "reviewed base"
364
+ H_BASE=$(git rev-parse HEAD)
365
+ git remote add origin "$H_REMOTE"
366
+ git push -q origin main
367
+ git fetch -q origin
368
+ # Detach at the reviewed base, crew-init stamp on top -- the detached line
369
+ # the REDO must push explicitly (set origin/HEAD only after H1).
370
+ git checkout -q --detach HEAD
371
+ echo crew-init >> f.txt
372
+ git commit -qam "crew-init: stamp provenance"
373
+ H_LINE=$(git rev-parse HEAD)
374
+ git symbolic-ref -q HEAD >/dev/null && fail "fixture H: HEAD should be detached"
375
+ export CREW_REPO="$H_DIR"
376
+ export CREW_HOME="$H_DIR/home"
377
+ export CREW_LIB="$REPO_DIR/lib"
378
+ # H1: origin/HEAD not set -- the destination is genuinely unknowable --
379
+ # push-destination fails closed (never a silent skip, never a bare-HEAD push)
380
+ out=$(bash "$LIFECYCLE" push-destination 2>&1) && fail "H1: push-destination should fail without origin/HEAD: $out"
381
+ echo "$out" | grep -q 'genuinely unknowable' || fail "H1: expected unknowable error, got: $out"
382
+ echo "fixture H1 (unknowable destination fails closed): passed"
383
+ # Now set the remote default branch explicitly (hermetic: no `remote set-head -a` network round-trip)
384
+ git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
385
+ tid="detachedorigin1"
386
+
387
+ # H2: push-destination resolves the remote default branch
388
+ out=$(bash "$LIFECYCLE" push-destination) || fail "H2 push-destination exited non-zero: $out"
389
+ [ "$out" = "main" ] || fail "H2 push-destination: expected main, got: $out"
390
+
391
+ # H3: prepare + task work, then integrate pushes inline as HEAD:main
392
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "H3 prepare exited non-zero: $out"
393
+ echo task-work >> "$H_DIR/.worktrees/$tid/f.txt"
394
+ (cd "$H_DIR/.worktrees/$tid" && git commit -qam "task work")
395
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "H3 integrate exited non-zero: $out"
396
+ echo "$out" | grep -q '^MERGED:' || fail "H3 integrate: expected MERGED, got: $out"
397
+ echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H3 integrate: expected explicit detached PUSHED, got: $out"
398
+
399
+ # H4: HEAD still detached; the detached line survived; the remote advanced
400
+ git symbolic-ref -q HEAD >/dev/null && fail "H4: HEAD should still be detached"
401
+ H_MERGE=$(git rev-parse HEAD)
402
+ git merge-base --is-ancestor "$H_BASE" HEAD || fail "H4: reviewed base abandoned"
403
+ git merge-base --is-ancestor "$H_LINE" HEAD || fail "H4: detached line abandoned"
404
+ [ "$(git rev-parse main)" = "$H_BASE" ] || fail "H4: local main moved (must stay at the reviewed base)"
405
+ [ "$(git --git-dir="$H_REMOTE" rev-parse refs/heads/main)" = "$H_MERGE" ] || fail "H4: origin/main not updated to the merge commit"
406
+
407
+ # H5: verify-merge VERIFIEDs; push-target re-pushes idempotently as HEAD:main
408
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "H5 verify-merge exited non-zero: $out"
409
+ echo "$out" | grep -q '^VERIFIED:' || fail "H5: expected VERIFIED, got: $out"
410
+ out=$(bash "$LIFECYCLE" push-target "$tid") || fail "H5 push-target exited non-zero: $out"
411
+ echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H5 push-target: expected idempotent PUSHED, got: $out"
412
+
413
+ echo "fixture H (detached HEAD + origin): all checks passed"
414
+
339
415
  rm -rf "$T"
340
416
  echo "PASS: detached-HEAD integrate fixtures"
@@ -21,6 +21,17 @@
21
21
  # * old helper that does not know --check (usage to stderr, exit 1,
22
22
  # no CREDENTIAL markers) with DRY_RUN=1 -> no skip (fall-through
23
23
  # preserved: unknown is not absent)
24
+ # - detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
25
+ # checkout with a local origin. Case 6 (origin/HEAD set, DRY_RUN=1) ->
26
+ # preflight passes through to PUBLISH_DRY_RUN=1 (no refusal — detached
27
+ # publication is legitimate). Case 7 (origin/HEAD deleted, DRY_RUN=1) ->
28
+ # PUBLISH_FAILED=env before any mutation, repo untouched (the genuinely
29
+ # unknowable destination fails in the preflight, ahead of the DRY_RUN
30
+ # block).
31
+ # - Case 8: attached checkout with no origin (DRY_RUN=1) -> PUBLISH_FAILED=env
32
+ # pre-mutation, repo untouched (2026-09-19 critic F-A1: the anchor used to
33
+ # check no-origin only on the detached branch, so attached+no-origin
34
+ # failed post-mutation after the version-bump commit and registry publish).
24
35
  # - --check probe unit test against the real helper: on the author
25
36
  # machine this expects exit 0 and exactly CREDENTIAL_OK=custom.npm.
26
37
  # NOTE: this case depends on the `custom.npm` credential existing in
@@ -52,6 +63,13 @@ git -C "$SCRATCH" config user.email "test@crew"
52
63
  git -C "$SCRATCH" config user.name "crew-test"
53
64
  echo x > "$SCRATCH/f.txt"
54
65
  git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
66
+ # The scratch repo needs an origin for the DRY_RUN pass-through cases
67
+ # (2026-09-19 critic F-A1: the push-destination anchor fails closed
68
+ # pre-mutation when origin is absent, attached or detached).
69
+ git init -q --bare "$TMPBASE/origin.git"
70
+ git -C "$SCRATCH" remote add origin "$TMPBASE/origin.git"
71
+ git -C "$SCRATCH" push -q origin main
72
+ git -C "$SCRATCH" remote set-head origin -a
55
73
 
56
74
  # --- Fake helpers ---
57
75
  cat > "$TMPBASE/helper-ok.py" <<'EOF'
@@ -78,6 +96,32 @@ print("Usage: helper-old.py <tarball-path>", file=sys.stderr)
78
96
  sys.exit(1)
79
97
  EOF
80
98
 
99
+ # --- Detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
100
+ # checkout with a local origin. Case 6 runs with origin/HEAD set; Case 7
101
+ # deletes origin/HEAD first, exercising push-destination's genuinely-
102
+ # unknowable branch. ---
103
+ ORIGIN="$TMPBASE/origin-det.git"
104
+ git init -q --bare "$ORIGIN"
105
+ SCRATCH_DET="$TMPBASE/repo-detached"
106
+ git init -q -b main "$SCRATCH_DET"
107
+ git -C "$SCRATCH_DET" config user.email "test@crew"
108
+ git -C "$SCRATCH_DET" config user.name "crew-test"
109
+ echo x > "$SCRATCH_DET/f.txt"
110
+ git -C "$SCRATCH_DET" add -A && git -C "$SCRATCH_DET" commit -qm init
111
+ git -C "$SCRATCH_DET" remote add origin "$ORIGIN"
112
+ git -C "$SCRATCH_DET" push -q origin main
113
+ git -C "$SCRATCH_DET" remote set-head origin -a
114
+ git -C "$SCRATCH_DET" checkout -q --detach HEAD
115
+
116
+ # --- Attached no-origin fixture (F-A1): an attached checkout with no origin
117
+ # fails closed in the anchor, attached or detached. ---
118
+ SCRATCH_NOORIGIN="$TMPBASE/repo-no-origin"
119
+ git init -q -b main "$SCRATCH_NOORIGIN"
120
+ git -C "$SCRATCH_NOORIGIN" config user.email "test@crew"
121
+ git -C "$SCRATCH_NOORIGIN" config user.name "crew-test"
122
+ echo x > "$SCRATCH_NOORIGIN/f.txt"
123
+ git -C "$SCRATCH_NOORIGIN" add -A && git -C "$SCRATCH_NOORIGIN" commit -qm init
124
+
81
125
  ACTIONABLE="npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
82
126
 
83
127
  # --- Structural: extract the shipped preflight block by anchor ---
@@ -123,13 +167,23 @@ fi
123
167
 
124
168
  # Runner: full publish-npm.sh against the scratch repo. The preflight exits
125
169
  # before any mutation, so the repo needs no package.json.
170
+ # LIFECYCLE + CREW_HOME are exported because the preflight now resolves the
171
+ # push destination up front (2026-09-19: the push-destination resolution moved
172
+ # from step 13 to the preflight — fail before mutation, never after the
173
+ # registry publish; 2026-09-19 detached-HEAD audit REDO: a detached checkout
174
+ # resolves to HEAD:<destination>, refused only when genuinely unknowable).
175
+ # The scratch repo sits on branch `main`, so integration-target
176
+ # resolves cleanly and the npm-credential assertions are unaffected.
126
177
  run_full() { # run_full <helper_path> [extra_env...]
127
178
  local helper="$1"; shift
128
179
  local run="$TMPBASE/run-$RANDOM"
129
180
  mkdir -p "$run"
181
+ local crew_home="$TMPBASE/crew-home"
182
+ mkdir -p "$crew_home"
130
183
  (
131
184
  export TASK_ID="preflight-test" REPO_PATH="$SCRATCH" PKG="muse-crew" TARGET_VERSION="9.9.9"
132
185
  export NPM_PUBLISH_PY="$helper"
186
+ export LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh" CREW_HOME="$crew_home" CREW_REPO="$SCRATCH"
133
187
  # A bare `export` (empty "$@") would dump the environment to stdout and
134
188
  # corrupt the captured run path — only export when extras are given.
135
189
  if [ $# -gt 0 ]; then export "$@"; fi
@@ -140,11 +194,11 @@ run_full() { # run_full <helper_path> [extra_env...]
140
194
  echo "$run"
141
195
  }
142
196
 
143
- repo_state() { # prints "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
144
- local head status tgz
145
- head="$(git -C "$SCRATCH" rev-parse HEAD)"
146
- status="$(git -C "$SCRATCH" status --porcelain | wc -l)"
147
- tgz="$(find "$SCRATCH" -maxdepth 1 -name '*.tgz' | wc -l)"
197
+ repo_state() { # repo_state [repo]: "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
198
+ local head status tgz repo="${1:-$SCRATCH}"
199
+ head="$(git -C "$repo" rev-parse HEAD)"
200
+ status="$(git -C "$repo" status --porcelain | wc -l)"
201
+ tgz="$(find "$repo" -maxdepth 1 -name '*.tgz' | wc -l)"
148
202
  echo "HEAD=$head STATUS=$status TGZ=$tgz"
149
203
  }
150
204
 
@@ -203,6 +257,41 @@ else
203
257
  no "real helper --check: code=$CHECK_CODE out='$CHECK_OUT' (expected CREDENTIAL_OK=custom.npm — this case is credential-dependent; see header)"
204
258
  fi
205
259
 
260
+ # --- Case 6: detached + resolvable destination + DRY_RUN=1 -> pass-through, no refusal ---
261
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
262
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
263
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
264
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
265
+ ok "detached + resolvable destination: preflight passes through to PUBLISH_DRY_RUN=1 (no refusal)"
266
+ else
267
+ no "detached resolvable: code=$CODE out='$OUT'"
268
+ fi
269
+
270
+ # --- Case 7: detached + origin/HEAD unset -> PUBLISH_FAILED=env before any mutation ---
271
+ git -C "$SCRATCH_DET" remote set-head origin -d
272
+ BEFORE="$(repo_state "$SCRATCH_DET")"
273
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
274
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_DET")"
275
+ if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
276
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
277
+ && [ "$BEFORE" = "$AFTER" ]; then
278
+ ok "detached + unknowable destination: fails closed pre-mutation, repo untouched"
279
+ else
280
+ no "detached unknowable: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
281
+ fi
282
+
283
+ # --- Case 8: attached + no origin -> PUBLISH_FAILED=env before any mutation ---
284
+ BEFORE="$(repo_state "$SCRATCH_NOORIGIN")"
285
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_NOORIGIN")"
286
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_NOORIGIN")"
287
+ if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
288
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
289
+ && [ "$BEFORE" = "$AFTER" ]; then
290
+ ok "attached + no origin: fails closed pre-mutation, repo untouched"
291
+ else
292
+ no "attached no-origin: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
293
+ fi
294
+
206
295
  bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
207
296
 
208
297
  echo ""