muse-crew 0.14.5 → 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.
Files changed (41) hide show
  1. package/API.md +106 -12
  2. package/docs/decisions/AGENTS.md +5 -0
  3. package/docs/decisions/publish-path.md +247 -9
  4. package/docs/decisions/qa-reproduce.md +30 -0
  5. package/docs/guide.md +41 -5
  6. package/docs/publish-unknown-recovery.md +28 -120
  7. package/docs/publish-verification.md +148 -501
  8. package/lib/AGENTS.md +9 -13
  9. package/lib/advance-publish-base.js +3 -3
  10. package/lib/compose-evidence-caption.js +1 -2
  11. package/lib/compute-publish-diff.js +82 -5
  12. package/lib/crew-api.js +1134 -1027
  13. package/lib/merge-lock.sh +153 -41
  14. package/lib/publish-note-vocabulary.js +135 -42
  15. package/lib/qa-db.js +132 -0
  16. package/lib/schema.sql +69 -0
  17. package/lib/serve-artifact.js +46 -2
  18. package/lib/test-detached-integrate.sh +122 -0
  19. package/lib/test-merge-lock.sh +30 -1
  20. package/lib/worktree-lifecycle.sh +284 -34
  21. package/package.json +1 -1
  22. package/seed/AGENTS.md +1 -0
  23. package/seed/cron-body-ack-scan.md +44 -0
  24. package/seed/cron-body-template.md +55 -89
  25. package/seed/crons.json +12 -0
  26. package/workflows/AGENTS.md +1 -1
  27. package/workflows/bugfix.js +489 -224
  28. package/workflows/chore.js +306 -226
  29. package/workflows/crew-dispatch.js +57 -4
  30. package/workflows/crew-init.js +23 -0
  31. package/workflows/crew-uninstall.js +7 -4
  32. package/workflows/docs.js +24 -2
  33. package/workflows/standard.js +494 -251
  34. package/workflows/upgrade.js +13 -1
  35. package/lib/build-readback-request.js +0 -140
  36. package/lib/check-intent-freshness.js +0 -101
  37. package/lib/classify-publish-absence.js +0 -462
  38. package/lib/publish-content.js +0 -154
  39. package/lib/readback-disk.js +0 -195
  40. package/lib/retry-publish.js +0 -417
  41. package/lib/verify-publish.js +0 -416
package/lib/merge-lock.sh CHANGED
@@ -19,6 +19,20 @@
19
19
  # (or malformed) lease is broken with logging and re-acquired. Every op
20
20
  # appends one line to $CREW_HOME/.merge-lock.log (the audit trail).
21
21
  #
22
+ # Stale-break atomicity (R-B1, 2026-09-21): the break's read→rm→create is
23
+ # serialized on a sidecar flock ($LOCK_FILE.flock) and the lease is re-read
24
+ # inside the critical section — the old unconditional rm before the noclobber
25
+ # create let two concurrent reclaimers both win. Refresh rewrites via
26
+ # temp-file + atomic rename so a concurrent reader never sees a torn file,
27
+ # and takes the same sidecar flock so a refresh can never clobber a
28
+ # reclaimer's fresh lock in its read→mv gap (2026-09-21): refresh's
29
+ # identity-only re-check closes the clobber because a reclaim always changes
30
+ # task_id. Refresh never checks expiry — a long build that outran the lease
31
+ # legitimately revives its lock here (publish-npm.sh step 5b relies on it).
32
+ # Release (review pass 2, 2026-09-21) takes the same sidecar flock with the
33
+ # identity re-check inside the critical section, so a release can never rm
34
+ # a reclaimer's fresh lock in its read→rm gap.
35
+ #
22
36
  # Usage:
23
37
  # merge-lock.sh acquire <task_id> <holder> — acquire the lock (0 ok, 1 held)
24
38
  # merge-lock.sh refresh <task_id> — extend the lease (holder task only)
@@ -44,6 +58,7 @@ fi
44
58
 
45
59
  LOCK_DIR="$CREW_REPO/.worktrees"
46
60
  LOCK_FILE="$LOCK_DIR/.merge-lock"
61
+ FLOCK_FILE="$LOCK_FILE.flock" # sidecar serializing stale-lease breaks (R-B1) and refresh rewrites (2026-09-21)
47
62
  LOG_FILE="$CREW_HOME/.merge-lock.log"
48
63
  LEASE_SECONDS="${MERGE_LOCK_LEASE_SECONDS:-600}"
49
64
 
@@ -127,55 +142,152 @@ case "$cmd" in
127
142
  exit 1
128
143
  fi
129
144
  # Expired or malformed lease — break it (logged) and re-acquire.
145
+ # R-B1 (2026-09-21): read→rm→create is serialized on the sidecar flock.
146
+ # The rm used to run before the noclobber create outside any atomic op,
147
+ # so two concurrent reclaimers could both win: B read the stale lease,
148
+ # A reclaimed (rm + create, exit 0), B's rm deleted A's fresh lock, and
149
+ # B's create then succeeded too. The lease is re-read INSIDE the
150
+ # critical section — a contender may have refreshed, released, or
151
+ # reclaimed while we waited on the flock.
130
152
  echo "STALE: lease for ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) expired — breaking" >&2
131
153
  log_op acquire "task_id=$task_id holder=$holder result=STALE-BREAK previous_task=${lock_task_id:-unknown} previous_holder=${lock_holder:-unknown}"
132
- rm -f "$LOCK_FILE"
133
- if write_lock "$task_id" "$holder"; then
134
- log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED-RECLAIMED previous_task=${lock_task_id:-unknown}"
135
- echo "ACQUIRED by $task_id (holder $holder; reclaimed expired lease from ${lock_task_id:-unknown})"
136
- exit 0
137
- fi
138
- # Lost the race: whoever won the create holds it now.
139
- read_lock 2>/dev/null || true
140
- log_op acquire "task_id=$task_id holder=$holder result=HELD-RACE holder_task=${lock_task_id:-unknown}"
141
- echo "HELD by ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) — lost reclaim race"
142
- exit 1
154
+ stale_task="${lock_task_id:-unknown}"
155
+ mkdir -p "$LOCK_DIR"
156
+ # Subshell exit codes: 0 reclaimed; 10 the re-read found a live lease;
157
+ # 11 lost the noclobber create to a fresh acquirer racing the break.
158
+ reclaim_code=0
159
+ (
160
+ exec 200>"$FLOCK_FILE"
161
+ flock -x 200
162
+ if read_lock; then
163
+ recheck="$(lock_remaining)"
164
+ if [ "$recheck" != "unknown" ] && [ "$recheck" -gt 0 ]; then
165
+ exit 10
166
+ fi
167
+ fi
168
+ rm -f "$LOCK_FILE"
169
+ if write_lock "$task_id" "$holder"; then
170
+ exit 0
171
+ else
172
+ exit 11
173
+ fi
174
+ ) || reclaim_code=$?
175
+ case "$reclaim_code" in
176
+ 0)
177
+ log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED-RECLAIMED previous_task=$stale_task"
178
+ echo "ACQUIRED by $task_id (holder $holder; reclaimed expired lease from $stale_task)"
179
+ exit 0
180
+ ;;
181
+ 10)
182
+ # Re-read found a live lease inside the critical section: another
183
+ # contender refreshed or reclaimed while we waited on the flock.
184
+ read_lock 2>/dev/null || true
185
+ if [ -n "$lock_task_id" ]; then
186
+ live_remaining="$(lock_remaining)"
187
+ log_op acquire "task_id=$task_id holder=$holder result=HELD-RECHECK holder_task=$lock_task_id holder_id=$lock_holder remaining=${live_remaining}s"
188
+ echo "HELD by $lock_task_id (holder $lock_holder, ${live_remaining}s of lease remaining)"
189
+ else
190
+ log_op acquire "task_id=$task_id holder=$holder result=HELD-RECHECK holder_task=unknown"
191
+ echo "HELD — lost the reclaim race to another contender"
192
+ fi
193
+ exit 1
194
+ ;;
195
+ *)
196
+ # Lost the noclobber create to a fresh acquirer racing the break:
197
+ # whoever won the create holds the lock now.
198
+ read_lock 2>/dev/null || true
199
+ log_op acquire "task_id=$task_id holder=$holder result=HELD-RACE holder_task=${lock_task_id:-unknown}"
200
+ echo "HELD by ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) — lost reclaim race"
201
+ exit 1
202
+ ;;
203
+ esac
143
204
  ;;
144
205
  refresh)
145
206
  [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
146
- if ! read_lock; then
147
- log_op refresh "task_id=$task_id result=NO-LOCK"
148
- echo "ERROR: no lock to refresh"
149
- exit 1
150
- fi
151
- if [ "$lock_task_id" != "$task_id" ]; then
152
- log_op refresh "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
153
- echo "ERROR: lock held by $lock_task_id, not $task_id"
154
- exit 1
155
- fi
156
- # Extend the lease: acquired_at=now, holder and lease window unchanged.
157
- printf 'task_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\n' \
158
- "$lock_task_id" "$lock_holder" "$(date +%s)" "${lock_lease_seconds:-$LEASE_SECONDS}" > "$LOCK_FILE"
159
- log_op refresh "task_id=$task_id holder=$lock_holder result=REFRESHED"
160
- echo "REFRESHED by $task_id (holder $lock_holder)"
161
- exit 0
207
+ # 2026-09-21: read→identity-check→rewrite is serialized on the sidecar
208
+ # flock against acquire's reclaim — a holder refreshing after lease
209
+ # expiry could otherwise clobber a reclaimer's fresh lock inside the
210
+ # read→mv gap. The re-check inside the critical section is IDENTITY-ONLY:
211
+ # a reclaim always changes task_id, so identity alone closes the clobber.
212
+ # There is deliberately NO expiry fail-closed check — a long build that
213
+ # outran the lease legitimately revives its lock here (publish-npm.sh
214
+ # step 5b and the workflow Publish prompt rely on it). Fail closed (exit
215
+ # 1) only if identity no longer matches.
216
+ refresh_code=0
217
+ (
218
+ exec 200>"$FLOCK_FILE"
219
+ flock -x 200
220
+ if ! read_lock; then
221
+ log_op refresh "task_id=$task_id result=NO-LOCK"
222
+ echo "ERROR: no lock to refresh"
223
+ exit 1
224
+ fi
225
+ if [ "$lock_task_id" != "$task_id" ]; then
226
+ log_op refresh "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
227
+ echo "ERROR: lock held by $lock_task_id, not $task_id"
228
+ exit 1
229
+ fi
230
+ # Extend the lease: acquired_at=now, holder and lease window unchanged.
231
+ # Write-temp + atomic rename (2026-09-21): a truncating rewrite lets a
232
+ # concurrent reader observe a torn (partially written) file and misread
233
+ # the lease as malformed — funneling it spuriously into the reclaim path.
234
+ tmp_lock="$(mktemp "$LOCK_DIR/.merge-lock.tmp.XXXXXX")" \
235
+ || { echo "ERROR: cannot stage lock refresh"; exit 1; }
236
+ printf 'task_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\n' \
237
+ "$lock_task_id" "$lock_holder" "$(date +%s)" "${lock_lease_seconds:-$LEASE_SECONDS}" > "$tmp_lock"
238
+ mv -f "$tmp_lock" "$LOCK_FILE"
239
+ log_op refresh "task_id=$task_id holder=$lock_holder result=REFRESHED"
240
+ echo "REFRESHED by $task_id (holder $lock_holder)"
241
+ exit 0
242
+ ) || refresh_code=$?
243
+ exit "$refresh_code"
162
244
  ;;
163
245
  release)
164
246
  [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
165
- if ! read_lock; then
166
- log_op release "task_id=$task_id result=NOT-LOCKED"
167
- echo "RELEASED (was not locked)"
168
- exit 0
169
- fi
170
- if [ "$lock_task_id" = "$task_id" ]; then
171
- rm -f "$LOCK_FILE"
172
- log_op release "task_id=$task_id holder=$lock_holder result=RELEASED"
173
- echo "RELEASED by $task_id"
174
- exit 0
175
- fi
176
- log_op release "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
177
- echo "ERROR: lock held by $lock_task_id, not $task_id"
178
- exit 1
247
+ # 2026-09-21 (review pass 2): read→identity-check→rm is serialized on the
248
+ # sidecar flock — the same double-hold race class as R-B1 is reachable
249
+ # here: releaser reads, reclaimer breaks and creates, releaser's rm then
250
+ # deletes the reclaimer's fresh lock. The identity re-check runs INSIDE
251
+ # the critical section.
252
+ release_code=0
253
+ # The identity check and rm run in a subshell, so the holder name is
254
+ # ferried out on stdout (subshell variables never reach the parent).
255
+ release_out="$(
256
+ (
257
+ exec 200>"$FLOCK_FILE"
258
+ flock -x 200
259
+ if ! read_lock; then
260
+ exit 10
261
+ fi
262
+ if [ "$lock_task_id" != "$task_id" ]; then
263
+ exit 11
264
+ fi
265
+ printf 'holder=%s\n' "$lock_holder"
266
+ rm -f "$LOCK_FILE"
267
+ exit 0
268
+ )
269
+ )" || release_code=$?
270
+ release_holder="${release_out#holder=}"
271
+ case "$release_code" in
272
+ 0)
273
+ log_op release "task_id=$task_id holder=$release_holder result=RELEASED"
274
+ echo "RELEASED by $task_id"
275
+ exit 0
276
+ ;;
277
+ 10)
278
+ log_op release "task_id=$task_id result=NOT-LOCKED"
279
+ echo "RELEASED (was not locked)"
280
+ exit 0
281
+ ;;
282
+ *)
283
+ # Re-read inside the critical section: identity checked against the
284
+ # lock as it stands now, not as it stood before the flock.
285
+ read_lock 2>/dev/null || true
286
+ log_op release "task_id=$task_id result=NOT-HOLDER holder_task=${lock_task_id:-unknown}"
287
+ echo "ERROR: lock held by ${lock_task_id:-unknown}, not $task_id"
288
+ exit 1
289
+ ;;
290
+ esac
179
291
  ;;
180
292
  status)
181
293
  if ! read_lock; then
@@ -1,64 +1,157 @@
1
- // Terminal-note vocabulary registry (D7, 2026-09-19). Explicit state, not
2
- // prose: every `publish: …` note the state machine treats as terminally
3
- // parked. Readers (scan-publish-unknown in lib/crew-api.js) consult this;
4
- // writers assert against it (lib/verify-publish.js's terminal() fails loud
5
- // before emitting a verb that is not in the registry). Add a note here when
6
- // a new terminal verb is introduced — the scan recognizes it with no other
7
- // change.
1
+ // Publish-note vocabulary registry (D7, 2026-09-19; 0.14.6 version-acknowledgement
2
+ // rewrite, 2026-09-20). Explicit state, not prose.
8
3
  //
9
- // This module is the closed-enum pattern's first instance: the audit
10
- // (room23-design/blanket-call-audit.md §3) lists the other state-carrying
11
- // string families (the full `publish: <transition>` note shapes, the
12
- // publish-ledger `outcome` vocabulary, worker-report marker lines) that a
13
- // later unification pass extends the enum-guard pattern to. The contract
14
- // for any new family: one registry module, readers consult it, writers
15
- // assert membership before writing, closed by default and open by edit.
4
+ // 0.14.6 replaced the content-verdict/readback architecture with Eric's
5
+ // publication contract: exact version acknowledgement is the sole positive
6
+ // completion criterion ("If we hear that the artifact acknowledges our
7
+ // version, that's it. We don't verify against content."). Provenance now
8
+ // certifies that an issuance request was ACKNOWLEDGED — never that bytes
9
+ // matched.
10
+ //
11
+ // Two registries:
12
+ // TERMINAL_PUBLISH_NOTES — every `publish: …` note the ack/intent scans
13
+ // treat as terminally parked. Readers consult this; a recognized
14
+ // terminal note is skipped as terminal with its meaning, never as
15
+ // unrecognized. Legacy verbs from the retired content-verdict machine
16
+ // stay recognized (marked below) so old note history never becomes
17
+ // "unrecognized" — the new machine never writes them.
18
+ // TRANSITIONAL_PUBLISH_NOTES — every non-terminal state-carrying
19
+ // `publish: …` note the 0.14.6 machine writes.
20
+ //
21
+ // Writers are split in two (2026-09-20 REVIEW): deterministic code writes
22
+ // through writeGuardedPublishNote (asserts CODE_WRITABLE_PUBLISH_NOTES —
23
+ // transitional + the four current terminals); tick prose writes only
24
+ // through the `record-publish-note` crew-api command (asserts
25
+ // WRITABLE_PUBLISH_NOTES — transitional ONLY, so prose can never mint a
26
+ // terminal state). A third prose path, `log-event` with type "note", is
27
+ // mechanically rejected for any `publish:` verb — notes are advisory, but
28
+ // only the closed writer may mint state-carrying ones.
29
+ //
30
+ // The one gap: the workflow's `publish: publish-requested` park note is
31
+ // written by the workflow's own parkTask plumbing, not through crew-api —
32
+ // the intent scan validates its shape (commit + attempt) against the intent
33
+ // ledger entry when claiming, which is the mechanical guard for that path.
34
+ //
35
+ // Add a note here when a new verb is introduced — readers recognize it with
36
+ // no other change, and writers cannot emit it without registering it.
16
37
  //
17
38
  // ESM, no shebang, no side effects on import (import-safe module — bare
18
39
  // `node publish-note-vocabulary.js` exits 0).
40
+ //
41
+ // The four 0.14.6 terminals the machine's own deterministic code writes
42
+ // (never tick prose — see the writer split above).
43
+ export const CODE_TERMINAL_PUBLISH_NOTES = [
44
+ // The artifact acknowledged the attempt's exact version on disk.
45
+ // Provenance is stamped; the task re-queues. (2026-09-20 REVIEW: disk
46
+ // is the sole positive evidence — the builder-report path was circular.)
47
+ "publish: version-acknowledged",
48
+ // The platform explicitly refused the tick worker's directly-issued edit.
49
+ // Honest terminal state — no re-issue; parked for human attention.
50
+ "publish: publish-refused",
51
+ // The 2-hour acknowledgement budget from first issuance is exhausted with
52
+ // no acknowledgement (unobserved within budget — the edit may still have
53
+ // landed). Parked for human attention.
54
+ "publish: version-timeout",
55
+ // One-party intent terminal: the diff for the committed intent cannot be
56
+ // (re)generated byte-identically — the staged file is missing or corrupt,
57
+ // or the base..commit range cannot be regenerated. Never re-claim, never
58
+ // loop; parked for human attention.
59
+ "publish: publish-unissuable",
60
+ ];
19
61
  export const TERMINAL_PUBLISH_NOTES = [
62
+ ...CODE_TERMINAL_PUBLISH_NOTES,
63
+ // Legacy terminals (retired content-verdict/unknown-recovery machine):
64
+ // recognized so old note history skips as terminal, never written anew.
20
65
  "publish: ambiguous",
21
66
  "publish: retry-superseded",
22
67
  "publish: retry-refused",
23
68
  "publish: verified",
24
69
  "publish: superseded",
25
- // One-party publish refusal (2026-09-20, blocker 22): the session-carrying
26
- // tick worker issued the edit directly and the platform refused it.
27
- // Honest terminal state — no re-issue, no park-unknown; the task stays
28
- // parked for human attention.
29
- "publish: publish-refused",
30
- // Verification pipeline's terminal content verdict (verify-publish.js).
31
- // The task stays parked for human attention; unknown-recovery never
32
- // re-enters it. Added 2026-09-19 (Room #23 J1/J3: was mislogged as
33
- // unrecognized-publish-note).
34
70
  "publish: verification-failed",
35
- // (2026-09-20, critic-0145 F-O2) One-party intent terminal: the diff for
36
- // the committed intent cannot be (re)generated byte-identically — the
37
- // staged file is missing or corrupt, or the base..commit range cannot be
38
- // regenerated. Never re-claim, never loop on an hourly reclaim; parked
39
- // for human attention.
40
- "publish: publish-unissuable",
41
- // (2026-09-20, critic-0145 F-R3) One-party intent terminal: a re-claimed
42
- // intent arrived with no pre-issuance manifest baseline, so the freshness
43
- // comparison is unperformable — the tick can neither prove a dead tick's
44
- // issuance nor safely re-issue. Never blind-issue, never loop
45
- // indefinitely; parked for human attention.
46
71
  "publish: intent-unverifiable",
47
72
  ];
