muse-crew 0.14.6 → 0.14.7

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/lib/crew-api.js CHANGED
@@ -338,6 +338,74 @@ function openDb(crewHome) {
338
338
  }
339
339
  // Legacy global-provenance backfill (watermarked, runs at most once).
340
340
  backfillProjectProvenance(db);
341
+ // Migration: cascade-park columns on tasks (room #26 blocker 36,
342
+ // 2026-09-21). A parked dep strands its todo dependents with zero
343
+ // telemetry; parking a task now cascade-parks them with structured
344
+ // attribution on the parked state (park_reason='dep_parked' +
345
+ // park_dep_id). Same idempotent PRAGMA-check pattern; ADD COLUMN
346
+ // without a default leaves existing rows NULL ("ordinary park", never
347
+ // fabricated). The pairing invariant lives in crew-api.js — SQLite
348
+ // forbids cross-column CHECKs on ADD COLUMN (verified live: "error in
349
+ // table after add column: no such column").
350
+ const taskCols = db.prepare("PRAGMA table_info(tasks)").all();
351
+ if (!taskCols.some((c) => c.name === "park_reason")) {
352
+ try {
353
+ db.exec("ALTER TABLE tasks ADD COLUMN park_reason TEXT CHECK (park_reason IS NULL OR park_reason = 'dep_parked')");
354
+ } catch (e) {
355
+ // Another process may have added it concurrently; ignore duplicate-column errors.
356
+ if (!/duplicate column name/i.test(e.message)) throw e;
357
+ }
358
+ }
359
+ if (!taskCols.some((c) => c.name === "park_dep_id")) {
360
+ try {
361
+ db.exec("ALTER TABLE tasks ADD COLUMN park_dep_id TEXT");
362
+ } catch (e) {
363
+ // Another process may have added it concurrently; ignore duplicate-column errors.
364
+ if (!/duplicate column name/i.test(e.message)) throw e;
365
+ }
366
+ }
367
+ // Migration: structured verdict records (room #26 blocker 33,
368
+ // 2026-09-21). Review verdict grounds were destroyed by the 2000-char
369
+ // session-note truncation — a park on a verified-correct implementation
370
+ // was unauditable. record-phase now optionally carries the full grounds
371
+ // and writes them here, uncapped, in the same transaction as the session
372
+ // note. CREATE TABLE IF NOT EXISTS is natively idempotent (no PRAGMA
373
+ // dance needed, race-safe). One verdict per recording session
374
+ // (UNIQUE(session_id)): a re-recorded phase for the same session is the
375
+ // same evidence, so the first write wins and rows are never revised; a
376
+ // new Review execution claims a new session and gets its own row. New
377
+ // table: no backfill — pre-migration verdicts are unrecoverable, which
378
+ // is the point of this fix.
379
+ db.exec(`CREATE TABLE IF NOT EXISTS verdicts (
380
+ id TEXT PRIMARY KEY,
381
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
382
+ step TEXT NOT NULL,
383
+ attempt INTEGER NOT NULL CHECK (attempt >= 0),
384
+ reviewer TEXT NOT NULL,
385
+ verdict TEXT NOT NULL CHECK (verdict IN ('PASS','FAIL','INDETERMINATE')),
386
+ grounds TEXT NOT NULL,
387
+ summary TEXT NOT NULL,
388
+ session_id TEXT NOT NULL,
389
+ created_at TEXT NOT NULL,
390
+ UNIQUE(session_id)
391
+ )`);
392
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_verdicts_task ON verdicts(task_id)`);
393
+ // Migration: review_basis on verdicts (room #26 blocker 34 redesign,
394
+ // 2026-09-21). The workflows were already sending review_basis in the
395
+ // verdict payload and the tests pinned "verdict row carries
396
+ // review_basis", but validateVerdictPayload dropped it and the table had
397
+ // no column — the structured basis never landed. Same idempotent
398
+ // PRAGMA-check pattern as the cascade-park columns; NULL for legacy
399
+ // rows (unclassified), never fabricated.
400
+ const verdictCols = db.prepare("PRAGMA table_info(verdicts)").all();
401
+ if (!verdictCols.some((c) => c.name === "review_basis")) {
402
+ try {
403
+ db.exec("ALTER TABLE verdicts ADD COLUMN review_basis TEXT");
404
+ } catch (e) {
405
+ // Another process may have added it concurrently; ignore duplicate-column errors.
406
+ if (!/duplicate column name/i.test(e.message)) throw e;
407
+ }
408
+ }
341
409
  }
342
410
  return db;
343
411
  }
@@ -397,6 +465,9 @@ function mapTask(row, taskStates = new Map()) {
397
465
  deps,
398
466
  filed_by: row.filed_by,
399
467
  retry_reset_at: row.retry_reset_at,
468
+ // Room #26 blocker 36: cascade-park attribution (NULL = ordinary park).
469
+ park_reason: row.park_reason ?? null,
470
+ park_dep_id: row.park_dep_id ?? null,
400
471
  blocked: deps.some((id) => taskStates.get(id) !== "done"),
401
472
  created_at: row.created_at,
402
473
  updated_at: row.updated_at,
@@ -434,6 +505,135 @@ function mapEvent(row) {
434
505
  };
435
506
  }
436
507
 
508
+ // Room #26 blocker 33 (2026-09-21): structured, non-lossy verdict records.
509
+ // The session note is truncated at 2000 chars by workflow design; the
510
+ // verdict row carries the full grounds, uncapped.
511
+ function mapVerdict(row) {
512
+ if (!row) return null;
513
+ return {
514
+ id: row.id,
515
+ task_id: row.task_id,
516
+ step: row.step,
517
+ attempt: row.attempt,
518
+ reviewer: row.reviewer,
519
+ verdict: row.verdict,
520
+ grounds: row.grounds,
521
+ review_basis: row.review_basis ?? null,
522
+ summary: row.summary,
523
+ session_id: row.session_id,
524
+ created_at: row.created_at,
525
+ };
526
+ }
527
+
528
+ // RUN-CLOSEOUT ON PARK (room #26 blocker 37, 2026-09-21). Park never
529
+ // closed workflow_runs rows — every park entry point left the task's open
530
+ // rows at 'running' forever. Every park path closes the task's open rows to
531
+ // the given terminal status in the same transaction as the park.
532
+ function closeWorkflowRuns(db, taskId, terminalStatus) {
533
+ const r = db.prepare(
534
+ `UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
535
+ WHERE task_id = ? AND status = 'running'`
536
+ ).run(terminalStatus, taskId);
537
+ return Number(r.changes);
538
+ }
539
+
540
+ // CASCADE-PARK (room #26 blocker 36, 2026-09-21). Park every todo task that
541
+ // depends on any id in parkedIds, with structured attribution on the
542
+ // existing 'parked' state (park_reason='dep_parked', park_dep_id=<the parked
543
+ // dep>) — never a new state, never silent todo, never auto-waived. Then
544
+ // repeat to a fixpoint: a newly parked dependent strands its own
545
+ // dependents. Only todo tasks cascade — an in_progress task's running
546
+ // workflow owns its verdict, so it stays in flight. Emits one 'blocked'
547
+ // event per cascade-parked task, naming the dependent and the parked dep.
548
+ // Returns the list of {id, dep_id} pairs parked.
549
+ function cascadeDepParks(db, parkedIds, timestamp) {
550
+ const cascaded = [];
551
+ const frontier = [...parkedIds];
552
+ const seen = new Set(parkedIds);
553
+ const parkStmt = db.prepare(
554
+ `UPDATE tasks SET state = 'parked', park_reason = 'dep_parked', park_dep_id = ?, updated_at = ?
555
+ WHERE id = ?`);
556
+ const settleStmt = db.prepare(
557
+ `UPDATE agent_sessions SET status = 'failed', ended_at = ?
558
+ WHERE task_id = ? AND status = 'running'`);
559
+ const eventStmt = db.prepare(
560
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
561
+ VALUES (?, 'blocked', ?, NULL, ?, ?)`);
562
+ while (frontier.length > 0) {
563
+ const parkedId = frontier.shift();
564
+ const todoRows = db.prepare("SELECT id, deps FROM tasks WHERE state = 'todo'").all();
565
+ for (const row of todoRows) {
566
+ if (seen.has(row.id)) continue;
567
+ if (!parseDeps(row.deps).includes(parkedId)) continue;
568
+ parkStmt.run(parkedId, timestamp, row.id);
569
+ settleStmt.run(timestamp, row.id);
570
+ // Room #26 blocker 37: a cascade-parked dependent may carry a stale
571
+ // 'running' workflow_runs row (crashed prior run, requeued to todo) —
572
+ // close it with the park, in the same transaction. Every park path
573
+ // reaches its dependents through this function, so this one site
574
+ // covers park-task, update-task, retry-platform-failure, and the
575
+ // tick reconciliation sweep.
576
+ closeWorkflowRuns(db, row.id, "parked");
577
+ eventStmt.run(uuid(), row.id,
578
+ `dep-parked: ${row.id} parked — dep ${parkedId} is parked; ` +
579
+ `re-release ${parkedId} or waive this task via update-task to resume`,
580
+ timestamp);
581
+ seen.add(row.id);
582
+ cascaded.push({ id: row.id, dep_id: parkedId });
583
+ frontier.push(row.id);
584
+ }
585
+ }
586
+ return cascaded;
587
+ }
588
+
589
+ // RELEASE (room #26 blocker 36, 2026-09-21). When a task leaves 'parked'
590
+ // for any non-parked state, its cascade-parked dependents' contingency is
591
+ // lifted: re-evaluate each. If another of the dependent's deps is still
592
+ // parked, re-attribute the park to that dep (stays parked — never
593
+ // auto-waived past a parked dep); otherwise return it to todo with the
594
+ // attribution cleared. Then repeat to a fixpoint: a dependent returned to
595
+ // todo lifts the contingency of ITS cascade-parked dependents — the release
596
+ // cascades exactly as far as the park did. Emits one 'blocked' event per
597
+ // touched dependent. Returns the list of {id, action, dep_id} outcomes.
598
+ function releaseDepParks(db, releasedId, timestamp) {
599
+ const outcomes = [];
600
+ const frontier = [releasedId];
601
+ const seen = new Set([releasedId]);
602
+ const depStmt = db.prepare(
603
+ `SELECT id, deps FROM tasks
604
+ WHERE state = 'parked' AND park_reason = 'dep_parked' AND park_dep_id = ?`);
605
+ const stateOf = db.prepare("SELECT state FROM tasks WHERE id = ?");
606
+ const eventStmt = db.prepare(
607
+ `INSERT INTO events (id, type, task_id, identity, message, timestamp)
608
+ VALUES (?, 'blocked', ?, NULL, ?, ?)`);
609
+ while (frontier.length > 0) {
610
+ const relId = frontier.shift();
611
+ for (const row of depStmt.all(relId)) {
612
+ if (seen.has(row.id)) continue;
613
+ seen.add(row.id);
614
+ const otherParked = parseDeps(row.deps).find(
615
+ (d) => d !== relId && stateOf.get(d)?.state === "parked");
616
+ if (otherParked) {
617
+ db.prepare("UPDATE tasks SET park_dep_id = ?, updated_at = ? WHERE id = ?")
618
+ .run(otherParked, timestamp, row.id);
619
+ eventStmt.run(uuid(), row.id,
620
+ `dep-parked: ${row.id} still parked — attribution moved from ${relId} to ${otherParked} (still parked)`,
621
+ timestamp);
622
+ outcomes.push({ id: row.id, action: "reattributed", dep_id: otherParked });
623
+ } else {
624
+ db.prepare("UPDATE tasks SET state = 'todo', park_reason = NULL, park_dep_id = NULL, updated_at = ? WHERE id = ?")
625
+ .run(timestamp, row.id);
626
+ eventStmt.run(uuid(), row.id,
627
+ `dep-released: ${row.id} returned to todo — dep ${relId} is no longer parked`,
628
+ timestamp);
629
+ outcomes.push({ id: row.id, action: "returned_todo" });
630
+ frontier.push(row.id);
631
+ }
632
+ }
633
+ }
634
+ return outcomes;
635
+ }
636
+
437
637
  // Retry counting, ported verbatim from the dashboard's retry.ts.
