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.
- package/AGENTS.md +1 -1
- package/docs/decisions/AGENTS.md +4 -0
- package/docs/decisions/publish-path.md +229 -0
- package/docs/publish-verification.md +14 -3
- package/docs/release-integrity.md +60 -0
- package/docs/reviews/critic-0143-redo.md +94 -0
- package/docs/reviews/critic-0143.md +108 -0
- package/docs/reviews/critic-0144.md +83 -0
- package/lib/AGENTS.md +14 -6
- package/lib/advance-publish-base.js +8 -0
- package/lib/append-ooda-step.js +12 -3
- package/lib/build-readback-request.js +10 -0
- package/lib/build-registry.js +8 -3
- package/lib/classify-publish-absence.js +11 -0
- package/lib/classify-surface.js +12 -2
- package/lib/commit-scaffold.js +22 -6
- package/lib/compose-evidence-caption.js +15 -4
- package/lib/compute-publish-diff.js +9 -0
- package/lib/crew-api.js +55 -20
- package/lib/crew-release.sh +233 -1
- package/lib/gitignore.js +23 -5
- package/lib/package.json +1 -0
- package/lib/publish-note-vocabulary.js +44 -0
- package/lib/publish-npm.sh +39 -10
- package/lib/read-ooda-verdict.js +11 -3
- package/lib/readback-disk.js +9 -0
- package/lib/render-html.js +17 -7
- package/lib/repo-orchestration.js +21 -5
- package/lib/retry-publish.js +79 -44
- package/lib/sample-project.js +22 -6
- package/lib/scaffold-crew.js +11 -2
- package/lib/see-act.js +17 -8
- package/lib/serve-artifact.js +12 -6
- package/lib/setup-project-repo.js +26 -7
- package/lib/test-detached-integrate.sh +79 -3
- package/lib/test-publish-preflight.sh +94 -5
- package/lib/update-watch.js +34 -17
- package/lib/ux-doctrine.js +31 -6
- package/lib/verify-publish.js +41 -6
- package/lib/worktree-lifecycle.sh +67 -25
- package/lib/write-ooda-verdict.js +12 -3
- package/package.json +1 -1
- package/workflows/bugfix.js +60 -61
- package/workflows/chore.js +60 -61
- package/workflows/standard.js +60 -42
- package/workflows/upgrade.js +4 -2
package/lib/retry-publish.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 {
|
|
402
|
+
try { unlinkSync(statePath); } catch {}
|
|
368
403
|
}
|
|
369
404
|
process.exit(0);
|
|
370
405
|
}
|
package/lib/sample-project.js
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
// --name: directory/project name (e.g. "flashcards")
|
|
16
16
|
// Prints JSON: { path, committed, commitSha }
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
65
|
+
export { setupSampleProject };
|
|
64
66
|
|
|
65
|
-
|
|
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(
|
|
91
|
+
console.error(USAGE);
|
|
76
92
|
process.exit(2);
|
|
77
93
|
}
|
|
78
94
|
try {
|
package/lib/scaffold-crew.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
84
|
+
return cjsRequire(join(envDir, "playwright-core"));
|
|
82
85
|
} catch (e) { /* fall through */ }
|
|
83
86
|
try {
|
|
84
|
-
return
|
|
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]" });
|
package/lib/serve-artifact.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
|
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 '^
|
|
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() { #
|
|
144
|
-
local head status tgz
|
|
145
|
-
head="$(git -C "$
|
|
146
|
-
status="$(git -C "$
|
|
147
|
-
tgz="$(find "$
|
|
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 ""
|