48
73
  export const TERMINAL_NOTE_MEANINGS = {
49
- "publish: ambiguous": "unknown-recovery terminal: could not prove the drop; parked for human attention",
50
- "publish: retry-superseded": "retry protocol terminal: superseded; parked for human attention",
51
- "publish: retry-refused": "retry protocol terminal: platform refused the re-issued edit; parked for human attention",
52
- "publish: verified": "publish verified and stamped; task re-queued (terminal for the scan)",
53
- "publish: superseded": "HEAD moved past the attempt; recovery aborted; parked for human attention",
54
- "publish: publish-refused": "one-party publish terminal: the platform refused the tick worker's directly-issued edit; parked for human attention",
55
- "publish: verification-failed": "verification pipeline's terminal content verdict — parked for human attention",
74
+ "publish: version-acknowledged": "the artifact acknowledged this attempt's exact version — provenance stamped; task re-queued (terminal for the scans)",
75
+ "publish: publish-refused": "the platform refused the tick worker's directly-issued edit (or a builder report carried the version + an explicit refusal) — parked for human attention",
76
+ "publish: version-timeout": "the 2-hour acknowledgement budget from first issuance expired with no version acknowledgement (unobserved within budget — the edit may still have landed; the outcome is unknown) — parked for human attention",
56
77
  "publish: publish-unissuable": "one-party intent terminal: the intent diff cannot be (re)generated byte-identically; parked for human attention",
57
- "publish: intent-unverifiable": "one-party intent terminal: re-claimed intent with no manifest baseline — freshness unprovable; parked for human attention",
78
+ "publish: ambiguous": "legacy (retired unknown-recovery): could not prove the drop; parked for human attention",
79
+ "publish: retry-superseded": "legacy (retired retry protocol): superseded; parked for human attention",
80
+ "publish: retry-refused": "legacy (retired retry protocol): platform refused the re-issued edit; parked for human attention",
81
+ "publish: verified": "legacy (retired content-verdict pipeline): publish verified and stamped; task re-queued",
82
+ "publish: superseded": "legacy (retired unknown-recovery): HEAD moved past the attempt; recovery aborted; parked for human attention",
83
+ "publish: verification-failed": "legacy (retired content-verdict pipeline): terminal content verdict — parked for human attention",
84
+ "publish: intent-unverifiable": "legacy (retired manifest-freshness path): re-claimed intent with no manifest baseline; parked for human attention",
58
85
  };
86
+ // 0.14.6 transitional notes — every non-terminal state-carrying note the
87
+ // machine writes. The ack scan (scan-ack-pending) is atomic code and needs
88
+ // no claim note of its own.
89
+ export const TRANSITIONAL_PUBLISH_NOTES = [
90
+ // The workflow parked at Publish with a checksummed publish-intent ledger
91
+ // entry (carries the per-attempt version). The intent scan claims these.
92
+ "publish: publish-requested",
93
+ // The intent scan claimed this tick's issuance lease (1 hour).
94
+ "publish: publish-intent-claimed",
95
+ // The intent's staged diff was computed against a superseded base —
96
+ // re-queued for the workflow's Publish to re-prepare. Never issued.
97
+ "publish: publish-base-stale",
98
+ // The tick worker issued the edit and recorded it (issuer-stamped
99
+ // submitted ledger entry). The ack scan owns the task from here.
100
+ "publish: edit-issued",
101
+ ];
102
+ // The prose-writable set (0.14.6 §1.5, closed publish-note writer): the ONLY
103
+ // verbs tick prose may mint, via the `record-publish-note` crew-api command.
104
+ // Transitional notes only — prose can never mint a terminal state. Legacy
105
+ // terminals are recognized (read-side, so old note history still skips as
106
+ // terminal) but NOT writable — history stays readable; nothing new may be
107
+ // written in the old tongue.
108
+ export const WRITABLE_PUBLISH_NOTES = [
109
+ ...TRANSITIONAL_PUBLISH_NOTES,
110
+ ];
111
+ // The code-writable set: deterministic code (the scans and the guarded
112
+ // record-* commands) writes through writeGuardedPublishNote, which asserts
113
+ // here — transitional notes plus the four current terminals the machine
114
+ // itself transitions to. Legacy terminals are never written anew.
115
+ export const CODE_WRITABLE_PUBLISH_NOTES = [
116
+ ...TRANSITIONAL_PUBLISH_NOTES,
117
+ ...CODE_TERMINAL_PUBLISH_NOTES,
118
+ ];
119
+ function extractPublishVerb(message) {
120
+ const m = /publish:\s*([a-z0-9-]+)/.exec(String(message || ""));
121
+ return m ? `publish: ${m[1]}` : null;
122
+ }
59
123
  export function matchTerminalPublishNote(message) {
60
124
  for (const note of TERMINAL_PUBLISH_NOTES) {
61
125
  if (message.includes(note)) return note; // same substring semantics the scan already uses
62
126
  }
63
127
  return null;
64
128
  }
