muse-crew 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +1 -1
  2. package/docs/decisions/AGENTS.md +4 -0
  3. package/docs/decisions/publish-path.md +229 -0
  4. package/docs/publish-verification.md +14 -3
  5. package/docs/release-integrity.md +60 -0
  6. package/docs/reviews/critic-0143-redo.md +94 -0
  7. package/docs/reviews/critic-0143.md +108 -0
  8. package/docs/reviews/critic-0144.md +83 -0
  9. package/lib/AGENTS.md +14 -6
  10. package/lib/advance-publish-base.js +8 -0
  11. package/lib/append-ooda-step.js +12 -3
  12. package/lib/build-readback-request.js +10 -0
  13. package/lib/build-registry.js +8 -3
  14. package/lib/classify-publish-absence.js +11 -0
  15. package/lib/classify-surface.js +12 -2
  16. package/lib/commit-scaffold.js +22 -6
  17. package/lib/compose-evidence-caption.js +15 -4
  18. package/lib/compute-publish-diff.js +9 -0
  19. package/lib/crew-api.js +55 -20
  20. package/lib/crew-release.sh +233 -1
  21. package/lib/gitignore.js +23 -5
  22. package/lib/package.json +1 -0
  23. package/lib/publish-note-vocabulary.js +44 -0
  24. package/lib/publish-npm.sh +39 -10
  25. package/lib/read-ooda-verdict.js +11 -3
  26. package/lib/readback-disk.js +9 -0
  27. package/lib/render-html.js +17 -7
  28. package/lib/repo-orchestration.js +21 -5
  29. package/lib/retry-publish.js +79 -44
  30. package/lib/sample-project.js +22 -6
  31. package/lib/scaffold-crew.js +11 -2
  32. package/lib/see-act.js +17 -8
  33. package/lib/serve-artifact.js +12 -6
  34. package/lib/setup-project-repo.js +26 -7
  35. package/lib/test-detached-integrate.sh +79 -3
  36. package/lib/test-publish-preflight.sh +94 -5
  37. package/lib/update-watch.js +34 -17
  38. package/lib/ux-doctrine.js +31 -6
  39. package/lib/verify-publish.js +41 -6
  40. package/lib/worktree-lifecycle.sh +67 -25
  41. package/lib/write-ooda-verdict.js +12 -3
  42. package/package.json +1 -1
  43. package/workflows/bugfix.js +60 -61
  44. package/workflows/chore.js +60 -61
  45. package/workflows/standard.js +60 -42
  46. package/workflows/upgrade.js +4 -2
@@ -127,8 +127,10 @@ const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
127
127
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
128
128
  const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
129
129
  const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
130
+ const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
131
+ const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
130
132
  // See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
131
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
133
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
132
134
 
133
135
  // Project config — passed by dispatcher, falls back to dashboard defaults
134
136
  const projectConfig = inputs.project_config || {};
@@ -250,19 +252,12 @@ async function reaskVerdict(stepName, reworkSuffix, workerText) {
250
252
  return verdict;
251
253
  }
252
254
  // Transport retry: the work-agent agent() call can throw even when the agent
253
- // did the work. Stochastic envelope non-compliance (bare prose instead of
254
- // the native {"status":"ok","result":"..."} envelope) trips the runtime's
255
- // JSON-candidate heuristic when the prose contains a {...}-looking
256
- // substring — canary 39457ee9's QA report quoted the change's own
257
- // {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
258
- // and the workflow discarded a complete VERDICT: PASS report as "no output".
259
- // The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
260
- // this covers the report never arriving. The assignment is retried boundedly
261
- // with fresh keys (never a cached replay) before failing closed. Re-entry is
262
- // safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
263
- // retry trailer tells the agent to check existing state first and report
264
- // rather than duplicate completed side effects, and the rework path already
265
- // re-runs Build after rejection — Build re-entry is an established pattern.
255
+ // did the work — the runtime's JSON-candidate heuristic trips on brace-shaped
256
+ // prose. Retry the assignment boundedly with fresh keys (never a cached
257
+ // replay) before failing closed. Re-entry is safe: lifecycle scripts answer
258
+ // REUSED for existing worktrees/branches, the retry trailer tells the agent
259
+ // to check existing state first, and Build re-entry after rejection is an
260
+ // established pattern. Full history: docs/decisions/workflow-core.md.
266
261
  function workRetryKey(stepName, reworkSuffix, attempt) {
267
262
  return "work-" + stepName + reworkSuffix + "-t" + attempt;
268
263
  }