438
638
  function countConsecutiveFailures(sessions, retryResetAt) {
439
639
  const latest = sessions[0];
@@ -566,6 +766,23 @@ commands["get-dispatch-state"] = (db) => {
566
766
  WHERE status = 'running' AND datetime(COALESCE(last_heartbeat, started_at)) < datetime('now', '-1 hour')`
567
767
  ).run();
568
768
 
769
+ // Reconcile stranded dependents (room #26 blocker 36, 2026-09-21): park-time
770
+ // cascades catch new parks, but a legacy or raced row can still sit todo
771
+ // with a parked dep — and then it would sit forever with zero telemetry.
772
+ // Every dispatcher tick parks any todo task whose dep is currently
773
+ // parked, so no task sits todo with a parked dep for more than one tick.
774
+ // Idempotent (only todo tasks transition, and they stop being todo),
775
+ // and event-free for tasks already attributed — cascadeDepParks only
776
+ // writes for newly parked tasks. Same write-on-read precedent as the
777
+ // zombie-session sweep above.
778
+ {
779
+ const parkedIds = db
780
+ .prepare("SELECT id FROM tasks WHERE state = 'parked'")
781
+ .all()
782
+ .map((r) => r.id);
783
+ if (parkedIds.length > 0) cascadeDepParks(db, parkedIds, now());
784
+ }
785
+
569
786
  const taskRows = db.prepare("SELECT * FROM tasks").all();
570
787
  const projectRows = db.prepare("SELECT * FROM projects ORDER BY display_name").all();
571
788
  const configRows = db.prepare("SELECT key, value FROM config WHERE key <> 'simultaneity'").all();
@@ -770,6 +987,13 @@ commands["update-task"] = (db, args, ctx) => {
770
987
  const timestamp = now();
771
988
  const patch = {};
772
989
  setRetryResetAtForTransition(patch, current.state, args.state, timestamp);
990
+ // Room #26 blocker 36: leaving 'parked' clears this task's own cascade
991
+ // attribution — the parked->todo waiver is an explicit operator decision
992
+ // (the task returns todo with blocked=true until its deps are done).
993
+ if (current.state === "parked" && args.state !== undefined && args.state !== "parked") {
994
+ patch.park_reason = null;
995
+ patch.park_dep_id = null;
996
+ }
773
997
  if (args.title !== undefined) {
774
998
  const t = args.title.trim();
775
999
  if (t.length < 1 || t.length > 200) throw usageError("title must be 1-200 chars.");
@@ -802,6 +1026,21 @@ commands["update-task"] = (db, args, ctx) => {
802
1026
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
803
1027
  VALUES (?, ?, ?, NULL, ?, ?)`
804
1028
  ).run(uuid(), type, args.id, `Moved \u201c${patch.title ?? current.title}\u201d to ${args.state.replace("_", " ")}.`, timestamp);
1029
+ // Room #26 blocker 36: the dep is no longer parked, so re-evaluate
1030
+ // cascade-parked dependents — their contingency is lifted.
1031
+ if (current.state === "parked" && args.state !== "parked") {
1032
+ releaseDepParks(db, args.id, timestamp);
1033
+ }
1034
+ // Room #26 blocker 36: an operator parking a dep strands its todo
1035
+ // dependents with zero telemetry — cascade-park them in the same
1036
+ // transaction, under the same attribution contract as park-task.
1037
+ if (args.state === "parked" && current.state !== "parked") {
1038
+ // Blocker 37: an operator parking an in-flight task must close its
1039
+ // open workflow_runs rows in the same transaction — park-task and
1040
+ // retry-platform-failure already do; this path was the gap.
1041
+ closeWorkflowRuns(db, args.id, "parked");
1042
+ cascadeDepParks(db, [args.id], timestamp);
1043
+ }
805
1044
  }
806
1045
  db.exec("COMMIT");
807
1046
  } catch (e) { db.exec("ROLLBACK"); throw e; }
@@ -971,6 +1210,14 @@ commands["list-reservations"] = (db) => {
971
1210
  commands["record-run-start"] = (db, args) => {
972
1211
  if (!args.task_id) throw usageError("task_id is required.");
973
1212
  if (!args.workflow) throw usageError("workflow is required.");
1213
+ // A terminal task (parked, done) can never gain a new run: a stale agent
1214
+ // retrying a start against a task that was parked/done since would
1215
+ // resurrect it in the run timeline (2026-09-21 R-2). todo/in_progress
1216
+ // starts stay allowed — telemetry may record before the self-claim.
1217
+ const taskState = db.prepare("SELECT state FROM tasks WHERE id = ?").get(args.task_id);
1218
+ if (taskState && (taskState.state === "parked" || taskState.state === "done")) {
1219
+ throw usageError("task " + args.task_id + " is " + taskState.state + "; cannot start a workflow run.");
1220
+ }
974
1221
  // The workflow doesn't know its platform run_id; the telemetry run_id is
975
1222
  // minted here (SQLite-side) so workflow JS never touches randomness.
976
1223
  const runId = args.run_id || uuid();
@@ -1021,9 +1268,12 @@ commands["record-run-end"] = (db, args) => {
1021
1268
  if (!["completed", "failed", "parked", "timed_out"].includes(status)) {
1022
1269
  throw usageError("status must be completed, failed, parked, or timed_out.");
1023
1270
  }
1271
+ // Terminal runs are immutable: a late run-end can never rewrite a run
1272
+ // the park/fail paths already closed (2026-09-21 R-2). Only a run still
1273
+ // in 'running' may transition; unknown run_ids match nothing (as before).
1024
1274
  db.prepare(
1025
1275
  `UPDATE workflow_runs SET status = ?, completed_at = datetime('now')
1026
- WHERE run_id = ?`
1276
+ WHERE run_id = ? AND status = 'running'`
1027
1277
  ).run(status, args.run_id);
1028
1278
  return { ok: true, run_id: args.run_id, status };
1029
1279
  };
@@ -1176,8 +1426,15 @@ commands["retry-platform-failure"] = (db, args) => {
1176
1426
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1177
1427
  VALUES (?, 'note', ?, NULL, ?, ?)`
1178
1428
  ).run(uuid(), failure.task_id, "Parked: " + msg, ts);
1429
+ // Room #26 blocker 37: park never closed workflow_runs rows — an open
1430
+ // 'running' row would sit forever. Close them to 'parked' in the
1431
+ // same transaction.
1432
+ const closedRuns = closeWorkflowRuns(db, failure.task_id, "parked");
1433
+ // Room #26 blocker 36: the parked dep's todo dependents strand with
1434
+ // zero telemetry otherwise — cascade-park them in the same transaction.
1435
+ const cascadeParked = cascadeDepParks(db, [failure.task_id], ts);
1179
1436
  db.exec("COMMIT");
1180
- return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes) };
1437
+ return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes), closed_workflow_runs: closedRuns, cascade_parked: cascadeParked };
1181
1438
  } catch (e) {
1182
1439
  db.exec("ROLLBACK");
1183
1440
  throw e;
@@ -1275,12 +1532,21 @@ commands["park-task"] = (db, args) => {
1275
1532
  notes = notes || ' Parked: ' || ?
1276
1533
  WHERE task_id = ? AND status = 'running'`
1277
1534
  ).run(timestamp, message.slice(0, 500), task.id);
1535
+ // Room #26 blocker 37: park never closed workflow_runs rows — an open
1536
+ // 'running' row would sit forever. Close them to 'parked' in the same
1537
+ // transaction (park is the designed terminal state for the run too).
1538
+ const closedRuns = closeWorkflowRuns(db, task.id, "parked");
1539
+ // Room #26 blocker 36: cascade-park todo dependents in the same
1540
+ // transaction — a parked dep strands them with zero telemetry otherwise.
1541
+ const cascadeParked = cascadeDepParks(db, [task.id], timestamp);
1278
1542
  db.exec("COMMIT");
1279
1543
  const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
1280
1544
  return {
1281
1545
  ok: true,
1282
1546
  task: mapTask({ ...task, state: "parked", updated_at: timestamp }, taskStates),
1283
1547
  settled_sessions: Number(settled.changes),
1548
+ closed_workflow_runs: closedRuns,
1549
+ cascade_parked: cascadeParked,
1284
1550
  };
1285
1551
  } catch (e) { db.exec("ROLLBACK"); throw e; }
1286
1552
  };
@@ -1324,7 +1590,9 @@ commands["recover-task"] = (db, args, ctx) => {
1324
1590
  const note = (args.updated_description ?? "").trim().slice(0, 5000);
1325
1591
  db.exec("BEGIN");
1326
1592
  try {
1327
- db.prepare("UPDATE tasks SET next_phase = ?, state = ?, updated_at = ? WHERE id = ?")
1593
+ // Room #26 blocker 36: recovery out of 'parked' clears this task's own
1594
+ // cascade attribution and re-evaluates cascade-parked dependents.
1595
+ db.prepare("UPDATE tasks SET next_phase = ?, state = ?, park_reason = NULL, park_dep_id = NULL, updated_at = ? WHERE id = ?")
1328
1596
  .run(targetPhase, newState, timestamp, task.id);
1329
1597
  db.prepare(
1330
1598
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
@@ -1334,11 +1602,14 @@ commands["recover-task"] = (db, args, ctx) => {
1334
1602
  (task.state !== newState ? ` (state ${task.state} -> ${newState})` : "") +
1335
1603
  (note ? `. ${note}` : ""),
1336
1604
  timestamp);
1605
+ if (task.state === "parked" && newState !== "parked") {
1606
+ releaseDepParks(db, task.id, timestamp);
1607
+ }
1337
1608
  db.exec("COMMIT");
1338
1609
  } catch (e) { db.exec("ROLLBACK"); throw e; }
1339
1610
  const taskStates = new Map(db.prepare("SELECT id, state FROM tasks").all().map((r) => [r.id, r.state]));
1340
1611
  return {
1341
- task: mapTask({ ...task, next_phase: targetPhase, state: newState, updated_at: timestamp }, taskStates),
1612
+ task: mapTask({ ...task, next_phase: targetPhase, state: newState, park_reason: null, park_dep_id: null, updated_at: timestamp }, taskStates),
1342
1613
  valid_phases: stepNames,
1343
1614
  };
1344
1615
  };
@@ -1921,12 +2192,36 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
1921
2192
  if (!["accepted", "refused"].includes(editOutcome)) {
1922
2193
  throw usageError(`unknown edit outcome: ${editOutcome}.`);
1923
2194
  }
2195
+ // Reclaimed-claim receipt guard (2026-09-21 R-3): a reclaimed claim
2196
+ // means a previous tick died mid-issuance and may have issued the edit
2197
+ // without recording it. The tick worker MUST run the step-2 receipt
2198
+ // check and report its outcome explicitly: "different" (receipt
2199
+ // readable but differs from this attempt's version), "unreadable"
2200
+ // (receipt missing or unreadable — NOT proof the edit did not land), or
2201
+ // "match" (receipt equals this attempt's version — the edit landed;
2202
+ // acknowledge it via record-version-ack, never re-issue). A missing or
2203
+ // unknown receipt_check on a reclaimed claim is a usage error; "match"
2204
+ // refuses to record (fail closed, no write) so a landed edit is never
2205
+ // re-issued. Non-reclaimed claims take no receipt_check.
2206
+ const reclaimed = args.reclaimed === true || args.reclaimed === "true";
2207
+ const receiptCheck = args.receipt_check;
2208
+ if (reclaimed) {
2209
+ if (!["different", "unreadable", "match"].includes(receiptCheck)) {
2210
+ throw usageError("receipt_check is required for a reclaimed claim (one of: different, unreadable, match).");
2211
+ }
2212
+ } else if (receiptCheck !== undefined && receiptCheck !== null) {
2213
+ throw usageError("receipt_check is only valid for a reclaimed claim.");
2214
+ }
1924
2215
 
1925
2216
  db.exec("BEGIN");
1926
2217
  try {
1927
2218
  const pre = resolveClaimedIntent(db, ctx, taskId, claimExpiry);
1928
2219
  if (!pre.ok) { db.exec("ROLLBACK"); return { recorded: false, reason: pre.reason }; }
1929
2220
  const { slug, ledgerPath, intentEntry, commit } = pre;
2221
+ if (reclaimed && receiptCheck === "match") {
2222
+ db.exec("ROLLBACK");
2223
+ return { recorded: false, reason: "receipt already matches the claimed version — the edit landed; acknowledge via record-version-ack, never re-issue" };
2224
+ }
1930
2225
  const publishAttempt = intentEntry.publish_attempt;
1931
2226
  const version = intentEntry.version;
1932
2227
  if (!Number.isInteger(publishAttempt) || publishAttempt < 1 ||
@@ -1942,19 +2237,22 @@ commands["record-intent-issuance"] = (db, args, ctx) => {
1942
2237
  issuer: "tick-worker",
1943
2238
  diff_sha256: intentEntry.diff_sha256 || null,
1944
2239
  issued_at: editOutcome === "accepted" ? ts : null,
2240
+ receipt_check: reclaimed ? receiptCheck : null,
1945
2241
  };
1946
2242
  let note;
1947
2243
  if (editOutcome === "refused") {
1948
2244
  const refusalText = String(args.refusal_text || "no refusal text recorded").split("\n")[0].slice(0, 500);
1949
2245
  ledgerLine.outcome = "rejected";
1950
- ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}`;
2246
+ ledgerLine.detail = `platform refused the directly-issued edit: ${refusalText}` +
2247
+ (reclaimed ? ` (reclaimed claim; step-2 receipt check was '${receiptCheck}')` : "");
1951
2248
  // Terminal: parked for human attention. "publish: publish-refused"
1952
2249
  // is registered in lib/publish-note-vocabulary.js (the closed
1953
2250
  // registry — the writer-side guard pattern from D7).
1954
2251
  note = `publish: publish-refused ${commit} attempt=${publishAttempt} — the platform refused the issued edit (${refusalText}). No re-issue; parked for human attention.`;
1955
2252
  } else {
1956
2253
  ledgerLine.outcome = "submitted";
1957
- ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt";
2254
+ ledgerLine.detail = "edit issued directly by the session-carrying tick worker (one-party publish); the staged diff carried the per-attempt version receipt" +
2255
+ (reclaimed ? `; reclaimed claim, step-2 receipt check was '${receiptCheck}' (a differing or unreadable receipt is not proof the prior attempt's edit did not land)` : "");
1958
2256
  note = `publish: edit-issued ${commit} attempt=${publishAttempt} version=${version} — issued directly by the session-carrying tick worker (one-party publish). The acknowledgement scan owns the task from here: exact version string, 30-minute window.`;
1959
2257
  }
1960
2258
  try {
@@ -2446,6 +2744,67 @@ commands["record-publish-note"] = (db, args) => {
2446
2744
  writePublishNote(db, taskId, note);
2447
2745
  return { ok: true, task_id: taskId, verb };
2448
2746
  };
2747
+
2748
+ // STRUCTURED VERDICT RECORDS (room #26 blocker 33, 2026-09-21). Review
2749
+ // verdict grounds were destroyed by the 2000-char session-note truncation —
2750
+ // a park on a verified-correct implementation was unauditable. record-phase
2751
+ // optionally carries the full grounds (`args.verdict`); they are validated
2752
+ // at the API boundary and written to the verdicts table in the same
2753
+ // transaction as the session note + event, so the lossless record can never
2754
+ // be missing when the note exists. One verdict per recording session: a
2755
+ // re-recorded phase for the same session is the same evidence, so the first
2756
+ // write wins (ON CONFLICT DO NOTHING) and rows are never revised. A new
2757
+ // Review execution claims a new session, so rework rounds and dispatcher
2758
+ // retries each get their own row.
2759
+ const VERDICT_RECORD_STEPS = ["Review"];
2760
+ const VERDICT_VALUES = ["PASS", "FAIL", "INDETERMINATE"];
2761
+ const MAX_VERDICT_GROUNDS = 1000000;
2762
+
2763
+ function validateVerdictPayload(taskId, verdict, sessionRow) {
2764
+ if (typeof verdict !== "object" || verdict === null)
2765
+ throw usageError("verdict must be an object.");
2766
+ const { step, attempt, reviewer, verdict: value, grounds, review_basis } = verdict;
2767
+ if (!VERDICT_RECORD_STEPS.includes(step))
2768
+ throw usageError(`verdict step must be one of: ${VERDICT_RECORD_STEPS.join(", ")}.`);
2769
+ if (!Number.isInteger(attempt) || attempt < 0)
2770
+ throw usageError("verdict attempt must be a non-negative integer.");
2771
+ if (typeof reviewer !== "string" || reviewer.trim().length < 1 || reviewer.length > 200)
2772
+ throw usageError("verdict reviewer is required (1-200 chars).");
2773
+ if (!VERDICT_VALUES.includes(value))
2774
+ throw usageError(`verdict must be one of: ${VERDICT_VALUES.join(", ")}.`);
2775
+ if (typeof grounds !== "string" || grounds.length < 1 || grounds.length > MAX_VERDICT_GROUNDS)
2776
+ throw usageError(`verdict grounds are required (1-${MAX_VERDICT_GROUNDS} chars).`);
2777
+ // Room #26 blocker 34 (redesign): the structured review basis — what the
2778
+ // review actually examined. NULL when the review was not branch-classified
2779
+ // (docs.js path). Shape-pinned at the API boundary: a lowercase kind tag,
2780
+ // optionally qualified by a 40-hex merge sha (frozen-merge:<sha>).
2781
+ let reviewBasis = null;
2782
+ if (review_basis !== undefined && review_basis !== null) {
2783
+ // Exact closed set (blocker 34 redesign, 2026-09-21): the workflows emit
2784
+ // only branch-diff, mechanical-fail, runtime-state-none, or
2785
+ // frozen-merge:<40-hex-sha>. A broad regex would accept fabricated kinds.
2786
+ if (typeof review_basis !== "string" ||
2787
+ !/^(branch-diff|mechanical-fail|runtime-state-none|frozen-merge:[0-9a-f]{40})$/.test(review_basis))
2788
+ throw usageError("verdict review_basis must be null or one of 'branch-diff', 'mechanical-fail', 'runtime-state-none', 'frozen-merge:<40-hex-sha>'.");
2789
+ reviewBasis = review_basis;
2790
+ }
2791
+ return {
2792
+ id: uuid(),
2793
+ task_id: taskId,
2794
+ step,
2795
+ attempt,
2796
+ reviewer: reviewer.trim(),
2797
+ verdict: value,
2798
+ grounds,
2799
+ review_basis: reviewBasis,
2800
+ // summary is derived from the session note actually recorded — one
2801
+ // source of truth, never a second caller-supplied truncation.
2802
+ summary: sessionRow.notes,
2803
+ session_id: sessionRow.id,
2804
+ created_at: now(),
2805
+ };
2806
+ }
2807
+
2449
2808
  commands["record-phase"] = (db, args) => {
2450
2809
  if (!args.task_id) throw usageError("task_id is required.");
2451
2810
  requireTask(db, args.task_id);
@@ -2481,6 +2840,13 @@ commands["record-phase"] = (db, args) => {
2481
2840
  id: uuid(), type: event.type, task_id: args.task_id,
2482
2841
  identity: event.identity ?? sessionRow.identity, message, timestamp,
2483
2842
  };
2843
+ // Blocker 33: validate the optional structured verdict BEFORE the
2844
+ // transaction — a malformed payload fails the whole phase record (fail
2845
+ // closed), never a silently dropped verdict.
2846
+ const verdictRow = args.verdict == null
2847
+ ? null
2848
+ : validateVerdictPayload(args.task_id, args.verdict, sessionRow);
2849
+ let recordedVerdict = null;
2484
2850
  db.exec("BEGIN");
2485
2851
  try {
2486
2852
  db.prepare(
@@ -2497,9 +2863,39 @@ commands["record-phase"] = (db, args) => {
2497
2863
  db.prepare(
2498
2864
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
2499
2865
  VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(eventRow);
2866
+ // Blocker 33: the verdict lands in the same transaction as the session
2867
+ // note + event — the lossless record can never be missing when the note
2868
+ // exists. Idempotency is session-keyed: a re-recorded phase for the
2869
+ // same session is the same evidence (first write wins, never revised);
2870
+ // a new Review execution claims a new session and gets its own row.
2871
+ if (verdictRow) {
2872
+ const verdictInsert = db.prepare(
2873
+ `INSERT INTO verdicts (id, task_id, step, attempt, reviewer, verdict,
2874
+ grounds, review_basis, summary, session_id, created_at)
2875
+ VALUES (@id, @task_id, @step, @attempt, @reviewer, @verdict,
2876
+ @grounds, @review_basis, @summary, @session_id, @created_at)
2877
+ ON CONFLICT(session_id) DO NOTHING`).run(verdictRow);
2878
+ recordedVerdict = verdictInsert.changes === 1
2879
+ ? verdictRow
2880
+ : db.prepare(
2881
+ `SELECT * FROM verdicts WHERE session_id = ?`).get(verdictRow.session_id);
2882
+ }
2500
2883
  db.exec("COMMIT");
2501
2884
  } catch (e) { db.exec("ROLLBACK"); throw e; }
2502
- return { session: mapSession(sessionRow), event: mapEvent(eventRow) };
2885
+ return { session: mapSession(sessionRow), event: mapEvent(eventRow),
2886
+ verdict: mapVerdict(recordedVerdict) };
2887
+ };
2888
+
2889
+ // Blocker 33 read surface: all structured verdict records for a task,
2890
+ // ordered by recording time (rework rounds and retries accumulate as
2891
+ // separate rows, never overwrite).
2892
+ commands["get-verdicts"] = (db, args) => {
2893
+ if (!args.task_id) throw usageError("task_id is required.");
2894
+ requireTask(db, args.task_id);
2895
+ const rows = db.prepare(
2896
+ `SELECT * FROM verdicts WHERE task_id = ? ORDER BY created_at ASC`
2897
+ ).all(args.task_id);
2898
+ return { verdicts: rows.map(mapVerdict) };
2503
2899
  };
2504
2900
 
2505
2901
  // Initial provenance stamp (2026-09-16, clean-room task ada95b71): the first