129
+ // Prose-side guard (0.14.6 §1.5, closed publish-note writer): the
130
+ // `record-publish-note` command asserts here. Returns the matched
131
+ // "publish: <verb>". Throws (usage) on anything else — prose cannot mint
132
+ // new states, and cannot mint terminal states.
133
+ export function assertWritablePublishNote(message) {
134
+ const verb = extractPublishVerb(message);
135
+ if (!verb || !WRITABLE_PUBLISH_NOTES.includes(verb)) {
136
+ throw new Error(
137
+ `unregistered publish-note verb ${verb === null ? "(none found)" : `'${verb}'`} — ` +
138
+ `tick prose may only mint transitional publish notes (lib/publish-note-vocabulary.js); ` +
139
+ `terminal states are written by deterministic code, never prose. Legacy terminals are recognized but not writable.`
140
+ );
141
+ }
142
+ return verb;
143
+ }
144
+ // Code-side guard: writeGuardedPublishNote asserts here. Returns the
145
+ // matched "publish: <verb>". Throws on anything else — a registry
146
+ // violation is a caller bug (usage).
147
+ export function assertCodeWritablePublishNote(message) {
148
+ const verb = extractPublishVerb(message);
149
+ if (!verb || !CODE_WRITABLE_PUBLISH_NOTES.includes(verb)) {
150
+ throw new Error(
151
+ `unregistered publish-note verb ${verb === null ? "(none found)" : `'${verb}'`} — ` +
152
+ `state-carrying publish notes are a closed set (lib/publish-note-vocabulary.js); ` +
153
+ `register the verb before writing it. Legacy terminals are recognized but never written anew.`
154
+ );
155
+ }
156
+ return verb;
157
+ }
package/lib/qa-db.js ADDED
@@ -0,0 +1,132 @@
1
+ // qa-db.js — fresh per-run QA database for the local artifact server.
2
+ //
3
+ // Import-safe: no side effects on import. `node qa-db.js` with no args exits
4
+ // 0 (the release entry gate executes bare non-CLI lib modules).
5
+ //
6
+ // `openQaDb(spaceDir)` opens a drizzle db over a FRESH per-run SQLite
7
+ // database, migrated from the space's own `drizzle/` migrations in journal
8
+ // order — the same migration path a fresh production install takes — and
9
+ // never a copy of the shipped app.db. The database file lives in a per-run
10
+ // temp dir, so the server stays read-only w.r.t. the space directory and QA
11
+ // writes can never contaminate production data (the B38 contamination
12
+ // class: audit sessions writing verification data into the live artifact's
13
+ // production DB).
14
+ //
15
+ // Driver: drizzle-orm/sqlite-proxy resolved from the space's own
16
+ // node_modules (so the driver matches the artifact's drizzle version), over
17
+ // node:sqlite (built-in). Zero extra dependencies in the crew release.
18
+ //
19
+ // Proxy contract, verified mechanically against drizzle-orm 0.45.2's
20
+ // COMPILED runtime (sqlite-proxy/driver.js, sqlite-proxy/session.js,
21
+ // utils.js — not just the .d.ts):
22
+ // - utils.js mapResultRow(columns, row, ...) reads row[columnIndex]:
23
+ // rows must be POSITIONAL value arrays in SQL column order. The proxy
24
+ // prepares with { returnArrays: true }, so node:sqlite returns rows
25
+ // as positional arrays — duplicate column names (self-joins, t.*,
26
+ // joins of overlapping schemas) survive by ordinal. The object-keyed
27
+ // default would silently collapse them.
28
+ // - session.js get(): mapGetResult(clientResult.rows) — a miss must be
29
+ // FALSY rows (returns undefined); a hit must be the single row as a
30
+ // positional array (it is NOT wrapped — mapGetResult treats rows as the
31
+ // row itself).
32
+ // - session.js all()/values(): { rows } is destructured and .map'ed, so
33
+ // rows must be an array of positional arrays.
34
+ // - session.js run(): the callback's return is handed to the caller; the
35
+ // declared type is Promise<{rows: any[]}> — {rows: []} is the honest
36
+ // empty shape.
37
+ // - session.js batch(): batchResults.map((result, i) =>
38
+ // preparedQueries[i].mapResult(result, true)) — each item must be one
39
+ // per-query {rows} object in order; mapResult with isFromBatch unwraps
40
+ // rows.rows before the same get/all mapping above.
41
+ // - driver.js drizzle(callback, batchCallback?, config?): the second
42
+ // positional arg is the batch callback when it is a function.
43
+
44
+ import { DatabaseSync } from "node:sqlite";
45
+ import { createRequire } from "node:module";
46
+ import { pathToFileURL } from "node:url";
47
+ import { join } from "node:path";
48
+ import { existsSync } from "node:fs";
49
+ import { readFile } from "node:fs/promises";
50
+ import { mkdtemp } from "node:fs/promises";
51
+ import { tmpdir } from "node:os";
52
+
53
+ // sqlite-proxy callback over a node:sqlite DatabaseSync. See the contract
54
+ // note at the top of this file.
55
+ export function sqliteProxyCallback(sqlite) {
56
+ const one = async (sql, params, method) => {
57
+ // node:sqlite Statements have no close(); the DatabaseSync owns them
58
+ // and they are reclaimed with it.
59
+ // returnArrays: rows come back as POSITIONAL value arrays, so
60
+ // duplicate column names (self-joins, t.*, joins of overlapping
61
+ // schemas) survive by ordinal. The object-keyed default would
62
+ // silently collapse them — Object.values() would drop the earlier
63
+ // columns entirely.
64
+ const stmt = sqlite.prepare(sql, { returnArrays: true });
65
+ if (method === "run") {
66
+ stmt.run(...params);
67
+ return { rows: [] };
68
+ }
69
+ if (method === "all" || method === "values") {
70
+ return { rows: stmt.all(...params) };
71
+ }
72
+ if (method === "get") {
73
+ const row = stmt.get(...params);
74
+ return row === undefined ? { rows: undefined } : { rows: row };
75
+ }
76
+ throw new Error("sqlite-proxy: unknown method " + method);
77
+ };
78
+ return one;
79
+ }
80
+
81
+ // Fresh per-run QA database for spaceDir. Resolves to
82
+ // { db, close } — db is the drizzle instance handed to ctx.db(); close()
83
+ // releases the sqlite handle (best effort on kill). Throws with a clear
84
+ // reason when the space has no drizzle migrations or drizzle-orm cannot be
85
+ // resolved from its node_modules; callers degrade gracefully (the server
86
+ // still boots, ctx.db throws the reason when called).
87
+ export async function openQaDb(spaceDir) {
88
+ const drizzleDir = join(spaceDir, "drizzle");
89
+ const journalPath = join(drizzleDir, "meta", "_journal.json");
90
+ if (!existsSync(journalPath)) {
91
+ throw new Error("no drizzle migrations at " + journalPath);
92
+ }
93
+ const journal = JSON.parse(await readFile(journalPath, "utf8"));
94
+ const entries = [...(journal.entries || [])].sort((a, b) => a.idx - b.idx);
95
+ if (entries.length === 0) {
96
+ throw new Error("empty drizzle journal at " + journalPath);
97
+ }
98
+ // Per-run temp dir — never the space dir, never the shipped app.db.
99
+ const dbDir = await mkdtemp(join(tmpdir(), "serve-artifact-db-"));
100
+ const sqlite = new DatabaseSync(join(dbDir, "qa.db"));
101
+ try {
102
+ sqlite.exec("PRAGMA foreign_keys = ON;");
103
+ for (const entry of entries) {
104
+ const sqlPath = join(drizzleDir, entry.tag + ".sql");
105
+ sqlite.exec(await readFile(sqlPath, "utf8"));
106
+ }
107
+ } catch (err) {
108
+ try { sqlite.close(); } catch { /* best effort */ }
109
+ throw new Error("migration failed: " + String((err && err.message) || err).slice(0, 200));
110
+ }
111
+ // drizzle-orm/sqlite-proxy resolved from the space's own node_modules, so
112
+ // the driver matches the artifact's drizzle version.
113
+ let db;
114
+ try {
115
+ const spaceRequire = createRequire(join(spaceDir, "package.json"));
116
+ const proxyEntry = spaceRequire.resolve("drizzle-orm/sqlite-proxy");
117
+ const { drizzle } = await import(pathToFileURL(proxyEntry).href);
118
+ const one = sqliteProxyCallback(sqlite);
119
+ db = drizzle(one, async (items) => {
120
+ const out = [];
121
+ for (const item of items) out.push(await one(item.sql, item.params, item.method));
122
+ return out;
123
+ });
124
+ } catch (err) {
125
+ try { sqlite.close(); } catch { /* best effort */ }
126
+ throw new Error("cannot build drizzle db: " + String((err && err.message) || err).slice(0, 200));
127
+ }
128
+ return {
129
+ db,
130
+ close() { try { sqlite.close(); } catch { /* best effort on kill */ } },
131
+ };
132
+ }
package/lib/schema.sql CHANGED
@@ -64,6 +64,17 @@ CREATE TABLE IF NOT EXISTS tasks (
64
64
  deps TEXT NOT NULL DEFAULT '[]',
65
65
  filed_by TEXT,
66
66
  retry_reset_at TEXT,
67
+ -- Cascade-park attribution (room #26 blocker 36, 2026-09-21). When a task
68
+ -- parks, its todo dependents cascade-park with structured attribution on
69
+ -- the existing 'parked' state — never a new state, never silent todo,
70
+ -- never auto-waived. park_reason is a closed single-column CHECK
71
+ -- ('dep_parked' only); park_dep_id names the parked dep. NULL on both =
72
+ -- ordinary park (human hold). The pairing invariant (reason<->dep)
73
+ -- cannot be a schema CHECK — SQLite forbids cross-column CHECKs on
74
+ -- ADD COLUMN — so it is enforced by crew-api.js, the single writer of
75
+ -- these columns.
76
+ park_reason TEXT CHECK (park_reason IS NULL OR park_reason = 'dep_parked'),
77
+ park_dep_id TEXT,
67
78
  created_at TEXT NOT NULL,
68
79
  updated_at TEXT NOT NULL
69
80
  );
@@ -108,6 +119,45 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
108
119
  (already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))
109
120
  );
110
121
 
122
+ -- Room #26 blocker 33 (2026-09-21): structured, non-lossy verdict records.
123
+ -- Review verdict grounds were destroyed by the 2000-char session-note
124
+ -- truncation (the workflow slices the worker report before record-phase
125
+ -- writes it as notes) — a park on a verified-correct implementation was
126
+ -- unauditable because the grounds past char 2000 existed nowhere. The fib:
127
+ -- "the review notes are the review record." The notes are lossy by design
128
+ -- (downstream consumers read them: rejectionNotes, the QA backstop, the
129
+ -- dashboard); this table is the lossless record. record-phase optionally
130
+ -- carries the full grounds and writes the row in the same transaction as
131
+ -- the session note, so the record can never be missing when the note
132
+ -- exists. `summary` is the truncated note actually recorded (provenance of
133
+ -- what the lossy path carried); `grounds` is the full worker report,
134
+ -- uncapped. One verdict per recording session: a re-recorded phase for the
135
+ -- same session is the same evidence, so the first write wins (ON CONFLICT
136
+ -- DO NOTHING) and rows are never revised. A new Review execution claims a
137
+ -- new session, so rework rounds and dispatcher retries each get their own
138
+ -- row, ordered by created_at.
139
+ -- `verdict` is the machine-extracted verdict; INDETERMINATE is the
140
+ -- fail-closed extraction failure (re-ask exhausted) — the grounds are still
141
+ -- preserved even though no verdict could be read.
142
+ CREATE TABLE IF NOT EXISTS verdicts (
143
+ id TEXT PRIMARY KEY,
144
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
145
+ step TEXT NOT NULL,
146
+ attempt INTEGER NOT NULL CHECK (attempt >= 0),
147
+ reviewer TEXT NOT NULL,
148
+ verdict TEXT NOT NULL CHECK (verdict IN ('PASS', 'FAIL', 'INDETERMINATE')),
149
+ grounds TEXT NOT NULL,
150
+ summary TEXT NOT NULL,
151
+ -- Room #26 blocker 34 (redesign): what the review actually examined —
152
+ -- mechanical-fail | branch-diff | runtime-state-none | frozen-merge:<sha>.
153
+ -- NULL = not a classified review (e.g. docs.js path, legacy rows).
154
+ review_basis TEXT,
155
+ session_id TEXT NOT NULL,
156
+ created_at TEXT NOT NULL,
157
+ UNIQUE(session_id)
158
+ );
159
+ CREATE INDEX IF NOT EXISTS idx_verdicts_task ON verdicts(task_id);
160
+
111
161
  CREATE TABLE IF NOT EXISTS events (
112
162
  id TEXT PRIMARY KEY,
113
163
  type TEXT NOT NULL CHECK (type IN
@@ -205,3 +255,22 @@ CREATE TABLE IF NOT EXISTS platform_run_tasks (
205
255
  linked_at TEXT NOT NULL DEFAULT (datetime('now'))
206
256
  );
207
257
  CREATE INDEX IF NOT EXISTS platform_run_tasks_task_id_idx ON platform_run_tasks(task_id);
258
+
259
+ -- Builder reports (0.14.6, 2026-09-20). The tick worker records the artifact
260
+ -- platform's report text verbatim after each directly-issued edit via the
261
+ -- `record-builder-report` command. The command mechanically rejects unless
262
+ -- the report text contains the exact per-attempt version, so every row here is
263
+ -- a version-keyed refusal the ack scan can evaluate: outcome 'refused' + a
264
+ -- version matching an issued attempt = terminal publish-refused. (The old
265
+ -- 'acknowledged' outcome was cut 2026-09-20 REVIEW as circular — disk is the
266
+ -- sole positive evidence.) Rows are evidence, never deleted by the scans.
267
+ CREATE TABLE IF NOT EXISTS builder_reports (
268
+ id TEXT PRIMARY KEY,
269
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
270
+ version TEXT NOT NULL,
271
+ outcome TEXT NOT NULL CHECK (outcome IN ('acknowledged', 'refused')),
272
+ report_text TEXT NOT NULL,
273
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
274
+ );
275
+ CREATE INDEX IF NOT EXISTS builder_reports_task_id_idx ON builder_reports(task_id);
276
+ CREATE INDEX IF NOT EXISTS builder_reports_version_idx ON builder_reports(version);