@@ -274,7 +269,7 @@ function attemptKey(base, reworkCount) {
274
269
  function pinLifecycle(key) {
275
270
  return agent(
276
271
  "Snapshot lifecycle scripts for version pinning.\n" +
277
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
272
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
278
273
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
279
274
  { key: key, label: "Pinning lifecycle scripts",
280
275
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -455,27 +450,11 @@ function hydrateReleaseDecision(rec) {
455
450
  return null;
456
451
  }
457
452
 
458
- // Publish read-back request (currently unavailable): the verbatim_request
459
- // the parent protocol (docs/publish-verification.md) would hand to an
460
- // independent read-back tool after the artifact build lands. artifact_inspect
461
- // was removed by the platform (2026-09-14); artifact.inspect is malfunction
462
- // diagnosis, not a substitute — so no agent-callable read-back tool exists
463
- // and this request cannot currently be issued. Pure function — no I/O, no
464
- // clock. The request carries the merged diff as the expected change and asks
465
- // for an independent read of the artifact's actual source: for each file, the
466
- // exact current text of the changed regions plus a per-line present/absent
467
- // finding. Until a read-back path exists, the parent cannot independently
468
- // confirm content and verification parks at "publish: verification-requested"
469
- // (see docs/publish-verification.md). This preserves the circularity break
470
- // that hollowed canary run 8 (2026-09-11): the old verifyAppliedChanges
471
- // compared the builder's applied-report against the diff the report was
472
- // derived from — a fabricated report passed by construction. The report
473
- // itself is gone now (2026-09-16 fire-and-forget trigger). Independent
474
- // read-back cannot be
475
- // fabricated from the diff; it must match the artifact's real content.
476
-
477
453
  // See docs/decisions/publish-path.md#publish-attempt-ledger: every trigger outcome is recorded in the durable ledger.
478
- async function recordPublishLedger(entry, rework) {
454
+ // keyTag (optional): same-outcome ledger writes in one pass need distinct
455
+ // agent-call keys — the runtime replays cached calls by key, so a shared key
456
+ // silently drops the second write (2026-09-19, submitted-on-issuance).
457
+ async function recordPublishLedger(entry, rework, keyTag) {
479
458
  try {
480
459
  var ledgerDir = crewHome + "/.publish-ledger";
481
460
  var line = JSON.stringify({
@@ -489,7 +468,10 @@ async function recordPublishLedger(entry, rework) {
489
468
  applied_report: entry.applied_report || null,
490
469
  manifest_before: entry.manifest_before || null,
491
470
  outcome: entry.outcome,
492
- detail: entry.detail || ""
471
+ detail: entry.detail || "",
472
+ // D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
473
+ // instant (null when no trigger); ts = ledger-write instant.
474
+ issued_at: entry.issued_at || null,
493
475
  });
494
476
  var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
495
477
  var res = await agent(
@@ -497,7 +479,7 @@ async function recordPublishLedger(entry, rework) {
497
479
  "Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
498
480
  " | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
499
481
  "Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
500
- { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
482
+ { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome + (keyTag ? "-" + keyTag : ""), rework),
501
483
  label: "Recording publish attempt in ledger",
502
484
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
503
485
  );
@@ -1195,7 +1177,7 @@ while (i < STEPS.length) {
1195
1177
  "The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
1196
1178
  "- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
1197
1179
  "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1198
- "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash, VERDICT: PASS; PUSH_SKIPPED — the detached line is room-local (the merge commit + record are the evidence), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1180
+ "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1199
1181
  "- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
1200
1182
  "- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
1201
1183
  "RESOLUTION:\n" +
@@ -1208,7 +1190,7 @@ while (i < STEPS.length) {
1208
1190
  "ESCALATE — 'conflict needs human resolution', VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
1209
1191
  "- ERROR: report it. VERDICT: FAIL.\n\n" +
1210
1192
  "R5-PUSH (manual R5 resolution only — the normal path pushed inline). Run: " + LIFECYCLE_ENV + LIFECYCLE + " push-target " + taskId + "\n" +
1211
- "PUSHED — report the merged hash, VERDICT: PASS; PUSH_SKIPPED or NO_REMOTE_PUSH — nothing to push, VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1193
+ "PUSHED — report the merged hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; PUSH_SKIPPED — no push attempted (no record + no lock); NO_REMOTE_PUSH — no remote; VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1212
1194
  "NEVER force-push.\n\n" +
1213
1195
  "Report what happened at each step, ending with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1214
1196
 
@@ -1546,7 +1528,7 @@ while (i < STEPS.length) {
1546
1528
  log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1547
1529
  }
1548
1530
  // See docs/decisions/publish-path.md#trigger-await: the artifact_edit call is awaited.
1549
- var rebuildTrigger = null;
1531
+ var rebuildTrigger = null, triggerThrew = false;
1550
1532
  try {
1551
1533
  var triggerText = String(await agent(rebuildPrompt,
1552
1534
  { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
@@ -1566,7 +1548,36 @@ while (i < STEPS.length) {
1566
1548
  }
1567
1549
  } catch (triggerErr) {
1568
1550
  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");
1551
+ triggerThrew = true;
1569
1552
  }
1553
+ // D1 (2026-09-19; F-A1 upper-bound 2026-09-20): issued_at is an UPPER
1554
+ // bound on issuance — captured AFTER the trigger-agent call returns,
1555
+ // ferried from one shell date call (workflows have no clock) and
1556
+ // validated mechanically; null when unobserved (fallback: ts).
1557
+ // See docs/decisions/publish-path.md#d1-issuance-time.
1558
+ var triggerIssuedAt = null;
1559
+ try {
1560
+ var issuedAtFerry = await agent(
1561
+ "Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
1562
+ "Return JSON { \"ts\": \"<the date output, verbatim>\" } and nothing else.",
1563
+ { key: attemptKey("publish-issued-at-" + taskId, reworkCount), label: "Capturing post-trigger instant (upper bound on issuance)",
1564
+ schema: { type: "object", properties: { ts: { type: "string" } }, required: ["ts"] } }
1565
+ );
1566
+ var issuedAtRaw = String((issuedAtFerry && issuedAtFerry.ts) || "").trim();
1567
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(issuedAtRaw)) triggerIssuedAt = issuedAtRaw;
1568
+ } catch (issuedAtErr) { /* issued_at stays null: the verifier falls back to ts */ }
1569
+ if (!triggerIssuedAt) log("Publish issuance instant unobserved for task " + taskId + " — issued_at null; the freshness gate falls back to ledger-write ts");
1570
+ // (2026-09-19, room #22) Submitted-on-issuance: see docs/decisions/publish-path.md#submitted-on-issuance.
1571
+ await recordPublishLedger({
1572
+ commit: mergeCommitForPublish,
1573
+ attempt: rebuildAttemptKey,
1574
+ agent_id: null,
1575
+ applied_report: publishAppliedObservation,
1576
+ manifest_before: preTriggerManifest,
1577
+ outcome: "submitted",
1578
+ issued_at: triggerIssuedAt,
1579
+ detail: "trigger issuance: rebuild-trigger agent invoked; receipt/observation pending. Invocation only - not evidence of artifact_edit or build acceptance." + (triggerThrew ? "; trigger-threw" : "; trigger-returned")
1580
+ }, reworkCount, "issuance");
1570
1581
  // Post-trigger observation (primary, not fallback): the workflow
1571
1582
  // attributes the edit itself. First the in-flight build state — a
1572
1583
  // build whose agent_id is new relative to the pre-trigger baseline
@@ -1686,8 +1697,9 @@ while (i < STEPS.length) {
1686
1697
  applied_report: publishAppliedObservation,
1687
1698
  manifest_before: preTriggerManifest,
1688
1699
  outcome: "submitted",
1689
- detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff)"
1690
- }, reworkCount);
1700
+ issued_at: triggerIssuedAt,
1701
+ detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff); observation, not re-issuance"
1702
+ }, reworkCount, "receipt");
1691
1703
  } else {
1692
1704
  var newAuditDirs = [];
1693
1705
  try {
@@ -1818,10 +1830,6 @@ while (i < STEPS.length) {
1818
1830
  // its outcome — submitted, rejected, or unknown (unknown is recorded
1819
1831
  // at the park site above). A later run or human matches commit hash +
1820
1832
  // attempt key against the builder's eventual completion.
1821
- // (2026-09-16) The trigger is fire-and-forget: the observation above
1822
- // already recorded the ledger's submitted line on both positive paths
1823
- // and parked on unknown — there is no applied report to observe and
1824
- // no rejection signal to record.
1825
1833
  // (2026-09-18, H2 verdict-first) The verdict was decided exactly
1826
1834
  // once above; dispatch on it. landed bypasses the receipt poll
1827
1835
  // (the audit evidence already proved completion); an explicit
@@ -1955,21 +1963,12 @@ while (i < STEPS.length) {
1955
1963
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1956
1964
  } else {
1957
1965
  // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
1958
- // the poll above only observes IN-FLIGHT builds. A build that
1959
- // finished between the receipt capture and the poll's first check
1960
- // leaves no in-flight trace — but the platform's audit harness
1961
- // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
1962
- // <timestamp>-<id>/ per completed build). Diff the audit-dir
1963
- // listing against the pre-trigger snapshot: a timestamped dir
1964
- // that appeared during the attempt window is evidence a build
1965
- // completed. Attribution is by window, not by build identity:
1966
- // the poll's saw_stranger signal only catches stranger builds in
1967
- // flight AT a check — a stranger that finished entirely inside
1968
- // the window is indistinguishable, so any observed stranger
1969
- // blocks attribution and the outcome stays unknown. This never
1970
- // re-issues the edit and never stamps provenance — ok=true only
1971
- // routes to the parent's independent content read-back, which
1972
- // remains the real verification.
1966
+ // the poll observes only in-flight builds; a build that finished
1967
+ // inside the window leaves a durable audit dir. Diff the audit
1968
+ // listing against the pre-trigger snapshot: a new timestamped dir
1969
+ // is evidence a build completed. Attribution is by window, not by build identity: any
1970
+ // observed stranger blocks it. Never re-issues, never stamps;
1971
+ // ok=true routes to the parent's read-back (the verification).
1973
1972
  //
1974
1973
  // The poll end-state is read from the poll's own observations,
1975
1974
  // not from build_done alone: a build in flight at the last check
@@ -78,8 +78,10 @@ const COMPUTE_DIFF_SRC = crewHome + "/current/lib/compute-publish-diff.js";
78
78
  const COMPUTE_DIFF = RUN_LIB + "/compute-publish-diff.js";
79
79
  const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
80
80
  const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
81
+ const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
82
+ const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
81
83
  // See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
82
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
84
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
83
85
 
84
86
  // Project config — passed by dispatcher, falls back to dashboard defaults
85
87
  const projectConfig = inputs.project_config || {};
@@ -199,19 +201,12 @@ async function reaskVerdict(stepName, reworkSuffix, workerText) {
199
201
  return verdict;
200
202
  }
201
203
  // Transport retry: the work-agent agent() call can throw even when the agent
202
- // did the work. Stochastic envelope non-compliance (bare prose instead of
203
- // the native {"status":"ok","result":"..."} envelope) trips the runtime's
204
- // JSON-candidate heuristic when the prose contains a {...}-looking
205
- // substring — canary 39457ee9's QA report quoted the change's own
206
- // {/* ... */} JSX comment, the runtime tried to parse it as JSON, threw,
207
- // and the workflow discarded a complete VERDICT: PASS report as "no output".
208
- // The verdict re-ask covers an unreadable verdict inside a RECEIVED report;
209
- // this covers the report never arriving. The assignment is retried boundedly
210
- // with fresh keys (never a cached replay) before failing closed. Re-entry is
211
- // safe: lifecycle scripts answer REUSED for existing worktrees/branches, the
212
- // retry trailer tells the agent to check existing state first and report
213
- // rather than duplicate completed side effects, and the rework path already
214
- // re-runs Build after rejection — Build re-entry is an established pattern.
204
+ // did the work — the runtime's JSON-candidate heuristic trips on brace-shaped
205
+ // prose. Retry the assignment boundedly with fresh keys (never a cached
206
+ // replay) before failing closed. Re-entry is safe: lifecycle scripts answer
207
+ // REUSED for existing worktrees/branches, the retry trailer tells the agent
208
+ // to check existing state first, and Build re-entry after rejection is an
209
+ // established pattern. Full history: docs/decisions/workflow-core.md.
215
210
  function workRetryKey(stepName, reworkSuffix, attempt) {
216
211
  return "work-" + stepName + reworkSuffix + "-t" + attempt;
217
212
  }
@@ -223,7 +218,7 @@ function attemptKey(base, reworkCount) {
223
218
  function pinLifecycle(key) {
224
219
  return agent(
225
220
  "Snapshot lifecycle scripts for version pinning.\n" +
226
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
221
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
227
222
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
228
223
  { key: key, label: "Pinning lifecycle scripts",
229
224
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
@@ -428,7 +423,10 @@ function hydrateReleaseDecision(rec) {
428
423
  // it must match the artifact's real content.
429
424
 
430
425
  // See docs/decisions/publish-path.md#publish-attempt-ledger: every trigger outcome is recorded in the durable ledger.
431
- async function recordPublishLedger(entry, rework) {
426
+ // keyTag (optional): same-outcome ledger writes in one pass need distinct
427
+ // agent-call keys — the runtime replays cached calls by key, so a shared key
428
+ // silently drops the second write (2026-09-19, submitted-on-issuance).
429
+ async function recordPublishLedger(entry, rework, keyTag) {
432
430
  try {
433
431
  var ledgerDir = crewHome + "/.publish-ledger";
434
432
  var line = JSON.stringify({
@@ -442,7 +440,10 @@ async function recordPublishLedger(entry, rework) {
442
440
  applied_report: entry.applied_report || null,
443
441
  manifest_before: entry.manifest_before || null,
444
442
  outcome: entry.outcome,
445
- detail: entry.detail || ""
443
+ detail: entry.detail || "",
444
+ // D1 (2026-09-19): issued_at = upper bound on the trigger-issuance
445
+ // instant (null when no trigger); ts = ledger-write instant.
446
+ issued_at: entry.issued_at || null,
446
447
  });
447
448
  var sq = function(s) { return "'" + String(s).split("'").join("'\\''") + "'"; };
448
449
  var res = await agent(
@@ -450,7 +451,7 @@ async function recordPublishLedger(entry, rework) {
450
451
  "Run: mkdir -p " + sq(ledgerDir) + " && printf '%s\n' " + sq(line) +
451
452
  " | sed \"s/@LEDGER_TS@/$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" >> " + sq(ledgerDir + "/" + PUBLISH_SLUG + ".jsonl") + " && echo LEDGER_OK\n" +
452
453
  "Return JSON { \"result\": \"<verbatim output>\" } and nothing else.",
453
- { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome, rework),
454
+ { key: attemptKey("publish-ledger-" + taskId + "-" + entry.outcome + (keyTag ? "-" + keyTag : ""), rework),
454
455
  label: "Recording publish attempt in ledger",
455
456
  schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] } }
456
457
  );
@@ -1182,7 +1183,7 @@ while (i < STEPS.length) {
1182
1183
  "The script merges, reconciles, records, and pushes under the merge lock. Informational only: RECONCILED, NO_REMOTE, NO_REMOTE_RECONCILE. Read the output:\n" +
1183
1184
  "- STAGED_BASE_MISMATCH: the line left the staged base (stray checkout). VERDICT: FAIL.\n" +
1184
1185
  "- MERGED_EMPTY — check FIRST (it contains the word MERGED): either a runtime-state deliverable or an already-merged sha the workflow verified (Build declared repo_diff: none). No lock taken, no new commit. Report 'merged empty: no repo changes'. VERDICT: PASS.\n" +
1185
- "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash, VERDICT: PASS; PUSH_SKIPPED — the detached line is room-local (the merge commit + record are the evidence), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1186
+ "- MERGED: the merge landed; the script pushed inline. Read the push line: PUSHED — report the hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; NO_REMOTE_PUSH — no remote configured, VERDICT: PASS; ERROR after MERGED — a retry recovers the push; report it, VERDICT: FAIL.\n" +
1186
1187
  "- LOCK_HELD: 10-minute backoff exhausted. VERDICT: FAIL.\n" +
1187
1188
  "- CONFLICT: the plain merge failed — aborted, the target is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
1188
1189
  "RESOLUTION:\n" +
@@ -1195,7 +1196,7 @@ while (i < STEPS.length) {
1195
1196
  "ESCALATE — 'conflict needs human resolution', VERDICT: FAIL — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_HOME=" + crewHome + " CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
1196
1197
  "- ERROR: report it. VERDICT: FAIL.\n\n" +
1197
1198
  "R5-PUSH (manual R5 resolution only — the normal path pushed inline). Run: " + LIFECYCLE_ENV + LIFECYCLE + " push-target " + taskId + "\n" +
1198
- "PUSHED — report the merged hash, VERDICT: PASS; PUSH_SKIPPED or NO_REMOTE_PUSH — nothing to push, VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1199
+ "PUSHED — report the merged hash (detached prints PUSHED: origin/main (refspec HEAD:main)), VERDICT: PASS; PUSH_SKIPPED — no push attempted (no record + no lock); NO_REMOTE_PUSH — no remote; VERDICT: PASS; ERROR or CONFLICT — report it, VERDICT: FAIL.\n" +
1199
1200
  "NEVER force-push.\n\n" +
1200
1201
  "Report what happened at each step, ending with exactly one line: VERDICT: PASS or VERDICT: FAIL.";
1201
1202
 
@@ -1533,7 +1534,7 @@ while (i < STEPS.length) {
1533
1534
  log("Publish pre-trigger baseline read failed for task " + taskId + " (" + (baselineErr && baselineErr.message ? baselineErr.message : baselineErr) + ") — receipt attribution skipped; durable audit-dir evidence is the only positive signal");
1534
1535
  }
1535
1536
  // See docs/decisions/publish-path.md#trigger-await: the artifact_edit call is awaited.
1536
- var rebuildTrigger = null;
1537
+ var rebuildTrigger = null, triggerThrew = false;
1537
1538
  try {
1538
1539
  var triggerText = String(await agent(rebuildPrompt,
1539
1540
  { key: rebuildAttemptKey, label: "Triggering artifact rebuild" }) || "");
@@ -1553,7 +1554,36 @@ while (i < STEPS.length) {
1553
1554
  }
1554
1555
  } catch (triggerErr) {
1555
1556
  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");
1557
+ triggerThrew = true;
1556
1558
  }
1559
+ // D1 (2026-09-19; F-A1 upper-bound 2026-09-20): issued_at is an UPPER
1560
+ // bound on issuance — captured AFTER the trigger-agent call returns,
1561
+ // ferried from one shell date call (workflows have no clock) and
1562
+ // validated mechanically; null when unobserved (fallback: ts).
1563
+ // See docs/decisions/publish-path.md#d1-issuance-time.
1564
+ var triggerIssuedAt = null;
1565
+ try {
1566
+ var issuedAtFerry = await agent(
1567
+ "Run: date -u +%Y-%m-%dT%H:%M:%SZ\n" +
1568
+ "Return JSON { \"ts\": \"<the date output, verbatim>\" } and nothing else.",
1569
+ { key: attemptKey("publish-issued-at-" + taskId, totalReworkCount), label: "Capturing post-trigger instant (upper bound on issuance)",
1570
+ schema: { type: "object", properties: { ts: { type: "string" } }, required: ["ts"] } }
1571
+ );
1572
+ var issuedAtRaw = String((issuedAtFerry && issuedAtFerry.ts) || "").trim();
1573
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(issuedAtRaw)) triggerIssuedAt = issuedAtRaw;
1574
+ } catch (issuedAtErr) { /* issued_at stays null: the verifier falls back to ts */ }
1575
+ if (!triggerIssuedAt) log("Publish issuance instant unobserved for task " + taskId + " — issued_at null; the freshness gate falls back to ledger-write ts");
1576
+ // (2026-09-19, room #22) Submitted-on-issuance: see docs/decisions/publish-path.md#submitted-on-issuance.
1577
+ await recordPublishLedger({
1578
+ commit: mergeCommitForPublish,
1579
+ attempt: rebuildAttemptKey,
1580
+ agent_id: null,
1581
+ applied_report: publishAppliedObservation,
1582
+ manifest_before: preTriggerManifest,
1583
+ outcome: "submitted",
1584
+ issued_at: triggerIssuedAt,
1585
+ detail: "trigger issuance: rebuild-trigger agent invoked; receipt/observation pending. Invocation only - not evidence of artifact_edit or build acceptance." + (triggerThrew ? "; trigger-threw" : "; trigger-returned")
1586
+ }, totalReworkCount, "issuance");
1557
1587
  // Post-trigger observation (primary, not fallback): the workflow
1558
1588
  // attributes the edit itself. First the in-flight build state — a
1559
1589
  // build whose agent_id is new relative to the pre-trigger baseline
@@ -1673,8 +1703,9 @@ while (i < STEPS.length) {
1673
1703
  applied_report: publishAppliedObservation,
1674
1704
  manifest_before: preTriggerManifest,
1675
1705
  outcome: "submitted",
1676
- detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff)"
1677
- }, totalReworkCount);
1706
+ issued_at: triggerIssuedAt,
1707
+ detail: "fire-and-forget trigger; build receipt captured by workflow-owned build-state observation (pre/post-trigger diff); observation, not re-issuance"
1708
+ }, totalReworkCount, "receipt");
1678
1709
  } else {
1679
1710
  var newAuditDirs = [];
1680
1711
  try {
@@ -1805,10 +1836,6 @@ while (i < STEPS.length) {
1805
1836
  // its outcome — submitted, rejected, or unknown (unknown is recorded
1806
1837
  // at the park site above). A later run or human matches commit hash +
1807
1838
  // attempt key against the builder's eventual completion.
1808
- // (2026-09-16) The trigger is fire-and-forget: the observation above
1809
- // already recorded the ledger's submitted line on both positive paths
1810
- // and parked on unknown — there is no applied report to observe and
1811
- // no rejection signal to record.
1812
1839
  // (2026-09-18, H2 verdict-first) The verdict was decided exactly
1813
1840
  // once above; dispatch on it. landed bypasses the receipt poll
1814
1841
  // (the audit evidence already proved completion); an explicit
@@ -1942,21 +1969,12 @@ while (i < STEPS.length) {
1942
1969
  log("Publish build landed for task " + taskId + " — provenance stamp deferred to parent content verification");
1943
1970
  } else {
1944
1971
  // STEP 1b durable audit-dir fallback (2026-09-15, task aadeccc3):
1945
- // the poll above only observes IN-FLIGHT builds. A build that
1946
- // finished between the receipt capture and the poll's first check
1947
- // leaves no in-flight trace — but the platform's audit harness
1948
- // leaves a durable one (~/workspace/ts-spaces/<slug>/audits/
1949
- // <timestamp>-<id>/ per completed build). Diff the audit-dir
1950
- // listing against the pre-trigger snapshot: a timestamped dir
1951
- // that appeared during the attempt window is evidence a build
1952
- // completed. Attribution is by window, not by build identity:
1953
- // the poll's saw_stranger signal only catches stranger builds in
1954
- // flight AT a check — a stranger that finished entirely inside
1955
- // the window is indistinguishable, so any observed stranger
1956
- // blocks attribution and the outcome stays unknown. This never
1957
- // re-issues the edit and never stamps provenance — ok=true only
1958
- // routes to the parent's independent content read-back, which
1959
- // remains the real verification.
1972
+ // the poll observes only in-flight builds; a build that finished
1973
+ // inside the window leaves a durable audit dir. Diff the audit
1974
+ // listing against the pre-trigger snapshot: a new timestamped dir
1975
+ // is evidence a build completed. Attribution is by window, not by build identity: any
1976
+ // observed stranger blocks it. Never re-issues, never stamps;
1977
+ // ok=true routes to the parent's read-back (the verification).
1960
1978
  //
1961
1979
  // The poll end-state is read from the poll's own observations,
1962
1980
  // not from build_done alone: a build in flight at the last check
@@ -165,12 +165,14 @@ const SCHEMA_SQL_SRC = crewHome + "/lib/schema.sql";
165
165
  const SCHEMA_SQL_PINNED = RUN_LIB + "/schema.sql";
166
166
  const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
167
167
  const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
168
+ const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
169
+ const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
168
170
  // The six basenames the pin step must materialize — asserted mechanically
169
171
  // by workflow code from the verbatim listing, never from agent prose.
170
172
  // CLASSIFY_SURFACE is the surface classifier (room #15, 2026-09-18):
171
173
  // crew-api.js statically imports it, so the pin must carry it — a pin
172
174
  // without it kills every claim with ERR_MODULE_NOT_FOUND.
173
- const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, CLASSIFY_SURFACE].map(function (p) { return p.split("/").pop(); });
175
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
174
176
 
175
177
  // Project config — passed by dispatcher, falls back to dashboard defaults
176
178
  const projectConfig = inputs.project_config || {};
@@ -201,7 +203,7 @@ if (!taskId) {
201
203
  function pinLifecycle(key) {
202
204
  return agent(
203
205
  "Snapshot lifecycle scripts for version pinning.\n" +
204
- "Run: mkdir -p " + sq(RUN_LIB) + " && cp " + sq(LIFECYCLE_SRC) + " " + sq(LIFECYCLE) + " && cp " + sq(MERGE_LOCK_SRC) + " " + sq(MERGE_LOCK) + " && cp " + sq(PUBLISH_NPM_SRC) + " " + sq(PUBLISH_NPM) + " && cp " + sq(CREW_API_SRC) + " " + sq(CREW_API_PINNED) + " && cp " + sq(SCHEMA_SQL_SRC) + " " + sq(SCHEMA_SQL_PINNED) + " && cp " + sq(CLASSIFY_SURFACE_SRC) + " " + sq(CLASSIFY_SURFACE) + " && chmod +x " + sq(LIFECYCLE) + " " + sq(MERGE_LOCK) + " " + sq(PUBLISH_NPM) + " && ls -1 " + sq(RUN_LIB) + "\n" +
206
+ "Run: mkdir -p " + sq(RUN_LIB) + " && cp " + sq(LIFECYCLE_SRC) + " " + sq(LIFECYCLE) + " && cp " + sq(MERGE_LOCK_SRC) + " " + sq(MERGE_LOCK) + " && cp " + sq(PUBLISH_NPM_SRC) + " " + sq(PUBLISH_NPM) + " && cp " + sq(CREW_API_SRC) + " " + sq(CREW_API_PINNED) + " && cp " + sq(SCHEMA_SQL_SRC) + " " + sq(SCHEMA_SQL_PINNED) + " && cp " + sq(CLASSIFY_SURFACE_SRC) + " " + sq(CLASSIFY_SURFACE) + " && cp " + sq(NOTE_VOCAB_SRC) + " " + sq(NOTE_VOCAB) + " && chmod +x " + sq(LIFECYCLE) + " " + sq(MERGE_LOCK) + " " + sq(PUBLISH_NPM) + " && ls -1 " + sq(RUN_LIB) + "\n" +
205
207
  "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
206
208
  { key: key, label: "Pinning lifecycle scripts",
207
209
  schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }