muse-crew 0.13.0 → 0.13.1
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/API.md +3 -0
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +6 -2
- package/lib/crew-api.js +54 -6
- package/lib/schema.sql +13 -1
- package/package.json +1 -1
- package/workflows/bugfix.js +172 -29
- package/workflows/chore.js +172 -29
- package/workflows/crew-init.js +39 -0
- package/workflows/standard.js +172 -29
package/API.md
CHANGED
|
@@ -124,6 +124,9 @@ Create or update an agent session record. Sessions track which identity is worki
|
|
|
124
124
|
| `notes` | string (≤ 3000) | no | |
|
|
125
125
|
| `failure_reason` | string (≤ 3000) or null | no | |
|
|
126
126
|
| `caveats` | array of `{text, severity}` | no | |
|
|
127
|
+
| `already_merged_sha` | 7–40 lowercase hex or null | no | **Do not set this yourself.** The workflow sets it automatically when it verifies that a task's work is already on the main branch (e.g., landed by a prior run). Written by workflow code (not by the agent's text output) after the `rev-parse` + `merge-base --is-ancestor` check passes; a bad value is a usage error. Review loads it from the field instead of parsing the notes. Null semantics: omitting the field preserves the existing value; passing explicit `null` clears it. `record-phase` always preserves (it never clears). |
|
|
128
|
+
|
|
129
|
+
The `already_merged_sha` field exists because session `notes` are hard-capped at 3000 chars: a truthful already-merged declaration at the tail of a long Build report was silently truncated, and Review could not recover it. The sha is managed by the workflow — the workflow writes it after verification, and downstream phases read the field, never the agent's text.
|
|
127
130
|
|
|
128
131
|
### `heartbeat-session`
|
|
129
132
|
|
package/docs/guide.md
CHANGED
|
@@ -34,7 +34,7 @@ When this guide says "call an action," it means calling an action defined in the
|
|
|
34
34
|
- A Muse account with access to workflows, crons, and artifacts.
|
|
35
35
|
- The `muse-crew` package installed via npm, or the repo cloned locally.
|
|
36
36
|
- A directory chosen for the crew home (e.g., `~/workspace/.jarvis`).
|
|
37
|
-
- A task service artifact already created. The [Orchestra Dashboard](https://github.com/emojimanegg1/orchestra-dashboard) is the reference implementation — clone its repo, create the
|
|
37
|
+
- A task service artifact already created. (An "artifact" is a Muse platform primitive — a hosted web app created through the Muse UI. The crew never creates artifacts; it only edits existing ones.) The [Orchestra Dashboard](https://github.com/emojimanegg1/orchestra-dashboard) is the reference implementation — clone its repo, create an artifact from it via the Muse UI, and note its slug. **Rule: every `deploy_slug` must point to an artifact that already exists.** Init checks that `~/workspace/ts-spaces/<slug>/` exists and will stop with an error if it doesn't — without this check, the first task would reach Publish and fail with a confusing error message. Publish edits an existing artifact; it never creates one.
|
|
38
38
|
|
|
39
39
|
### Running init
|
|
40
40
|
|
|
@@ -46,8 +46,12 @@ The workflow dropped the report entirely on 2026-09-16 (clean-room task
|
|
|
46
46
|
`e2a8d9f8`): the trigger's JSON closeout contract traveled over the
|
|
47
47
|
stochastic text channel and the runtime's JSON-candidate heuristic misfired
|
|
48
48
|
on its prose ("workflow agent output was not JSON"), parking a task whose
|
|
49
|
-
edit may have gone through. The trigger is now awaited
|
|
50
|
-
|
|
49
|
+
edit may have gone through. The trigger is now awaited and scanned for a
|
|
50
|
+
single explicit refusal signal — `ARTIFACT_EDIT_REFUSED: <text>` as the
|
|
51
|
+
entire trimmed turn output — and nothing else is consumed from the return.
|
|
52
|
+
An exact refusal is conclusive negative evidence (parks `rejected`, skips
|
|
53
|
+
observation polling); any other output (including prose quoting the signal)
|
|
54
|
+
is inconclusive and follows the existing fail-closed observation path.
|
|
51
55
|
`applied_report` is `missing-report` on ledger lines for issued triggers
|
|
52
56
|
(pre-trigger parks and unattributed-unknown parks write null — no trigger
|
|
53
57
|
was observed, so there is nothing to report). The
|
package/lib/crew-api.js
CHANGED
|
@@ -65,6 +65,20 @@ function validateEnvironmentType(value) {
|
|
|
65
65
|
throw usageError("environment_type must be 'artifact', 'terminal', or null.");
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// already_merged_sha validation (room #16 blocker 11, 2026-09-18): the
|
|
69
|
+
// workflow-verified already-merged sha as structured session control state.
|
|
70
|
+
// undefined means "not provided" (preserves the existing value); explicit
|
|
71
|
+
// null clears the field; otherwise a 7-40 hex string, normalized to
|
|
72
|
+
// lowercase. Anything else is a usage error. The CHECK constraint in
|
|
73
|
+
// schema.sql enforces the same shape at the SQL layer.
|
|
74
|
+
// layer; this is the API-boundary validation.
|
|
75
|
+
function validateAlreadyMergedSha(value) {
|
|
76
|
+
if (value === undefined || value === null) return null;
|
|
77
|
+
const s = String(value).trim().toLowerCase();
|
|
78
|
+
if (/^[0-9a-f]{7,40}$/.test(s)) return s;
|
|
79
|
+
throw usageError("already_merged_sha must be a 7-40 char hex string or null.");
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
// ---------------------------------------------------------------------------
|
|
69
83
|
// Crew home resolution (fail closed)
|
|
70
84
|
// ---------------------------------------------------------------------------
|
|
@@ -249,6 +263,23 @@ function openDb(crewHome) {
|
|
|
249
263
|
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
250
264
|
}
|
|
251
265
|
}
|
|
266
|
+
// Migration: add already_merged_sha column if missing (room #16 blocker
|
|
267
|
+
// 11, 2026-09-18). The workflow-verified already-merged sha as structured
|
|
268
|
+
// control state — Build's declaration used to travel to Review only
|
|
269
|
+
// through session notes, which are hard-capped at 3000 chars
|
|
270
|
+
// (record-phase below), so a truthful declaration at the report's tail
|
|
271
|
+
// never arrived. The workflow writes the sha here itself after the
|
|
272
|
+
// mechanical ancestor check; NULL = no verified declaration. Same
|
|
273
|
+
// idempotent PRAGMA-check pattern; no backfill (a missing value means
|
|
274
|
+
// "not verified", never fabricated).
|
|
275
|
+
if (!cols.some((c) => c.name === "already_merged_sha")) {
|
|
276
|
+
try {
|
|
277
|
+
db.exec("ALTER TABLE agent_sessions ADD COLUMN already_merged_sha TEXT CHECK (already_merged_sha IS NULL OR (already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))");
|
|
278
|
+
} catch (e) {
|
|
279
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
280
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
252
283
|
const projectCols = db.prepare("PRAGMA table_info(projects)").all();
|
|
253
284
|
// Migration: add visual_protocol column if missing (per-project visual
|
|
254
285
|
// protocol toggle, 2026-09-12). Same idempotent PRAGMA-check pattern;
|
|
@@ -376,6 +407,10 @@ function mapSession(row) {
|
|
|
376
407
|
caveats: parseCaveats(row.caveats),
|
|
377
408
|
failure_reason: row.failure_reason,
|
|
378
409
|
last_heartbeat: row.last_heartbeat ?? null,
|
|
410
|
+
// Room #16 blocker 11: the workflow-verified already-merged sha (NULL
|
|
411
|
+
// when no verified declaration). Structured control state — Review
|
|
412
|
+
// hydrates from this field, never from capped prose.
|
|
413
|
+
already_merged_sha: row.already_merged_sha ?? null,
|
|
379
414
|
};
|
|
380
415
|
}
|
|
381
416
|
|
|
@@ -1340,18 +1375,24 @@ commands["upsert-session"] = (db, args) => {
|
|
|
1340
1375
|
caveats: args.caveats === undefined ? (current?.caveats ?? "[]") : JSON.stringify(args.caveats),
|
|
1341
1376
|
failure_reason: args.failure_reason === undefined ? (current?.failure_reason ?? null) : args.failure_reason,
|
|
1342
1377
|
last_heartbeat: now(),
|
|
1378
|
+
// Room #16 blocker 11: preserve the verified sha across upserts unless
|
|
1379
|
+
// explicitly provided (validated at the API boundary).
|
|
1380
|
+
already_merged_sha: args.already_merged_sha === undefined
|
|
1381
|
+
? (current?.already_merged_sha ?? null)
|
|
1382
|
+
: validateAlreadyMergedSha(args.already_merged_sha),
|
|
1343
1383
|
};
|
|
1344
1384
|
if (current) {
|
|
1345
1385
|
db.prepare(
|
|
1346
1386
|
`UPDATE agent_sessions SET task_id=@task_id, identity=@identity, step=@step,
|
|
1347
1387
|
status=@status, started_at=@started_at, ended_at=@ended_at, notes=@notes,
|
|
1348
|
-
caveats=@caveats, failure_reason=@failure_reason, last_heartbeat=@last_heartbeat
|
|
1388
|
+
caveats=@caveats, failure_reason=@failure_reason, last_heartbeat=@last_heartbeat,
|
|
1389
|
+
already_merged_sha=@already_merged_sha WHERE id=@id`).run(row);
|
|
1349
1390
|
} else {
|
|
1350
1391
|
db.prepare(
|
|
1351
1392
|
`INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
|
|
1352
|
-
ended_at, notes, caveats, failure_reason, last_heartbeat)
|
|
1393
|
+
ended_at, notes, caveats, failure_reason, last_heartbeat, already_merged_sha)
|
|
1353
1394
|
VALUES (@id, @task_id, @identity, @step, @status, @started_at,
|
|
1354
|
-
@ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
|
|
1395
|
+
@ended_at, @notes, @caveats, @failure_reason, @last_heartbeat, @already_merged_sha)
|
|
1355
1396
|
ON CONFLICT DO NOTHING`).run(row);
|
|
1356
1397
|
}
|
|
1357
1398
|
return { session: mapSession(row) };
|
|
@@ -1715,6 +1756,10 @@ commands["record-phase"] = (db, args) => {
|
|
|
1715
1756
|
caveats: session.caveats === undefined ? "[]" : JSON.stringify(session.caveats),
|
|
1716
1757
|
failure_reason: session.failure_reason ?? null,
|
|
1717
1758
|
last_heartbeat: timestamp,
|
|
1759
|
+
// Room #16 blocker 11: the workflow-verified already-merged sha, written
|
|
1760
|
+
// by the workflow itself after the mechanical ancestor check — never
|
|
1761
|
+
// builder prose. Validated at the API boundary; NULL = not verified.
|
|
1762
|
+
already_merged_sha: validateAlreadyMergedSha(session.already_merged_sha),
|
|
1718
1763
|
};
|
|
1719
1764
|
const eventRow = {
|
|
1720
1765
|
id: uuid(), type: event.type, task_id: args.task_id,
|
|
@@ -1724,12 +1769,15 @@ commands["record-phase"] = (db, args) => {
|
|
|
1724
1769
|
try {
|
|
1725
1770
|
db.prepare(
|
|
1726
1771
|
`INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
|
|
1727
|
-
ended_at, notes, caveats, failure_reason, last_heartbeat)
|
|
1772
|
+
ended_at, notes, caveats, failure_reason, last_heartbeat, already_merged_sha)
|
|
1728
1773
|
VALUES (@id, @task_id, @identity, @step, @status, @started_at,
|
|
1729
|
-
@ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
|
|
1774
|
+
@ended_at, @notes, @caveats, @failure_reason, @last_heartbeat, @already_merged_sha)
|
|
1775
|
+
-- record-phase never clears already_merged_sha: a phase record is not a
|
|
1776
|
+
-- field update, so both undefined and null preserve the existing value.
|
|
1730
1777
|
ON CONFLICT(id) DO UPDATE SET status=@status, ended_at=@ended_at,
|
|
1731
1778
|
notes=@notes, caveats=@caveats, failure_reason=@failure_reason,
|
|
1732
|
-
last_heartbeat=@last_heartbeat
|
|
1779
|
+
last_heartbeat=@last_heartbeat,
|
|
1780
|
+
already_merged_sha=COALESCE(@already_merged_sha, already_merged_sha)`).run(sessionRow);
|
|
1733
1781
|
db.prepare(
|
|
1734
1782
|
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
1735
1783
|
VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(eventRow);
|
package/lib/schema.sql
CHANGED
|
@@ -93,7 +93,19 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
|
93
93
|
notes TEXT NOT NULL DEFAULT '',
|
|
94
94
|
caveats TEXT NOT NULL DEFAULT '[]',
|
|
95
95
|
failure_reason TEXT,
|
|
96
|
-
last_heartbeat TEXT
|
|
96
|
+
last_heartbeat TEXT,
|
|
97
|
+
-- Room #16 blocker 11 (2026-09-18): the workflow-verified already-merged
|
|
98
|
+
-- sha as structured control state. Build's `repo_diff: none
|
|
99
|
+
-- (already-merged: <sha>)` declaration used to travel to Review only
|
|
100
|
+
-- through capped prose (session notes are truncated at 3000 chars), so a
|
|
101
|
+
-- truthful declaration at the report's tail never arrived. The workflow
|
|
102
|
+
-- writes the sha here itself after the mechanical ancestor check — the
|
|
103
|
+
-- column is workflow-owned, never builder prose. NULL = no verified
|
|
104
|
+
-- declaration for this session. The CHECK enforces the full shape:
|
|
105
|
+
-- 7-40 lowercase hex chars (NOT GLOB '*[^0-9a-f]*' means no non-hex char
|
|
106
|
+
-- anywhere — GLOB '[0-9a-f]*' alone would only check the first char).
|
|
107
|
+
already_merged_sha TEXT CHECK (already_merged_sha IS NULL OR
|
|
108
|
+
(already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))
|
|
97
109
|
);
|
|
98
110
|
|
|
99
111
|
CREATE TABLE IF NOT EXISTS events (
|
package/package.json
CHANGED
package/workflows/bugfix.js
CHANGED
|
@@ -549,14 +549,34 @@ function extractMarkerLines(workerText) {
|
|
|
549
549
|
// builder correctly makes no commit because the deliverable is already on
|
|
550
550
|
// main (a prior merge or hand-repair landed it), it declares
|
|
551
551
|
// `repo_diff: none (already-merged: <sha>)` naming the main commit that
|
|
552
|
-
// carries the work.
|
|
553
|
-
//
|
|
554
|
-
//
|
|
552
|
+
// carries the work. Room #16 blocker 11 (2026-09-18): the line anchor
|
|
553
|
+
// missed Wren's mid-paragraph declaration, and the persisted notes truncated
|
|
554
|
+
// the tail — so the anchor is gone and a sha followed by `)`, whitespace, or
|
|
555
|
+
// end-of-string (truncation) is accepted. The sha is hex-only (7-40 chars)
|
|
556
|
+
// so the workflow can interpolate it into the mechanical ancestor check
|
|
557
|
+
// without injection risk; a over-long hex run never matches (the lookahead
|
|
558
|
+
// fails on the extra hex char). Pure — pinned byte-identical across
|
|
559
|
+
// standard/bugfix/chore.
|
|
555
560
|
function extractAlreadyMerged(workerText) {
|
|
556
|
-
var m =
|
|
561
|
+
var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
|
|
557
562
|
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
558
563
|
}
|
|
559
564
|
|
|
565
|
+
// Explicit artifact refusal (room #16 blocker 10, 2026-09-18): the rebuild
|
|
566
|
+
// trigger child ends its turn with `ARTIFACT_EDIT_REFUSED: <text>` when
|
|
567
|
+
// artifact_edit explicitly refuses the edit (e.g. the artifact does not
|
|
568
|
+
// exist). A refusal is conclusive negative evidence — the edit provably did
|
|
569
|
+
// NOT go through — distinct from an unconsumed trigger return (unknown).
|
|
570
|
+
// Pure — pinned byte-identical across standard/bugfix/chore.
|
|
571
|
+
// The signal must be the ENTIRE trimmed turn output (not a line within prose):
|
|
572
|
+
// the trigger child is instructed to end its turn with exactly this line and
|
|
573
|
+
// nothing else. A confused child quoting the instructions back in prose must
|
|
574
|
+
// NOT produce a conclusive negative — that degrades to unknown (fail-closed).
|
|
575
|
+
function extractRefusal(workerText) {
|
|
576
|
+
var m = /^ARTIFACT_EDIT_REFUSED:\s*(.+?)\s*$/.exec(String(workerText || "").trim());
|
|
577
|
+
return m ? m[1].slice(0, 300) : null;
|
|
578
|
+
}
|
|
579
|
+
|
|
560
580
|
// Worktree confinement: the Build agent must declare the exact worktree
|
|
561
581
|
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
562
582
|
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
@@ -1378,23 +1398,40 @@ while (i < STEPS.length) {
|
|
|
1378
1398
|
} else if (step.name === "Review") {
|
|
1379
1399
|
// Already-merged hydration: when this run did not execute Build itself
|
|
1380
1400
|
// (dispatcher resume at Review after a platform death between phases),
|
|
1381
|
-
// recover the workflow-
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
1401
|
+
// recover the workflow-verified sha. Room #16 blocker 11: the structured
|
|
1402
|
+
// session field is read FIRST — the `already_merged_verified:` notes line
|
|
1403
|
+
// is only a fallback, because session notes are hard-capped at 3000
|
|
1404
|
+
// chars and a truthful declaration at the report's tail was silently
|
|
1405
|
+
// truncated. The structured value was written by the workflow after a
|
|
1406
|
+
// mechanical ancestor check — it is trusted; the builder's bare
|
|
1407
|
+
// declaration never is. Absent both, the mechanical fact below reads
|
|
1408
|
+
// "none declared" and Cass fails closed. The hydration read is best-effort:
|
|
1409
|
+
// a transport throw degrades to "none declared" rather than crashing Review.
|
|
1386
1410
|
if (!alreadyMergedSha) {
|
|
1387
|
-
var
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1411
|
+
var hydResult = null;
|
|
1412
|
+
try {
|
|
1413
|
+
hydResult = await agent(
|
|
1414
|
+
"Read the latest completed Build session for task " + taskId + ".\n" +
|
|
1415
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
1416
|
+
"In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return exactly two sections, verbatim, with no commentary:\n" +
|
|
1417
|
+
"SHA: <the session's already_merged_sha field value, or the word null when it is null>\n" +
|
|
1418
|
+
"NOTES:\n<the session's notes field, verbatim>",
|
|
1419
|
+
{ key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
|
|
1420
|
+
);
|
|
1421
|
+
} catch (hydErr) {
|
|
1422
|
+
log("Hydration read failed (" + String(hydErr && hydErr.message || hydErr) + "); treating as none declared.");
|
|
1423
|
+
}
|
|
1424
|
+
var hydStr = hydResult ? ((typeof hydResult === "string") ? hydResult : JSON.stringify(hydResult)) : "";
|
|
1425
|
+
var hydSha = /^SHA:\s*([0-9a-f]{7,40})\s*$/im.exec(hydStr);
|
|
1426
|
+
if (hydSha) {
|
|
1427
|
+
alreadyMergedSha = hydSha[1].toLowerCase();
|
|
1428
|
+
log("Hydrated already-merged verification from structured session field: " + alreadyMergedSha);
|
|
1429
|
+
} else {
|
|
1430
|
+
var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
|
|
1431
|
+
if (hvm) {
|
|
1432
|
+
alreadyMergedSha = hvm[1].toLowerCase();
|
|
1433
|
+
log("Hydrated already-merged verification from Build session notes (fallback): " + alreadyMergedSha);
|
|
1434
|
+
}
|
|
1398
1435
|
}
|
|
1399
1436
|
}
|
|
1400
1437
|
instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
|
|
@@ -1537,6 +1574,85 @@ while (i < STEPS.length) {
|
|
|
1537
1574
|
// Skipped entirely when no lock was held — nothing merged, nothing
|
|
1538
1575
|
// to ship.
|
|
1539
1576
|
if (!publishSkippedNoLock) {
|
|
1577
|
+
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1578
|
+
// Minted once here (not re-minted per use site) so the ledger always
|
|
1579
|
+
// records the exact key that was issued — and so a re-minted duplicate
|
|
1580
|
+
// can never drift from it. Defined before the preflight so pre-trigger
|
|
1581
|
+
// parks (room #16 blocker 10) record the same attempt key.
|
|
1582
|
+
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
|
|
1583
|
+
// STEP 0.5 (mechanical, room #16 blocker 10): assert the artifact
|
|
1584
|
+
// target exists before any artifact_status / artifact_edit call. The
|
|
1585
|
+
// project was classified as an artifact surface (deploy_slug set),
|
|
1586
|
+
// but setup never provisioned the artifact — Publish then entered
|
|
1587
|
+
// the trigger path against a slug with no on-disk target and the
|
|
1588
|
+
// edit failed opaquely ("web artifact <slug> was not found on
|
|
1589
|
+
// disk"), which the ledger could only record as unknown. A missing
|
|
1590
|
+
// target is conclusive negative evidence: the edit provably did NOT
|
|
1591
|
+
// go through, so this parks rejected (not unknown) with the actual
|
|
1592
|
+
// missing path — no trigger issued, no blind retry, no observation
|
|
1593
|
+
// polling. The check is a pure filesystem stat; the path is
|
|
1594
|
+
// workflow-computed, never agent prose. An inconclusive check
|
|
1595
|
+
// (throw / unparseable signal) is fail-closed unknown: without
|
|
1596
|
+
// proof the target exists, no edit is issued. The slug is
|
|
1597
|
+
// interpolated into a shell command — a slug outside [a-zA-Z0-9_-]
|
|
1598
|
+
// (e.g. from a hand-edited space.json) is treated as inconclusive
|
|
1599
|
+
// rather than risking shell injection.
|
|
1600
|
+
var artifactTargetDir = "~/workspace/ts-spaces/" + PUBLISH_SLUG + "/";
|
|
1601
|
+
var preflightSignal = "";
|
|
1602
|
+
var preflightInconclusive = false;
|
|
1603
|
+
// Misconfiguration fast path: artifact surface with no slug is not a
|
|
1604
|
+
// signal problem — it's a project setup defect. Park rejected with a
|
|
1605
|
+
// truthful reason, not "inconclusive."
|
|
1606
|
+
if (!PUBLISH_SLUG) {
|
|
1607
|
+
await recordPublishLedger({
|
|
1608
|
+
commit: mergeCommitForPublish,
|
|
1609
|
+
attempt: rebuildAttemptKey,
|
|
1610
|
+
agent_id: null,
|
|
1611
|
+
applied_report: null,
|
|
1612
|
+
outcome: "rejected",
|
|
1613
|
+
detail: "artifact surface with empty deploy_slug (preflight): the project is classified as artifact but has no deploy_slug — misconfiguration, not a missing artifact. No edit was issued."
|
|
1614
|
+
}, totalReworkCount);
|
|
1615
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the project is classified as an artifact surface but has no deploy_slug. This is a project configuration defect — set a deploy_slug for the project, then re-run Publish. Human attention needed.");
|
|
1616
|
+
}
|
|
1617
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(PUBLISH_SLUG)) {
|
|
1618
|
+
preflightInconclusive = true;
|
|
1619
|
+
log("Publish artifact preflight for task " + taskId + ": PUBLISH_SLUG has an unsafe shape — inconclusive, fail-closed");
|
|
1620
|
+
} else try {
|
|
1621
|
+
var preflight = await agent(
|
|
1622
|
+
"Check whether the artifact target directory exists.\n" +
|
|
1623
|
+
"Run in shell: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/ && echo ARTIFACT_TARGET: present || echo ARTIFACT_TARGET: missing\n" +
|
|
1624
|
+
"Return JSON { \"signal\": \"<the exact ARTIFACT_TARGET line>\" } and nothing else.",
|
|
1625
|
+
{ key: attemptKey("publish-artifact-preflight-" + taskId, totalReworkCount), label: "Checking artifact target exists",
|
|
1626
|
+
schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
|
|
1627
|
+
);
|
|
1628
|
+
preflightSignal = String((preflight && preflight.signal) || "");
|
|
1629
|
+
} catch (preflightErr) {
|
|
1630
|
+
preflightInconclusive = true;
|
|
1631
|
+
log("Publish artifact preflight for task " + taskId + " threw (" + (preflightErr && preflightErr.message ? preflightErr.message : preflightErr) + ") — inconclusive, fail-closed");
|
|
1632
|
+
}
|
|
1633
|
+
if (!preflightInconclusive && /ARTIFACT_TARGET:\s*missing/.test(preflightSignal)) {
|
|
1634
|
+
await recordPublishLedger({
|
|
1635
|
+
commit: mergeCommitForPublish,
|
|
1636
|
+
attempt: rebuildAttemptKey,
|
|
1637
|
+
agent_id: null,
|
|
1638
|
+
applied_report: null,
|
|
1639
|
+
outcome: "rejected",
|
|
1640
|
+
detail: "artifact target directory missing (preflight): " + artifactTargetDir + " does not exist — setup never provisioned the artifact for deploy_slug " + PUBLISH_SLUG + ". The edit provably did not go through: no trigger issued, no blind retry"
|
|
1641
|
+
}, totalReworkCount);
|
|
1642
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target directory " + artifactTargetDir + " does not exist. The project is classified as an artifact surface (deploy_slug " + PUBLISH_SLUG + ") but setup never provisioned the artifact — this is conclusive (rejected, not unknown): no edit was issued. Create the artifact via the Muse UI (Publish edits an existing artifact; it never creates one), then re-run init and Publish. Human attention needed.");
|
|
1643
|
+
}
|
|
1644
|
+
if (preflightInconclusive || !/ARTIFACT_TARGET:\s*present/.test(preflightSignal)) {
|
|
1645
|
+
await recordPublishLedger({
|
|
1646
|
+
commit: mergeCommitForPublish,
|
|
1647
|
+
attempt: rebuildAttemptKey,
|
|
1648
|
+
agent_id: null,
|
|
1649
|
+
applied_report: null,
|
|
1650
|
+
outcome: "unknown",
|
|
1651
|
+
detail: "artifact preflight inconclusive (no parsable ARTIFACT_TARGET signal): target existence unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
|
|
1652
|
+
}, totalReworkCount);
|
|
1653
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target preflight was inconclusive (no parsable signal). Target existence is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
|
|
1654
|
+
}
|
|
1655
|
+
log("Publish artifact preflight for task " + taskId + ": target " + artifactTargetDir + " present");
|
|
1540
1656
|
// (below) the diff computation, rebuild trigger, application
|
|
1541
1657
|
// verification, bounded poll, and provenance stamp. The builder
|
|
1542
1658
|
// only makes the artifact_edit call and reports the applied
|
|
@@ -1647,14 +1763,10 @@ while (i < STEPS.length) {
|
|
|
1647
1763
|
"- After applying, rebuild and deploy.'\n" +
|
|
1648
1764
|
"Edit-request contract (read carefully):\n" +
|
|
1649
1765
|
"- Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — end your turn.\n" +
|
|
1766
|
+
"- If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: end your turn with exactly one line and nothing else: ARTIFACT_EDIT_REFUSED: <the refusal text, one line>.\n" +
|
|
1650
1767
|
"- If artifact_edit is not available after the load, do NOT improvise — end your turn.\n" +
|
|
1651
1768
|
"- You do NOT call setprovenance, artifact_inspect, or post-deploy yourself.\n" +
|
|
1652
1769
|
"No report is needed: do not return JSON, do not summarize what you did, do not echo the diff. End your turn after the artifact_edit call.\n";
|
|
1653
|
-
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1654
|
-
// Minted once here (not re-minted per use site) so the ledger always
|
|
1655
|
-
// records the exact key that was issued — and so a re-minted duplicate
|
|
1656
|
-
// can never drift from it.
|
|
1657
|
-
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
|
|
1658
1770
|
// The artifact build's agent_id, attributed to this edit by the
|
|
1659
1771
|
// workflow-owned observation below. The agent_id is the artifact
|
|
1660
1772
|
// system's in-flight correlation ID (research 2026-09-12):
|
|
@@ -1820,9 +1932,27 @@ while (i < STEPS.length) {
|
|
|
1820
1932
|
// re-trigger duplicated the edit on 2026-09-12).
|
|
1821
1933
|
var rebuildTrigger = null;
|
|
1822
1934
|
try {
|
|
1823
|
-
var
|
|
1824
|
-
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "")
|
|
1825
|
-
log("Publish rebuild trigger for task " + taskId + " returned (" +
|
|
1935
|
+
var triggerText = String(await agent(rebuildPrompt,
|
|
1936
|
+
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
|
|
1937
|
+
log("Publish rebuild trigger for task " + taskId + " returned (" + triggerText.length + " chars; awaited; scanned only for the explicit refusal signal)");
|
|
1938
|
+
// Explicit refusal (room #16 blocker 10): the child ends its turn
|
|
1939
|
+
// with ARTIFACT_EDIT_REFUSED when artifact_edit explicitly refused.
|
|
1940
|
+
// Conclusive negative evidence — the edit provably did NOT go
|
|
1941
|
+
// through — so this parks rejected and skips observation polling.
|
|
1942
|
+
// A missing/unparseable signal is NOT a refusal: it stays unknown
|
|
1943
|
+
// and fail-closed below.
|
|
1944
|
+
var refusalText = extractRefusal(triggerText);
|
|
1945
|
+
if (refusalText) {
|
|
1946
|
+
await recordPublishLedger({
|
|
1947
|
+
commit: mergeCommitForPublish,
|
|
1948
|
+
attempt: rebuildAttemptKey,
|
|
1949
|
+
agent_id: null,
|
|
1950
|
+
applied_report: null,
|
|
1951
|
+
outcome: "rejected",
|
|
1952
|
+
detail: "artifact_edit explicitly refused the edit (parsed ARTIFACT_EDIT_REFUSED signal): " + refusalText + " — conclusive negative: the edit provably did not go through, no observation polling, no blind retry"
|
|
1953
|
+
}, totalReworkCount);
|
|
1954
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": artifact_edit explicitly refused the edit (" + refusalText + "). This is conclusive (rejected, not unknown): the edit did not go through. Repair or provision the artifact target, then re-run Publish. Human attention needed.");
|
|
1955
|
+
}
|
|
1826
1956
|
} catch (triggerErr) {
|
|
1827
1957
|
log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
|
|
1828
1958
|
}
|
|
@@ -3062,8 +3192,12 @@ while (i < STEPS.length) {
|
|
|
3062
3192
|
"Update the session and log the event.\n" +
|
|
3063
3193
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
3064
3194
|
task_id: taskId,
|
|
3195
|
+
// Room #16 blocker 11: the workflow-verified already-merged sha as
|
|
3196
|
+
// structured control state. Only the Build gate sets alreadyMergedSha
|
|
3197
|
+
// (after the mechanical ancestor check); the API validates the shape
|
|
3198
|
+
// and a later write without the field never clears it (COALESCE).
|
|
3065
3199
|
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name,
|
|
3066
|
-
status: status, notes: summary },
|
|
3200
|
+
status: status, notes: summary, already_merged_sha: (step.name === "Build" ? alreadyMergedSha : null) },
|
|
3067
3201
|
event: { task_id: taskId, type: status, identity: step.identity,
|
|
3068
3202
|
message: step.name + " " + status + " by " + step.identity }
|
|
3069
3203
|
}),
|
|
@@ -3096,6 +3230,15 @@ while (i < STEPS.length) {
|
|
|
3096
3230
|
return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved.");
|
|
3097
3231
|
}
|
|
3098
3232
|
rejectionNotes = summary;
|
|
3233
|
+
// Already-merged corrective (room #16 blocker 11): when Review rejected
|
|
3234
|
+
// an empty branch but the work is already on main (the workflow verified
|
|
3235
|
+
// the sha), Wren must declare it — not re-implement or re-commit
|
|
3236
|
+
// already-landed work. Scoped to the empty-branch rejection; any other
|
|
3237
|
+
// rejection already carries its own specific notes.
|
|
3238
|
+
if (step.name === "Review" && alreadyMergedSha && /no commits ahead of main/i.test(summary)) {
|
|
3239
|
+
rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on main — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of main. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
|
|
3240
|
+
log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
|
|
3241
|
+
}
|
|
3099
3242
|
i = BUILD_INDEX;
|
|
3100
3243
|
log(step.name + " rejected — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
|
|
3101
3244
|
continue;
|
package/workflows/chore.js
CHANGED
|
@@ -601,14 +601,34 @@ function extractMarkerLines(workerText) {
|
|
|
601
601
|
// builder correctly makes no commit because the deliverable is already on
|
|
602
602
|
// main (a prior merge or hand-repair landed it), it declares
|
|
603
603
|
// `repo_diff: none (already-merged: <sha>)` naming the main commit that
|
|
604
|
-
// carries the work.
|
|
605
|
-
//
|
|
606
|
-
//
|
|
604
|
+
// carries the work. Room #16 blocker 11 (2026-09-18): the line anchor
|
|
605
|
+
// missed Wren's mid-paragraph declaration, and the persisted notes truncated
|
|
606
|
+
// the tail — so the anchor is gone and a sha followed by `)`, whitespace, or
|
|
607
|
+
// end-of-string (truncation) is accepted. The sha is hex-only (7-40 chars)
|
|
608
|
+
// so the workflow can interpolate it into the mechanical ancestor check
|
|
609
|
+
// without injection risk; a over-long hex run never matches (the lookahead
|
|
610
|
+
// fails on the extra hex char). Pure — pinned byte-identical across
|
|
611
|
+
// standard/bugfix/chore.
|
|
607
612
|
function extractAlreadyMerged(workerText) {
|
|
608
|
-
var m =
|
|
613
|
+
var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
|
|
609
614
|
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
610
615
|
}
|
|
611
616
|
|
|
617
|
+
// Explicit artifact refusal (room #16 blocker 10, 2026-09-18): the rebuild
|
|
618
|
+
// trigger child ends its turn with `ARTIFACT_EDIT_REFUSED: <text>` when
|
|
619
|
+
// artifact_edit explicitly refuses the edit (e.g. the artifact does not
|
|
620
|
+
// exist). A refusal is conclusive negative evidence — the edit provably did
|
|
621
|
+
// NOT go through — distinct from an unconsumed trigger return (unknown).
|
|
622
|
+
// Pure — pinned byte-identical across standard/bugfix/chore.
|
|
623
|
+
// The signal must be the ENTIRE trimmed turn output (not a line within prose):
|
|
624
|
+
// the trigger child is instructed to end its turn with exactly this line and
|
|
625
|
+
// nothing else. A confused child quoting the instructions back in prose must
|
|
626
|
+
// NOT produce a conclusive negative — that degrades to unknown (fail-closed).
|
|
627
|
+
function extractRefusal(workerText) {
|
|
628
|
+
var m = /^ARTIFACT_EDIT_REFUSED:\s*(.+?)\s*$/.exec(String(workerText || "").trim());
|
|
629
|
+
return m ? m[1].slice(0, 300) : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
612
632
|
// Worktree confinement: the Build agent must declare the exact worktree
|
|
613
633
|
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
614
634
|
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
@@ -1265,23 +1285,40 @@ while (i < STEPS.length) {
|
|
|
1265
1285
|
} else if (step.name === "Review") {
|
|
1266
1286
|
// Already-merged hydration: when this run did not execute Build itself
|
|
1267
1287
|
// (dispatcher resume at Review after a platform death between phases),
|
|
1268
|
-
// recover the workflow-
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1288
|
+
// recover the workflow-verified sha. Room #16 blocker 11: the structured
|
|
1289
|
+
// session field is read FIRST — the `already_merged_verified:` notes line
|
|
1290
|
+
// is only a fallback, because session notes are hard-capped at 3000
|
|
1291
|
+
// chars and a truthful declaration at the report's tail was silently
|
|
1292
|
+
// truncated. The structured value was written by the workflow after a
|
|
1293
|
+
// mechanical ancestor check — it is trusted; the builder's bare
|
|
1294
|
+
// declaration never is. Absent both, the mechanical fact below reads
|
|
1295
|
+
// "none declared" and Cass fails closed. The hydration read is best-effort:
|
|
1296
|
+
// a transport throw degrades to "none declared" rather than crashing Review.
|
|
1273
1297
|
if (!alreadyMergedSha) {
|
|
1274
|
-
var
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1298
|
+
var hydResult = null;
|
|
1299
|
+
try {
|
|
1300
|
+
hydResult = await agent(
|
|
1301
|
+
"Read the latest completed Build session for task " + taskId + ".\n" +
|
|
1302
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
1303
|
+
"In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return exactly two sections, verbatim, with no commentary:\n" +
|
|
1304
|
+
"SHA: <the session's already_merged_sha field value, or the word null when it is null>\n" +
|
|
1305
|
+
"NOTES:\n<the session's notes field, verbatim>",
|
|
1306
|
+
{ key: "hydrate-already-merged" + (reworkCount > 0 ? "-r" + reworkCount : ""), label: "Hydrating already-merged verification" }
|
|
1307
|
+
);
|
|
1308
|
+
} catch (hydErr) {
|
|
1309
|
+
log("Hydration read failed (" + String(hydErr && hydErr.message || hydErr) + "); treating as none declared.");
|
|
1310
|
+
}
|
|
1311
|
+
var hydStr = hydResult ? ((typeof hydResult === "string") ? hydResult : JSON.stringify(hydResult)) : "";
|
|
1312
|
+
var hydSha = /^SHA:\s*([0-9a-f]{7,40})\s*$/im.exec(hydStr);
|
|
1313
|
+
if (hydSha) {
|
|
1314
|
+
alreadyMergedSha = hydSha[1].toLowerCase();
|
|
1315
|
+
log("Hydrated already-merged verification from structured session field: " + alreadyMergedSha);
|
|
1316
|
+
} else {
|
|
1317
|
+
var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
|
|
1318
|
+
if (hvm) {
|
|
1319
|
+
alreadyMergedSha = hvm[1].toLowerCase();
|
|
1320
|
+
log("Hydrated already-merged verification from Build session notes (fallback): " + alreadyMergedSha);
|
|
1321
|
+
}
|
|
1285
1322
|
}
|
|
1286
1323
|
}
|
|
1287
1324
|
instructions = "Review independently and cold. No prior context from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
|
|
@@ -1424,6 +1461,85 @@ while (i < STEPS.length) {
|
|
|
1424
1461
|
// Skipped entirely when no lock was held — nothing merged, nothing
|
|
1425
1462
|
// to ship.
|
|
1426
1463
|
if (!publishSkippedNoLock) {
|
|
1464
|
+
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1465
|
+
// Minted once here (not re-minted per use site) so the ledger always
|
|
1466
|
+
// records the exact key that was issued — and so a re-minted duplicate
|
|
1467
|
+
// can never drift from it. Defined before the preflight so pre-trigger
|
|
1468
|
+
// parks (room #16 blocker 10) record the same attempt key.
|
|
1469
|
+
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, reworkCount);
|
|
1470
|
+
// STEP 0.5 (mechanical, room #16 blocker 10): assert the artifact
|
|
1471
|
+
// target exists before any artifact_status / artifact_edit call. The
|
|
1472
|
+
// project was classified as an artifact surface (deploy_slug set),
|
|
1473
|
+
// but setup never provisioned the artifact — Publish then entered
|
|
1474
|
+
// the trigger path against a slug with no on-disk target and the
|
|
1475
|
+
// edit failed opaquely ("web artifact <slug> was not found on
|
|
1476
|
+
// disk"), which the ledger could only record as unknown. A missing
|
|
1477
|
+
// target is conclusive negative evidence: the edit provably did NOT
|
|
1478
|
+
// go through, so this parks rejected (not unknown) with the actual
|
|
1479
|
+
// missing path — no trigger issued, no blind retry, no observation
|
|
1480
|
+
// polling. The check is a pure filesystem stat; the path is
|
|
1481
|
+
// workflow-computed, never agent prose. An inconclusive check
|
|
1482
|
+
// (throw / unparseable signal) is fail-closed unknown: without
|
|
1483
|
+
// proof the target exists, no edit is issued. The slug is
|
|
1484
|
+
// interpolated into a shell command — a slug outside [a-zA-Z0-9_-]
|
|
1485
|
+
// (e.g. from a hand-edited space.json) is treated as inconclusive
|
|
1486
|
+
// rather than risking shell injection.
|
|
1487
|
+
var artifactTargetDir = "~/workspace/ts-spaces/" + PUBLISH_SLUG + "/";
|
|
1488
|
+
var preflightSignal = "";
|
|
1489
|
+
var preflightInconclusive = false;
|
|
1490
|
+
// Misconfiguration fast path: artifact surface with no slug is not a
|
|
1491
|
+
// signal problem — it's a project setup defect. Park rejected with a
|
|
1492
|
+
// truthful reason, not "inconclusive."
|
|
1493
|
+
if (!PUBLISH_SLUG) {
|
|
1494
|
+
await recordPublishLedger({
|
|
1495
|
+
commit: mergeCommitForPublish,
|
|
1496
|
+
attempt: rebuildAttemptKey,
|
|
1497
|
+
agent_id: null,
|
|
1498
|
+
applied_report: null,
|
|
1499
|
+
outcome: "rejected",
|
|
1500
|
+
detail: "artifact surface with empty deploy_slug (preflight): the project is classified as artifact but has no deploy_slug — misconfiguration, not a missing artifact. No edit was issued."
|
|
1501
|
+
}, reworkCount);
|
|
1502
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the project is classified as an artifact surface but has no deploy_slug. This is a project configuration defect — set a deploy_slug for the project, then re-run Publish. Human attention needed.");
|
|
1503
|
+
}
|
|
1504
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(PUBLISH_SLUG)) {
|
|
1505
|
+
preflightInconclusive = true;
|
|
1506
|
+
log("Publish artifact preflight for task " + taskId + ": PUBLISH_SLUG has an unsafe shape — inconclusive, fail-closed");
|
|
1507
|
+
} else try {
|
|
1508
|
+
var preflight = await agent(
|
|
1509
|
+
"Check whether the artifact target directory exists.\n" +
|
|
1510
|
+
"Run in shell: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/ && echo ARTIFACT_TARGET: present || echo ARTIFACT_TARGET: missing\n" +
|
|
1511
|
+
"Return JSON { \"signal\": \"<the exact ARTIFACT_TARGET line>\" } and nothing else.",
|
|
1512
|
+
{ key: attemptKey("publish-artifact-preflight-" + taskId, reworkCount), label: "Checking artifact target exists",
|
|
1513
|
+
schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
|
|
1514
|
+
);
|
|
1515
|
+
preflightSignal = String((preflight && preflight.signal) || "");
|
|
1516
|
+
} catch (preflightErr) {
|
|
1517
|
+
preflightInconclusive = true;
|
|
1518
|
+
log("Publish artifact preflight for task " + taskId + " threw (" + (preflightErr && preflightErr.message ? preflightErr.message : preflightErr) + ") — inconclusive, fail-closed");
|
|
1519
|
+
}
|
|
1520
|
+
if (!preflightInconclusive && /ARTIFACT_TARGET:\s*missing/.test(preflightSignal)) {
|
|
1521
|
+
await recordPublishLedger({
|
|
1522
|
+
commit: mergeCommitForPublish,
|
|
1523
|
+
attempt: rebuildAttemptKey,
|
|
1524
|
+
agent_id: null,
|
|
1525
|
+
applied_report: null,
|
|
1526
|
+
outcome: "rejected",
|
|
1527
|
+
detail: "artifact target directory missing (preflight): " + artifactTargetDir + " does not exist — setup never provisioned the artifact for deploy_slug " + PUBLISH_SLUG + ". The edit provably did not go through: no trigger issued, no blind retry"
|
|
1528
|
+
}, reworkCount);
|
|
1529
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target directory " + artifactTargetDir + " does not exist. The project is classified as an artifact surface (deploy_slug " + PUBLISH_SLUG + ") but setup never provisioned the artifact — this is conclusive (rejected, not unknown): no edit was issued. Create the artifact via the Muse UI (Publish edits an existing artifact; it never creates one), then re-run init and Publish. Human attention needed.");
|
|
1530
|
+
}
|
|
1531
|
+
if (preflightInconclusive || !/ARTIFACT_TARGET:\s*present/.test(preflightSignal)) {
|
|
1532
|
+
await recordPublishLedger({
|
|
1533
|
+
commit: mergeCommitForPublish,
|
|
1534
|
+
attempt: rebuildAttemptKey,
|
|
1535
|
+
agent_id: null,
|
|
1536
|
+
applied_report: null,
|
|
1537
|
+
outcome: "unknown",
|
|
1538
|
+
detail: "artifact preflight inconclusive (no parsable ARTIFACT_TARGET signal): target existence unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
|
|
1539
|
+
}, reworkCount);
|
|
1540
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target preflight was inconclusive (no parsable signal). Target existence is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
|
|
1541
|
+
}
|
|
1542
|
+
log("Publish artifact preflight for task " + taskId + ": target " + artifactTargetDir + " present");
|
|
1427
1543
|
// (below) the diff computation, rebuild trigger, application
|
|
1428
1544
|
// verification, bounded poll, and provenance stamp. The builder
|
|
1429
1545
|
// only makes the artifact_edit call and reports the applied
|
|
@@ -1534,14 +1650,10 @@ while (i < STEPS.length) {
|
|
|
1534
1650
|
"- After applying, rebuild and deploy.'\n" +
|
|
1535
1651
|
"Edit-request contract (read carefully):\n" +
|
|
1536
1652
|
"- Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — end your turn.\n" +
|
|
1653
|
+
"- If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: end your turn with exactly one line and nothing else: ARTIFACT_EDIT_REFUSED: <the refusal text, one line>.\n" +
|
|
1537
1654
|
"- If artifact_edit is not available after the load, do NOT improvise — end your turn.\n" +
|
|
1538
1655
|
"- You do NOT call setprovenance, artifact_inspect, or post-deploy yourself.\n" +
|
|
1539
1656
|
"No report is needed: do not return JSON, do not summarize what you did, do not echo the diff. End your turn after the artifact_edit call.\n";
|
|
1540
|
-
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1541
|
-
// Minted once here (not re-minted per use site) so the ledger always
|
|
1542
|
-
// records the exact key that was issued — and so a re-minted duplicate
|
|
1543
|
-
// can never drift from it.
|
|
1544
|
-
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, reworkCount);
|
|
1545
1657
|
// The artifact build's agent_id, attributed to this edit by the
|
|
1546
1658
|
// workflow-owned observation below. The agent_id is the artifact
|
|
1547
1659
|
// system's in-flight correlation ID (research 2026-09-12):
|
|
@@ -1707,9 +1819,27 @@ while (i < STEPS.length) {
|
|
|
1707
1819
|
// re-trigger duplicated the edit on 2026-09-12).
|
|
1708
1820
|
var rebuildTrigger = null;
|
|
1709
1821
|
try {
|
|
1710
|
-
var
|
|
1711
|
-
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "")
|
|
1712
|
-
log("Publish rebuild trigger for task " + taskId + " returned (" +
|
|
1822
|
+
var triggerText = String(await agent(rebuildPrompt,
|
|
1823
|
+
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
|
|
1824
|
+
log("Publish rebuild trigger for task " + taskId + " returned (" + triggerText.length + " chars; awaited; scanned only for the explicit refusal signal)");
|
|
1825
|
+
// Explicit refusal (room #16 blocker 10): the child ends its turn
|
|
1826
|
+
// with ARTIFACT_EDIT_REFUSED when artifact_edit explicitly refused.
|
|
1827
|
+
// Conclusive negative evidence — the edit provably did NOT go
|
|
1828
|
+
// through — so this parks rejected and skips observation polling.
|
|
1829
|
+
// A missing/unparseable signal is NOT a refusal: it stays unknown
|
|
1830
|
+
// and fail-closed below.
|
|
1831
|
+
var refusalText = extractRefusal(triggerText);
|
|
1832
|
+
if (refusalText) {
|
|
1833
|
+
await recordPublishLedger({
|
|
1834
|
+
commit: mergeCommitForPublish,
|
|
1835
|
+
attempt: rebuildAttemptKey,
|
|
1836
|
+
agent_id: null,
|
|
1837
|
+
applied_report: null,
|
|
1838
|
+
outcome: "rejected",
|
|
1839
|
+
detail: "artifact_edit explicitly refused the edit (parsed ARTIFACT_EDIT_REFUSED signal): " + refusalText + " — conclusive negative: the edit provably did not go through, no observation polling, no blind retry"
|
|
1840
|
+
}, reworkCount);
|
|
1841
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": artifact_edit explicitly refused the edit (" + refusalText + "). This is conclusive (rejected, not unknown): the edit did not go through. Repair or provision the artifact target, then re-run Publish. Human attention needed.");
|
|
1842
|
+
}
|
|
1713
1843
|
} catch (triggerErr) {
|
|
1714
1844
|
log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
|
|
1715
1845
|
}
|
|
@@ -2624,7 +2754,11 @@ while (i < STEPS.length) {
|
|
|
2624
2754
|
"Update session and log event.\n" +
|
|
2625
2755
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2626
2756
|
task_id: taskId,
|
|
2627
|
-
|
|
2757
|
+
// Room #16 blocker 11: the workflow-verified already-merged sha as
|
|
2758
|
+
// structured control state. Only the Build gate sets alreadyMergedSha
|
|
2759
|
+
// (after the mechanical ancestor check); the API validates the shape
|
|
2760
|
+
// and a later write without the field never clears it (COALESCE).
|
|
2761
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary, already_merged_sha: (step.name === "Build" ? alreadyMergedSha : null) },
|
|
2628
2762
|
event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
|
|
2629
2763
|
}) + "\n",
|
|
2630
2764
|
{
|
|
@@ -2640,6 +2774,15 @@ while (i < STEPS.length) {
|
|
|
2640
2774
|
return await parkTask("Exceeded " + MAX_REWORK + " rework attempts after Review rejection. Worktree preserved.");
|
|
2641
2775
|
}
|
|
2642
2776
|
rejectionNotes = summary;
|
|
2777
|
+
// Already-merged corrective (room #16 blocker 11): when Review rejected
|
|
2778
|
+
// an empty branch but the work is already on main (the workflow verified
|
|
2779
|
+
// the sha), Wren must declare it — not re-implement or re-commit
|
|
2780
|
+
// already-landed work. Scoped to the empty-branch rejection; any other
|
|
2781
|
+
// rejection already carries its own specific notes.
|
|
2782
|
+
if (step.name === "Review" && alreadyMergedSha && /no commits ahead of main/i.test(summary)) {
|
|
2783
|
+
rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on main — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of main. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
|
|
2784
|
+
log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
|
|
2785
|
+
}
|
|
2643
2786
|
i = BUILD_INDEX;
|
|
2644
2787
|
log("Review rejected — bouncing to Build (rework #" + reworkCount + ")");
|
|
2645
2788
|
continue;
|
package/workflows/crew-init.js
CHANGED
|
@@ -429,6 +429,45 @@ if (projectResult && projectResult.action === "created" &&
|
|
|
429
429
|
}
|
|
430
430
|
} // end dashboard-mode project registration
|
|
431
431
|
|
|
432
|
+
// ── Artifact target verification (room #16 blocker 10, 2026-09-18) ──
|
|
433
|
+
// Setup owns the invariant: deploy_slug ⇒ artifact exists. The dashboard
|
|
434
|
+
// project is registered with deploy_slug = dashboardSlug, but registration
|
|
435
|
+
// alone does not create the artifact — the artifact is a platform primitive
|
|
436
|
+
// created by the human via the Muse UI (docs/guide.md prerequisite: "A task
|
|
437
|
+
// service artifact already created"). If the target directory is missing,
|
|
438
|
+
// init blocks loudly: a dangling deploy_slug would otherwise let the first
|
|
439
|
+
// task reach Publish and fail opaquely. This is not a provisioner — the
|
|
440
|
+
// human creates the artifact; init only verifies and refuses to proceed
|
|
441
|
+
// without it.
|
|
442
|
+
if (dashboardSlug) {
|
|
443
|
+
// The slug is interpolated into a shell command — reject an unsafe shape
|
|
444
|
+
// rather than risking injection (dashboardSlug comes from init args).
|
|
445
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(dashboardSlug)) {
|
|
446
|
+
return { __hatchWorkflowControl: "blocked", result: {
|
|
447
|
+
blocked_reason: "Dashboard slug has an unsafe shape",
|
|
448
|
+
message: "dashboardSlug \"" + dashboardSlug + "\" contains characters outside [a-zA-Z0-9_-]. The slug is used in shell paths and as the artifact key — choose a safe slug and re-run init."
|
|
449
|
+
} };
|
|
450
|
+
}
|
|
451
|
+
var artifactTargetCheck = await agent(
|
|
452
|
+
"Verify the dashboard artifact target exists.\n" +
|
|
453
|
+
"Run in shell: test -d ~/workspace/ts-spaces/" + dashboardSlug + "/ && echo ARTIFACT_TARGET: present || echo ARTIFACT_TARGET: missing\n" +
|
|
454
|
+
"Return JSON { \"signal\": \"<the exact ARTIFACT_TARGET line>\" } and nothing else.",
|
|
455
|
+
{
|
|
456
|
+
key: "artifact-target-verify",
|
|
457
|
+
label: "Verifying dashboard artifact target exists",
|
|
458
|
+
schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] }
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
var artifactSignal = String((artifactTargetCheck && artifactTargetCheck.signal) || "");
|
|
462
|
+
if (!/ARTIFACT_TARGET:\s*present/.test(artifactSignal)) {
|
|
463
|
+
return { __hatchWorkflowControl: "blocked", result: {
|
|
464
|
+
blocked_reason: "Dashboard artifact target missing",
|
|
465
|
+
message: "The dashboard project is registered with deploy_slug \"" + dashboardSlug + "\", but the artifact target directory ~/workspace/ts-spaces/" + dashboardSlug + "/ does not exist. An artifact is a Muse platform primitive (a hosted web app) — the crew never creates artifacts, it only edits existing ones. Create the artifact from your dashboard repo via the Muse UI first (see docs/guide.md prerequisites), then re-run init. Init will not complete with a deploy_slug that points to nothing: the first task's Publish would otherwise fail with a confusing error."
|
|
466
|
+
} };
|
|
467
|
+
}
|
|
468
|
+
log("Artifact target verified: ~/workspace/ts-spaces/" + dashboardSlug + "/ exists");
|
|
469
|
+
}
|
|
470
|
+
|
|
432
471
|
// ── Project repo setup (2026-09-18) ─────────────────────────────────
|
|
433
472
|
// One deterministic composer — lib/setup-project-repo.js — scaffolds
|
|
434
473
|
// $REPO/.orchestration/, applies the consented .gitignore update, and COMMITS
|
package/workflows/standard.js
CHANGED
|
@@ -561,14 +561,34 @@ function extractMarkerLines(workerText) {
|
|
|
561
561
|
// builder correctly makes no commit because the deliverable is already on
|
|
562
562
|
// main (a prior merge or hand-repair landed it), it declares
|
|
563
563
|
// `repo_diff: none (already-merged: <sha>)` naming the main commit that
|
|
564
|
-
// carries the work.
|
|
565
|
-
//
|
|
566
|
-
//
|
|
564
|
+
// carries the work. Room #16 blocker 11 (2026-09-18): the line anchor
|
|
565
|
+
// missed Wren's mid-paragraph declaration, and the persisted notes truncated
|
|
566
|
+
// the tail — so the anchor is gone and a sha followed by `)`, whitespace, or
|
|
567
|
+
// end-of-string (truncation) is accepted. The sha is hex-only (7-40 chars)
|
|
568
|
+
// so the workflow can interpolate it into the mechanical ancestor check
|
|
569
|
+
// without injection risk; a over-long hex run never matches (the lookahead
|
|
570
|
+
// fails on the extra hex char). Pure — pinned byte-identical across
|
|
571
|
+
// standard/bugfix/chore.
|
|
567
572
|
function extractAlreadyMerged(workerText) {
|
|
568
|
-
var m =
|
|
573
|
+
var m = /repo_diff:\s*none\s*\(already-merged:\s*([0-9a-f]{7,40})(?=[\s)]|$)/i.exec(workerText || "");
|
|
569
574
|
return m ? { sha: m[1].toLowerCase() } : { sha: null };
|
|
570
575
|
}
|
|
571
576
|
|
|
577
|
+
// Explicit artifact refusal (room #16 blocker 10, 2026-09-18): the rebuild
|
|
578
|
+
// trigger child ends its turn with `ARTIFACT_EDIT_REFUSED: <text>` when
|
|
579
|
+
// artifact_edit explicitly refuses the edit (e.g. the artifact does not
|
|
580
|
+
// exist). A refusal is conclusive negative evidence — the edit provably did
|
|
581
|
+
// NOT go through — distinct from an unconsumed trigger return (unknown).
|
|
582
|
+
// Pure — pinned byte-identical across standard/bugfix/chore.
|
|
583
|
+
// The signal must be the ENTIRE trimmed turn output (not a line within prose):
|
|
584
|
+
// the trigger child is instructed to end its turn with exactly this line and
|
|
585
|
+
// nothing else. A confused child quoting the instructions back in prose must
|
|
586
|
+
// NOT produce a conclusive negative — that degrades to unknown (fail-closed).
|
|
587
|
+
function extractRefusal(workerText) {
|
|
588
|
+
var m = /^ARTIFACT_EDIT_REFUSED:\s*(.+?)\s*$/.exec(String(workerText || "").trim());
|
|
589
|
+
return m ? m[1].slice(0, 300) : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
572
592
|
// Worktree confinement: the Build agent must declare the exact worktree
|
|
573
593
|
// path it built in on a `worktree:` marker line. The workflow compares it
|
|
574
594
|
// against WORKTREE_HINT mechanically (exact string match) — never by
|
|
@@ -1259,23 +1279,40 @@ while (i < STEPS.length) {
|
|
|
1259
1279
|
} else if (step.name === "Review") {
|
|
1260
1280
|
// Already-merged hydration: when this run did not execute Build itself
|
|
1261
1281
|
// (dispatcher resume at Review after a platform death between phases),
|
|
1262
|
-
// recover the workflow-
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1282
|
+
// recover the workflow-verified sha. Room #16 blocker 11: the structured
|
|
1283
|
+
// session field is read FIRST — the `already_merged_verified:` notes line
|
|
1284
|
+
// is only a fallback, because session notes are hard-capped at 3000
|
|
1285
|
+
// chars and a truthful declaration at the report's tail was silently
|
|
1286
|
+
// truncated. The structured value was written by the workflow after a
|
|
1287
|
+
// mechanical ancestor check — it is trusted; the builder's bare
|
|
1288
|
+
// declaration never is. Absent both, the mechanical fact below reads
|
|
1289
|
+
// "none declared" and Cass fails closed. The hydration read is best-effort:
|
|
1290
|
+
// a transport throw degrades to "none declared" rather than crashing Review.
|
|
1267
1291
|
if (!alreadyMergedSha) {
|
|
1268
|
-
var
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1292
|
+
var hydResult = null;
|
|
1293
|
+
try {
|
|
1294
|
+
hydResult = await agent(
|
|
1295
|
+
"Read the latest completed Build session for task " + taskId + ".\n" +
|
|
1296
|
+
"Run in shell and return the stdout verbatim:\n" + crewCmd("get-state", { events_limit: 1 }) + "\n" +
|
|
1297
|
+
"In the returned sessions array, find the most recent session (by started_at) with task_id \"" + taskId + "\", step \"Build\", and status \"completed\". Return exactly two sections, verbatim, with no commentary:\n" +
|
|
1298
|
+
"SHA: <the session's already_merged_sha field value, or the word null when it is null>\n" +
|
|
1299
|
+
"NOTES:\n<the session's notes field, verbatim>",
|
|
1300
|
+
{ key: "hydrate-already-merged" + (totalReworkCount > 0 ? "-r" + totalReworkCount : ""), label: "Hydrating already-merged verification" }
|
|
1301
|
+
);
|
|
1302
|
+
} catch (hydErr) {
|
|
1303
|
+
log("Hydration read failed (" + String(hydErr && hydErr.message || hydErr) + "); treating as none declared.");
|
|
1304
|
+
}
|
|
1305
|
+
var hydStr = hydResult ? ((typeof hydResult === "string") ? hydResult : JSON.stringify(hydResult)) : "";
|
|
1306
|
+
var hydSha = /^SHA:\s*([0-9a-f]{7,40})\s*$/im.exec(hydStr);
|
|
1307
|
+
if (hydSha) {
|
|
1308
|
+
alreadyMergedSha = hydSha[1].toLowerCase();
|
|
1309
|
+
log("Hydrated already-merged verification from structured session field: " + alreadyMergedSha);
|
|
1310
|
+
} else {
|
|
1311
|
+
var hvm = /already_merged_verified:\s*([0-9a-f]{7,40})/i.exec(hydStr);
|
|
1312
|
+
if (hvm) {
|
|
1313
|
+
alreadyMergedSha = hvm[1].toLowerCase();
|
|
1314
|
+
log("Hydrated already-merged verification from Build session notes (fallback): " + alreadyMergedSha);
|
|
1315
|
+
}
|
|
1279
1316
|
}
|
|
1280
1317
|
}
|
|
1281
1318
|
instructions = "Review independently and cold. You have NOT seen any reasoning from the builder.\nDo NOT access the task dashboard, event log, or any comments. Your review is based solely on the spec and the code.\n\n" +
|
|
@@ -1418,6 +1455,85 @@ while (i < STEPS.length) {
|
|
|
1418
1455
|
// Skipped entirely when no lock was held — nothing merged, nothing
|
|
1419
1456
|
// to ship.
|
|
1420
1457
|
if (!publishSkippedNoLock) {
|
|
1458
|
+
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1459
|
+
// Minted once here (not re-minted per use site) so the ledger always
|
|
1460
|
+
// records the exact key that was issued — and so a re-minted duplicate
|
|
1461
|
+
// can never drift from it. Defined before the preflight so pre-trigger
|
|
1462
|
+
// parks (room #16 blocker 10) record the same attempt key.
|
|
1463
|
+
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
|
|
1464
|
+
// STEP 0.5 (mechanical, room #16 blocker 10): assert the artifact
|
|
1465
|
+
// target exists before any artifact_status / artifact_edit call. The
|
|
1466
|
+
// project was classified as an artifact surface (deploy_slug set),
|
|
1467
|
+
// but setup never provisioned the artifact — Publish then entered
|
|
1468
|
+
// the trigger path against a slug with no on-disk target and the
|
|
1469
|
+
// edit failed opaquely ("web artifact <slug> was not found on
|
|
1470
|
+
// disk"), which the ledger could only record as unknown. A missing
|
|
1471
|
+
// target is conclusive negative evidence: the edit provably did NOT
|
|
1472
|
+
// go through, so this parks rejected (not unknown) with the actual
|
|
1473
|
+
// missing path — no trigger issued, no blind retry, no observation
|
|
1474
|
+
// polling. The check is a pure filesystem stat; the path is
|
|
1475
|
+
// workflow-computed, never agent prose. An inconclusive check
|
|
1476
|
+
// (throw / unparseable signal) is fail-closed unknown: without
|
|
1477
|
+
// proof the target exists, no edit is issued. The slug is
|
|
1478
|
+
// interpolated into a shell command — a slug outside [a-zA-Z0-9_-]
|
|
1479
|
+
// (e.g. from a hand-edited space.json) is treated as inconclusive
|
|
1480
|
+
// rather than risking shell injection.
|
|
1481
|
+
var artifactTargetDir = "~/workspace/ts-spaces/" + PUBLISH_SLUG + "/";
|
|
1482
|
+
var preflightSignal = "";
|
|
1483
|
+
var preflightInconclusive = false;
|
|
1484
|
+
// Misconfiguration fast path: artifact surface with no slug is not a
|
|
1485
|
+
// signal problem — it's a project setup defect. Park rejected with a
|
|
1486
|
+
// truthful reason, not "inconclusive."
|
|
1487
|
+
if (!PUBLISH_SLUG) {
|
|
1488
|
+
await recordPublishLedger({
|
|
1489
|
+
commit: mergeCommitForPublish,
|
|
1490
|
+
attempt: rebuildAttemptKey,
|
|
1491
|
+
agent_id: null,
|
|
1492
|
+
applied_report: null,
|
|
1493
|
+
outcome: "rejected",
|
|
1494
|
+
detail: "artifact surface with empty deploy_slug (preflight): the project is classified as artifact but has no deploy_slug — misconfiguration, not a missing artifact. No edit was issued."
|
|
1495
|
+
}, totalReworkCount);
|
|
1496
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the project is classified as an artifact surface but has no deploy_slug. This is a project configuration defect — set a deploy_slug for the project, then re-run Publish. Human attention needed.");
|
|
1497
|
+
}
|
|
1498
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(PUBLISH_SLUG)) {
|
|
1499
|
+
preflightInconclusive = true;
|
|
1500
|
+
log("Publish artifact preflight for task " + taskId + ": PUBLISH_SLUG has an unsafe shape — inconclusive, fail-closed");
|
|
1501
|
+
} else try {
|
|
1502
|
+
var preflight = await agent(
|
|
1503
|
+
"Check whether the artifact target directory exists.\n" +
|
|
1504
|
+
"Run in shell: test -d ~/workspace/ts-spaces/" + PUBLISH_SLUG + "/ && echo ARTIFACT_TARGET: present || echo ARTIFACT_TARGET: missing\n" +
|
|
1505
|
+
"Return JSON { \"signal\": \"<the exact ARTIFACT_TARGET line>\" } and nothing else.",
|
|
1506
|
+
{ key: attemptKey("publish-artifact-preflight-" + taskId, totalReworkCount), label: "Checking artifact target exists",
|
|
1507
|
+
schema: { type: "object", properties: { signal: { type: "string" } }, required: ["signal"] } }
|
|
1508
|
+
);
|
|
1509
|
+
preflightSignal = String((preflight && preflight.signal) || "");
|
|
1510
|
+
} catch (preflightErr) {
|
|
1511
|
+
preflightInconclusive = true;
|
|
1512
|
+
log("Publish artifact preflight for task " + taskId + " threw (" + (preflightErr && preflightErr.message ? preflightErr.message : preflightErr) + ") — inconclusive, fail-closed");
|
|
1513
|
+
}
|
|
1514
|
+
if (!preflightInconclusive && /ARTIFACT_TARGET:\s*missing/.test(preflightSignal)) {
|
|
1515
|
+
await recordPublishLedger({
|
|
1516
|
+
commit: mergeCommitForPublish,
|
|
1517
|
+
attempt: rebuildAttemptKey,
|
|
1518
|
+
agent_id: null,
|
|
1519
|
+
applied_report: null,
|
|
1520
|
+
outcome: "rejected",
|
|
1521
|
+
detail: "artifact target directory missing (preflight): " + artifactTargetDir + " does not exist — setup never provisioned the artifact for deploy_slug " + PUBLISH_SLUG + ". The edit provably did not go through: no trigger issued, no blind retry"
|
|
1522
|
+
}, totalReworkCount);
|
|
1523
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target directory " + artifactTargetDir + " does not exist. The project is classified as an artifact surface (deploy_slug " + PUBLISH_SLUG + ") but setup never provisioned the artifact — this is conclusive (rejected, not unknown): no edit was issued. Create the artifact via the Muse UI (Publish edits an existing artifact; it never creates one), then re-run init and Publish. Human attention needed.");
|
|
1524
|
+
}
|
|
1525
|
+
if (preflightInconclusive || !/ARTIFACT_TARGET:\s*present/.test(preflightSignal)) {
|
|
1526
|
+
await recordPublishLedger({
|
|
1527
|
+
commit: mergeCommitForPublish,
|
|
1528
|
+
attempt: rebuildAttemptKey,
|
|
1529
|
+
agent_id: null,
|
|
1530
|
+
applied_report: null,
|
|
1531
|
+
outcome: "unknown",
|
|
1532
|
+
detail: "artifact preflight inconclusive (no parsable ARTIFACT_TARGET signal): target existence unproven, so the trigger was NOT issued; unknown parks fail closed with no blind retry"
|
|
1533
|
+
}, totalReworkCount);
|
|
1534
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": the artifact target preflight was inconclusive (no parsable signal). Target existence is unproven, so no edit was issued and nothing was retried blindly. Human attention needed.");
|
|
1535
|
+
}
|
|
1536
|
+
log("Publish artifact preflight for task " + taskId + ": target " + artifactTargetDir + " present");
|
|
1421
1537
|
// (below) the diff computation, rebuild trigger, application
|
|
1422
1538
|
// verification, bounded poll, and provenance stamp. The builder
|
|
1423
1539
|
// only makes the artifact_edit call and reports the applied
|
|
@@ -1528,14 +1644,10 @@ while (i < STEPS.length) {
|
|
|
1528
1644
|
"- After applying, rebuild and deploy.'\n" +
|
|
1529
1645
|
"Edit-request contract (read carefully):\n" +
|
|
1530
1646
|
"- Call artifact_edit exactly once with the slug and verbatim_request above. Never retry the edit yourself: if the edit is not accepted, do NOT call artifact_edit again — end your turn.\n" +
|
|
1647
|
+
"- If artifact_edit explicitly refuses the edit (the call is rejected — e.g. the artifact does not exist), do NOT call artifact_edit again: end your turn with exactly one line and nothing else: ARTIFACT_EDIT_REFUSED: <the refusal text, one line>.\n" +
|
|
1531
1648
|
"- If artifact_edit is not available after the load, do NOT improvise — end your turn.\n" +
|
|
1532
1649
|
"- You do NOT call setprovenance, artifact_inspect, or post-deploy yourself.\n" +
|
|
1533
1650
|
"No report is needed: do not return JSON, do not summarize what you did, do not echo the diff. End your turn after the artifact_edit call.\n";
|
|
1534
|
-
// The trigger key of the attempt that last ran, for the publish ledger.
|
|
1535
|
-
// Minted once here (not re-minted per use site) so the ledger always
|
|
1536
|
-
// records the exact key that was issued — and so a re-minted duplicate
|
|
1537
|
-
// can never drift from it.
|
|
1538
|
-
var rebuildAttemptKey = attemptKey("publish-artifact-rebuild-" + taskId, totalReworkCount);
|
|
1539
1651
|
// The artifact build's agent_id, attributed to this edit by the
|
|
1540
1652
|
// workflow-owned observation below. The agent_id is the artifact
|
|
1541
1653
|
// system's in-flight correlation ID (research 2026-09-12):
|
|
@@ -1701,9 +1813,27 @@ while (i < STEPS.length) {
|
|
|
1701
1813
|
// re-trigger duplicated the edit on 2026-09-12).
|
|
1702
1814
|
var rebuildTrigger = null;
|
|
1703
1815
|
try {
|
|
1704
|
-
var
|
|
1705
|
-
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "")
|
|
1706
|
-
log("Publish rebuild trigger for task " + taskId + " returned (" +
|
|
1816
|
+
var triggerText = String(await agent(rebuildPrompt,
|
|
1817
|
+
{ key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
|
|
1818
|
+
log("Publish rebuild trigger for task " + taskId + " returned (" + triggerText.length + " chars; awaited; scanned only for the explicit refusal signal)");
|
|
1819
|
+
// Explicit refusal (room #16 blocker 10): the child ends its turn
|
|
1820
|
+
// with ARTIFACT_EDIT_REFUSED when artifact_edit explicitly refused.
|
|
1821
|
+
// Conclusive negative evidence — the edit provably did NOT go
|
|
1822
|
+
// through — so this parks rejected and skips observation polling.
|
|
1823
|
+
// A missing/unparseable signal is NOT a refusal: it stays unknown
|
|
1824
|
+
// and fail-closed below.
|
|
1825
|
+
var refusalText = extractRefusal(triggerText);
|
|
1826
|
+
if (refusalText) {
|
|
1827
|
+
await recordPublishLedger({
|
|
1828
|
+
commit: mergeCommitForPublish,
|
|
1829
|
+
attempt: rebuildAttemptKey,
|
|
1830
|
+
agent_id: null,
|
|
1831
|
+
applied_report: null,
|
|
1832
|
+
outcome: "rejected",
|
|
1833
|
+
detail: "artifact_edit explicitly refused the edit (parsed ARTIFACT_EDIT_REFUSED signal): " + refusalText + " — conclusive negative: the edit provably did not go through, no observation polling, no blind retry"
|
|
1834
|
+
}, totalReworkCount);
|
|
1835
|
+
return await parkTask("Publish cannot proceed for task " + taskId + ": artifact_edit explicitly refused the edit (" + refusalText + "). This is conclusive (rejected, not unknown): the edit did not go through. Repair or provision the artifact target, then re-run Publish. Human attention needed.");
|
|
1836
|
+
}
|
|
1707
1837
|
} catch (triggerErr) {
|
|
1708
1838
|
log("Publish rebuild trigger for task " + taskId + " threw (" + (triggerErr && triggerErr.message ? triggerErr.message : triggerErr) + ") — outcome unknown until observation confirms it; the edit may have gone through");
|
|
1709
1839
|
}
|
|
@@ -2818,7 +2948,11 @@ while (i < STEPS.length) {
|
|
|
2818
2948
|
"Update the session and log the event.\n" +
|
|
2819
2949
|
"Run in shell and return the stdout verbatim:\n" + crewCmd("record-phase", {
|
|
2820
2950
|
task_id: taskId,
|
|
2821
|
-
|
|
2951
|
+
// Room #16 blocker 11: the workflow-verified already-merged sha as
|
|
2952
|
+
// structured control state. Only the Build gate sets alreadyMergedSha
|
|
2953
|
+
// (after the mechanical ancestor check); the API validates the shape
|
|
2954
|
+
// and a later write without the field never clears it (COALESCE).
|
|
2955
|
+
session: { id: activeSessionId, task_id: taskId, identity: step.identity, step: step.name, status: status, notes: summary, already_merged_sha: (step.name === "Build" ? alreadyMergedSha : null) },
|
|
2822
2956
|
event: { task_id: taskId, type: status, identity: step.identity, message: step.name + " " + status + " by " + step.identity }
|
|
2823
2957
|
}),
|
|
2824
2958
|
{
|
|
@@ -2835,6 +2969,15 @@ while (i < STEPS.length) {
|
|
|
2835
2969
|
return await parkTask("Exceeded shared rework budget (" + MAX_TOTAL_REWORK + " total rework attempts across Review and QA) after " + step.name + " rejection. Worktree preserved.");
|
|
2836
2970
|
}
|
|
2837
2971
|
rejectionNotes = summary;
|
|
2972
|
+
// Already-merged corrective (room #16 blocker 11): when Review rejected
|
|
2973
|
+
// an empty branch but the work is already on main (the workflow verified
|
|
2974
|
+
// the sha), Wren must declare it — not re-implement or re-commit
|
|
2975
|
+
// already-landed work. Scoped to the empty-branch rejection; any other
|
|
2976
|
+
// rejection already carries its own specific notes.
|
|
2977
|
+
if (step.name === "Review" && alreadyMergedSha && /no commits ahead of main/i.test(summary)) {
|
|
2978
|
+
rejectionNotes += "\n\nCORRECTIVE (from the workflow, not the reviewer): the deliverable is already on main — the workflow mechanically verified that " + alreadyMergedSha + " is an ancestor of main. Do NOT re-implement the work and do NOT create a new commit for it. In your Build report, declare exactly: repo_diff: none (already-merged: " + alreadyMergedSha + ") — then end with VERDICT: PASS.";
|
|
2979
|
+
log("Rework corrective appended for task " + taskId + ": already-merged " + alreadyMergedSha + " — Wren must declare, not rebuild");
|
|
2980
|
+
}
|
|
2838
2981
|
i = BUILD_INDEX;
|
|
2839
2982
|
log(step.name + " rejected — bouncing to Build (rework #" + totalReworkCount + " of " + MAX_TOTAL_REWORK + ")");
|
|
2840
2983
|
continue;
|