muse-crew 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/crew-api.js CHANGED
@@ -65,6 +65,20 @@ function validateEnvironmentType(value) {
65
65
  throw usageError("environment_type must be 'artifact', 'terminal', or null.");
66
66
  }
67
67
 
68
+ // already_merged_sha validation (room #16 blocker 11, 2026-09-18): the
69
+ // workflow-verified already-merged sha as structured session control state.
70
+ // undefined means "not provided" (preserves the existing value); explicit
71
+ // null clears the field; otherwise a 7-40 hex string, normalized to
72
+ // lowercase. Anything else is a usage error. The CHECK constraint in
73
+ // schema.sql enforces the same shape at the SQL layer.
74
+ // layer; this is the API-boundary validation.
75
+ function validateAlreadyMergedSha(value) {
76
+ if (value === undefined || value === null) return null;
77
+ const s = String(value).trim().toLowerCase();
78
+ if (/^[0-9a-f]{7,40}$/.test(s)) return s;
79
+ throw usageError("already_merged_sha must be a 7-40 char hex string or null.");
80
+ }
81
+
68
82
  // ---------------------------------------------------------------------------
69
83
  // Crew home resolution (fail closed)
70
84
  // ---------------------------------------------------------------------------
@@ -88,12 +102,148 @@ function resolveCrewHome(flagValue) {
88
102
 
89
103
  const SCHEMA_PATH = join(import.meta.dirname, "schema.sql");
90
104
 
105
+ // Provenance shape rules, shared by the writer and the backfill's
106
+ // pre-validation so they can never diverge (room #15, 2026-09-18).
107
+ const PROVENANCE_SOURCE_COMMIT_RE = /^[0-9a-f]{40}$/;
108
+ const PROVENANCE_CREW_RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
109
+
110
+ // One-time backfill of the legacy crew-home-global provenance triple
111
+ // (config.provenance.*) into the per-project provenance columns (room #15
112
+ // blocker 8, 2026-09-18). Attribution is never fabricated: the legacy base
113
+ // is stamped onto a project ONLY when it is an ancestor of exactly one
114
+ // project's repo HEAD (verified with git, never assumed). Zero or ambiguous
115
+ // matches are recorded as deferred:<reason> and the legacy keys are left
116
+ // untouched — new readers see null (empty-tree first publish) while old
117
+ // readers keep their global triple. Watermarked in config, so it runs at
118
+ // most once per crew home. Benign edge: a legacy key written by the very
119
+ // command whose open triggers the backfill is re-created after the DELETE
120
+ // and lingers in config — inert dead storage, never read by new code.
121
+ function backfillProjectProvenance(db) {
122
+ let wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
123
+ if (wm && wm.value === "none") {
124
+ // "none" means "no legacy triple at the last look". A triple that
125
+ // arrives later (hand-written via update-config — the key space is
126
+ // open) is new evidence, not a settled state: clear the watermark and
127
+ // evaluate it. Any other watermark (stamped:*, deferred:*) is terminal.
128
+ const legacy = db.prepare(
129
+ "SELECT key FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'"
130
+ ).all();
131
+ if (legacy.length > 0) {
132
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
133
+ wm = null;
134
+ } else {
135
+ return;
136
+ }
137
+ }
138
+ if (wm) return;
139
+ const setWatermark = (v) =>
140
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(v);
141
+ const rows = db.prepare(
142
+ "SELECT key, value FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at','provenance.task_id')"
143
+ ).all();
144
+ const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
145
+ const sc = byKey["provenance.source_commit"];
146
+ const cr = byKey["provenance.crew_release"];
147
+ const pa = byKey["provenance.published_at"];
148
+ if (!sc && !cr && !pa && !byKey["provenance.task_id"]) {
149
+ // No legacy provenance at all: nothing to attribute, ever.
150
+ setWatermark("none");
151
+ return;
152
+ }
153
+ if (!sc || !cr || !pa) {
154
+ // Incomplete triple (partial hand-write): do NOT watermark — the next
155
+ // open retries. Watermarking here would strand keys that arrive later.
156
+ return;
157
+ }
158
+ const matches = [];
159
+ for (const p of db.prepare("SELECT id, repo_path FROM projects").all()) {
160
+ try {
161
+ execFileSync("git", ["merge-base", "--is-ancestor", sc, "HEAD"],
162
+ { cwd: p.repo_path, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] });
163
+ matches.push(p.id);
164
+ } catch {
165
+ // Not an ancestor, or the repo is unreadable: not a match.
166
+ }
167
+ }
168
+ if (matches.length === 1) {
169
+ // Validate the legacy triple against the writer's shape rules BEFORE
170
+ // touching the database: a hand-written abbreviated sha passes
171
+ // `git merge-base --is-ancestor` (git resolves abbreviations) but
172
+ // violates the storage CHECK — and a throw here would escape openDb
173
+ // and hard-fail every CLI command against the home. The backfill's
174
+ // whole contract is best-effort attribution, so a bad shape is a
175
+ // deferred watermark, never an exception. Legacy keys are retained
176
+ // read-only for old readers, exactly like the other deferred cases.
177
+ const shapeProblem =
178
+ !PROVENANCE_SOURCE_COMMIT_RE.test(sc ?? "")
179
+ ? `source_commit not 40-char hex (${String(sc).slice(0, 24)})`
180
+ : !PROVENANCE_CREW_RELEASE_RE.test(cr ?? "")
181
+ ? `crew_release not 40-char hex or pkg-<semver> (${String(cr).slice(0, 24)})`
182
+ : null;
183
+ if (shapeProblem) {
184
+ setWatermark(`deferred:stamp-failed:${shapeProblem}`);
185
+ } else {
186
+ db.exec("BEGIN");
187
+ try {
188
+ db.prepare(
189
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
190
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ? WHERE id = ?`
191
+ ).run(sc, cr, pa, byKey["provenance.task_id"] ?? null, now(), matches[0]);
192
+ db.prepare("DELETE FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'").run();
193
+ setWatermark(`stamped:${matches[0]}`);
194
+ db.exec("COMMIT");
195
+ } catch (e) {
196
+ db.exec("ROLLBACK");
197
+ const reason = String((e && e.message) || e).replace(/[\r\n]+/g, " ").slice(0, 100);
198
+ setWatermark(`deferred:stamp-failed:${reason}`);
199
+ }
200
+ }
201
+ } else if (matches.length === 0) {
202
+ setWatermark(`deferred:no-ancestor-match:${String(sc).slice(0, 7)}`);
203
+ } else {
204
+ setWatermark(`deferred:ambiguous:${matches.length}`);
205
+ }
206
+ }
207
+
208
+ // Re-run a deferred backfill when the match set may have changed: a new
209
+ // project was just created, and a `deferred:no-ancestor-match` or
210
+ // `deferred:ambiguous` watermark from an earlier look can now resolve —
211
+ // e.g. a home whose legacy triple predates its first project. Only
212
+ // deferred:* watermarks are retried, and only while the legacy keys still
213
+ // exist (a successful stamp deleted them). Returns the stamped project id,
214
+ // or null. Never throws for a bad triple — the evaluation watermarks
215
+ // instead (see above); a throw here means the database itself is broken,
216
+ // and failing project creation is then the honest outcome.
217
+ function retryDeferredBackfill(db) {
218
+ const wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
219
+ if (!wm || !String(wm.value).startsWith("deferred:")) return null;
220
+ const legacy = db.prepare(
221
+ "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
222
+ ).all();
223
+ if (legacy.length === 0) return null;
224
+ // Lift the terminal watermark, re-evaluate, and let the evaluation write
225
+ // the new one. If the re-evaluation throws (it shouldn't — every path
226
+ // watermarks), restore the old watermark so the deferral isn't lost.
227
+ const old = wm.value;
228
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
229
+ try {
230
+ backfillProjectProvenance(db);
231
+ } catch (e) {
232
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(old);
233
+ throw e;
234
+ }
235
+ const wm2 = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
236
+ const m = wm2 && /^stamped:(.+)$/.exec(String(wm2.value));
237
+ return m ? m[1] : null;
238
+ }
239
+
91
240
  function openDb(crewHome) {
92
241
  const dbPath = join(crewHome, "crew-state.db");
93
242
  const fresh = !existsSync(dbPath);
94
243
  const db = new DatabaseSync(dbPath);
95
244
  db.exec("PRAGMA journal_mode = WAL;");
96
245
  db.exec("PRAGMA foreign_keys = ON;");
246
+ db.exec("PRAGMA busy_timeout = 5000;");
97
247
  if (fresh) {
98
248
  const schema = readFileSync(SCHEMA_PATH, "utf8");
99
249
  db.exec(schema);
@@ -113,6 +263,23 @@ function openDb(crewHome) {
113
263
  if (!/duplicate column name/i.test(e.message)) throw e;
114
264
  }
115
265
  }
266
+ // Migration: add already_merged_sha column if missing (room #16 blocker
267
+ // 11, 2026-09-18). The workflow-verified already-merged sha as structured
268
+ // control state — Build's declaration used to travel to Review only
269
+ // through session notes, which are hard-capped at 3000 chars
270
+ // (record-phase below), so a truthful declaration at the report's tail
271
+ // never arrived. The workflow writes the sha here itself after the
272
+ // mechanical ancestor check; NULL = no verified declaration. Same
273
+ // idempotent PRAGMA-check pattern; no backfill (a missing value means
274
+ // "not verified", never fabricated).
275
+ if (!cols.some((c) => c.name === "already_merged_sha")) {
276
+ try {
277
+ db.exec("ALTER TABLE agent_sessions ADD COLUMN already_merged_sha TEXT CHECK (already_merged_sha IS NULL OR (already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))");
278
+ } catch (e) {
279
+ // Another process may have added it concurrently; ignore duplicate-column errors.
280
+ if (!/duplicate column name/i.test(e.message)) throw e;
281
+ }
282
+ }
116
283
  const projectCols = db.prepare("PRAGMA table_info(projects)").all();
117
284
  // Migration: add visual_protocol column if missing (per-project visual
118
285
  // protocol toggle, 2026-09-12). Same idempotent PRAGMA-check pattern;
@@ -139,6 +306,29 @@ function openDb(crewHome) {
139
306
  if (!/duplicate column name/i.test(e.message)) throw e;
140
307
  }
141
308
  }
309
+ // Migration: per-project provenance columns (room #15 blocker 8,
310
+ // 2026-09-18). Same idempotent PRAGMA-check pattern; ADD COLUMN without
311
+ // a default leaves existing rows NULL, which is "no provenance yet" by
312
+ // design — attribution is never fabricated, so there is no backfill step
313
+ // here beyond the watermarked legacy-triple attribution below.
314
+ const provMigration = [
315
+ ["provenance_source_commit", "TEXT CHECK (provenance_source_commit IS NULL OR length(provenance_source_commit) = 40)"],
316
+ ["provenance_crew_release", "TEXT"],
317
+ ["provenance_published_at", "TEXT"],
318
+ ["provenance_task_id", "TEXT"],
319
+ ];
320
+ const provColNames = new Set(db.prepare("PRAGMA table_info(projects)").all().map((c) => c.name));
321
+ for (const [colName, colType] of provMigration) {
322
+ if (!provColNames.has(colName)) {
323
+ try {
324
+ db.exec(`ALTER TABLE projects ADD COLUMN ${colName} ${colType}`);
325
+ } catch (e) {
326
+ if (!/duplicate column name/i.test(e.message)) throw e;
327
+ }
328
+ }
329
+ }
330
+ // Legacy global-provenance backfill (watermarked, runs at most once).
331
+ backfillProjectProvenance(db);
142
332
  }
143
333
  return db;
144
334
  }
@@ -217,6 +407,10 @@ function mapSession(row) {
217
407
  caveats: parseCaveats(row.caveats),
218
408
  failure_reason: row.failure_reason,
219
409
  last_heartbeat: row.last_heartbeat ?? null,
410
+ // Room #16 blocker 11: the workflow-verified already-merged sha (NULL
411
+ // when no verified declaration). Structured control state — Review
412
+ // hydrates from this field, never from capped prose.
413
+ already_merged_sha: row.already_merged_sha ?? null,
220
414
  };
221
415
  }
222
416
 
@@ -276,7 +470,7 @@ function requireTask(db, id) {
276
470
 
277
471
  function requireProject(db, id) {
278
472
  const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
279
- if (!row) throw notFound("Project not found.");
473
+ if (!row) throw notFound(`Project not found: "${id}" — check list-projects, or create it with create-project.`);
280
474
  return row;
281
475
  }
282
476
 
@@ -1181,18 +1375,24 @@ commands["upsert-session"] = (db, args) => {
1181
1375
  caveats: args.caveats === undefined ? (current?.caveats ?? "[]") : JSON.stringify(args.caveats),
1182
1376
  failure_reason: args.failure_reason === undefined ? (current?.failure_reason ?? null) : args.failure_reason,
1183
1377
  last_heartbeat: now(),
1378
+ // Room #16 blocker 11: preserve the verified sha across upserts unless
1379
+ // explicitly provided (validated at the API boundary).
1380
+ already_merged_sha: args.already_merged_sha === undefined
1381
+ ? (current?.already_merged_sha ?? null)
1382
+ : validateAlreadyMergedSha(args.already_merged_sha),
1184
1383
  };
1185
1384
  if (current) {
1186
1385
  db.prepare(
1187
1386
  `UPDATE agent_sessions SET task_id=@task_id, identity=@identity, step=@step,
1188
1387
  status=@status, started_at=@started_at, ended_at=@ended_at, notes=@notes,
1189
- caveats=@caveats, failure_reason=@failure_reason, last_heartbeat=@last_heartbeat WHERE id=@id`).run(row);
1388
+ caveats=@caveats, failure_reason=@failure_reason, last_heartbeat=@last_heartbeat,
1389
+ already_merged_sha=@already_merged_sha WHERE id=@id`).run(row);
1190
1390
  } else {
1191
1391
  db.prepare(
1192
1392
  `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
1193
- ended_at, notes, caveats, failure_reason, last_heartbeat)
1393
+ ended_at, notes, caveats, failure_reason, last_heartbeat, already_merged_sha)
1194
1394
  VALUES (@id, @task_id, @identity, @step, @status, @started_at,
1195
- @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
1395
+ @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat, @already_merged_sha)
1196
1396
  ON CONFLICT DO NOTHING`).run(row);
1197
1397
  }
1198
1398
  return { session: mapSession(row) };
@@ -1263,16 +1463,27 @@ commands["get-events"] = (db, args) => {
1263
1463
  // against this code's own realpath (the release it is actually running
1264
1464
  // from). Unresolvable or disputed => throw fail-closed BEFORE the claim
1265
1465
  // transaction: provenance is never stamped with an unknown release.
1266
- function resolveActiveRelease(crewHome) {
1466
+ //
1467
+ // Active-release resolution is split in two (room #15, 2026-09-18):
1468
+ // resolveActiveReleaseName (symlink only — for writers) below, and
1469
+ // resolveActiveRelease (symlink + self-path cross-check — for verifiers).
1470
+ function resolveActiveReleaseName(crewHome) {
1267
1471
  let linkTarget;
1268
1472
  try {
1269
1473
  linkTarget = readlinkSync(join(crewHome, "current"));
1270
1474
  } catch (e) {
1271
1475
  throw usageError(
1272
- `scan-verification-pending: cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}). Refusing to verify.`
1476
+ `cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}).`
1273
1477
  );
1274
1478
  }
1275
- const activeRelease = basename(resolve(crewHome, linkTarget));
1479
+ return basename(resolve(crewHome, linkTarget));
1480
+ }
1481
+ // The strict resolver for verifiers (scan-verification-pending): the symlink
1482
+ // answer PLUS the self-path cross-check — the running code must provably be
1483
+ // the active release's own crew-api.js before it stamps verification claims.
1484
+ // Stale cron bodies and mid-deploy swaps throw fail-closed here.
1485
+ function resolveActiveRelease(crewHome) {
1486
+ const activeRelease = resolveActiveReleaseName(crewHome);
1276
1487
  const selfPath = new URL(import.meta.url).pathname;
1277
1488
  const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
1278
1489
  if (!selfMatch) {
@@ -1545,6 +1756,10 @@ commands["record-phase"] = (db, args) => {
1545
1756
  caveats: session.caveats === undefined ? "[]" : JSON.stringify(session.caveats),
1546
1757
  failure_reason: session.failure_reason ?? null,
1547
1758
  last_heartbeat: timestamp,
1759
+ // Room #16 blocker 11: the workflow-verified already-merged sha, written
1760
+ // by the workflow itself after the mechanical ancestor check — never
1761
+ // builder prose. Validated at the API boundary; NULL = not verified.
1762
+ already_merged_sha: validateAlreadyMergedSha(session.already_merged_sha),
1548
1763
  };
1549
1764
  const eventRow = {
1550
1765
  id: uuid(), type: event.type, task_id: args.task_id,
@@ -1554,12 +1769,15 @@ commands["record-phase"] = (db, args) => {
1554
1769
  try {
1555
1770
  db.prepare(
1556
1771
  `INSERT INTO agent_sessions (id, task_id, identity, step, status, started_at,
1557
- ended_at, notes, caveats, failure_reason, last_heartbeat)
1772
+ ended_at, notes, caveats, failure_reason, last_heartbeat, already_merged_sha)
1558
1773
  VALUES (@id, @task_id, @identity, @step, @status, @started_at,
1559
- @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat)
1774
+ @ended_at, @notes, @caveats, @failure_reason, @last_heartbeat, @already_merged_sha)
1775
+ -- record-phase never clears already_merged_sha: a phase record is not a
1776
+ -- field update, so both undefined and null preserve the existing value.
1560
1777
  ON CONFLICT(id) DO UPDATE SET status=@status, ended_at=@ended_at,
1561
1778
  notes=@notes, caveats=@caveats, failure_reason=@failure_reason,
1562
- last_heartbeat=@last_heartbeat`).run(sessionRow);
1779
+ last_heartbeat=@last_heartbeat,
1780
+ already_merged_sha=COALESCE(@already_merged_sha, already_merged_sha)`).run(sessionRow);
1563
1781
  db.prepare(
1564
1782
  `INSERT INTO events (id, type, task_id, identity, message, timestamp)
1565
1783
  VALUES (@id, @type, @task_id, @identity, @message, @timestamp)`).run(eventRow);
@@ -1575,39 +1793,49 @@ commands["record-phase"] = (db, args) => {
1575
1793
  // the JSON transport and over the 200-line budget. The artifact is built from
1576
1794
  // the repo checkout at registration time, so create-project stamps
1577
1795
  // source_commit = repo HEAD for artifact-deploy projects; the first publish
1578
- // then diffs only the task's own changes. The stamp never clobbers an
1579
- // existing one (provenance storage is crew-home-global, so a second artifact
1580
- // project keeps the first stamp — per-project provenance is a known gap this
1581
- // does not introduce). A skipped stamp is reported, never silent, and never
1582
- // fails project creation.
1583
- function initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName }) {
1796
+ // The initial-provenance stamp at create-project time: an artifact project
1797
+ // opens with its publish base set to the repo HEAD at registration, so the
1798
+ // first publish diffs only the task's own changes. Per-project (room #15
1799
+ // blocker 8): the project row was just inserted, so it is unstamped by
1800
+ // construction — there is no "already stamped" skip; a second artifact
1801
+ // project gets its own row, never the first project's base. A skipped stamp
1802
+ // is reported, never silent, and never fails project creation.
1803
+ function initialProvenancePlan({ deployType, headSha, releaseName }) {
1584
1804
  if (deployType !== "artifact") return { stamped: false, reason: "deploy_type is not artifact" };
1585
- if (provenanceExists) return { stamped: false, reason: "provenance already stamped" };
1586
1805
  if (!headSha) return { stamped: false, reason: "repo HEAD is unresolvable (unborn repo?)" };
1587
1806
  if (!releaseName) return { stamped: false, reason: "active crew release is unresolvable" };
1588
1807
  return { stamped: true, source_commit: headSha, crew_release: releaseName };
1589
1808
  }
1590
1809
 
1591
- function tryStampInitialProvenance(db, crewHome, repoPath, deployType) {
1592
- const keys = db.prepare(
1593
- "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
1594
- ).all().map((r) => r.key);
1595
- const provenanceExists = keys.length === 3;
1810
+ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType) {
1596
1811
  let headSha = null;
1597
1812
  try {
1598
1813
  headSha = execFileSync("git", ["rev-parse", "HEAD"],
1599
- { cwd: repoPath, encoding: "utf8", timeout: 10000 }).trim();
1814
+ { cwd: repoPath, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
1600
1815
  if (!/^[0-9a-f]{40}$/.test(headSha)) headSha = null;
1601
1816
  } catch { headSha = null; }
1602
1817
  let releaseName = null;
1603
- try { releaseName = resolveActiveRelease(crewHome); } catch { releaseName = null; }
1604
- const plan = initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName });
1818
+ // Symlink-only resolution: the stamp records the crew home's ACTIVE
1819
+ // release — it must not depend on which copy of this file is running.
1820
+ // The strict self-path cross-check lives in resolveActiveRelease, used by
1821
+ // scan-verification-pending; requiring it here silently skipped the stamp
1822
+ // for every setup path that invokes crew-api.js outside the release tree
1823
+ // (room #15: create-project through the npm package's own lib/).
1824
+ try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
1825
+ const plan = initialProvenancePlan({ deployType, headSha, releaseName });
1605
1826
  if (!plan.stamped) return plan;
1606
- const upsert = db.prepare(
1607
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1608
- upsert.run("provenance.source_commit", plan.source_commit);
1609
- upsert.run("provenance.crew_release", plan.crew_release);
1610
- upsert.run("provenance.published_at", now());
1827
+ try {
1828
+ stampProvenance(db, {
1829
+ project_id: projectId,
1830
+ source_commit: plan.source_commit,
1831
+ crew_release: plan.crew_release,
1832
+ });
1833
+ } catch (e) {
1834
+ // The stamp is best-effort: a writer rejection (e.g. a release name
1835
+ // outside the stampable shape) skips the stamp, never the project.
1836
+ // Reported via the reason, never silent.
1837
+ return { stamped: false, reason: `initial stamp rejected: ${e.message}` };
1838
+ }
1611
1839
  return plan;
1612
1840
  }
1613
1841
 
@@ -1675,7 +1903,17 @@ commands["create-project"] = (db, args, ctx) => {
1675
1903
  description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
1676
1904
  VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
1677
1905
  @description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
1678
- const initialProvenance = tryStampInitialProvenance(db, ctx.crewHome, repoPath, deployType);
1906
+ // A deferred backfill (zero/ambiguous matches at the last look) gets one
1907
+ // more evaluation now that the match set changed: a new project whose
1908
+ // repo descends from the legacy commit inherits the attribution instead
1909
+ // of stranding the triple in config forever. When the backfill stamps
1910
+ // this project, the initial stamp below is skipped — the legacy triple
1911
+ // is a real publish record and must not be overwritten by the
1912
+ // creation-time HEAD.
1913
+ const backfillStampedId = retryDeferredBackfill(db);
1914
+ const initialProvenance = backfillStampedId === id
1915
+ ? { stamped: true, reason: "legacy backfill attributed this project; initial stamp skipped" }
1916
+ : tryStampInitialProvenance(db, id, ctx.crewHome, repoPath, deployType);
1679
1917
  return { project: mapProject(row), initial_provenance: initialProvenance };
1680
1918
  };
1681
1919
 
@@ -1819,35 +2057,48 @@ commands["update-config"] = (db, args) => {
1819
2057
  return { ok: true };
1820
2058
  };
1821
2059
 
1822
- commands["set-provenance"] = (db, args) => {
1823
- if (!/^[0-9a-f]{40}$/.test(args.source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
1824
- if (!/^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/.test(args.crew_release ?? "")) {
2060
+ // The single provenance writer (room #15 blocker 8). Every stamp flows
2061
+ // through here: set-provenance and create-project's initial stamp are thin
2062
+ // shells over it. Per-project by design — a crew home with N artifact
2063
+ // projects carries N independent publish bases, and stamping one project
2064
+ // never touches another's row.
2065
+ function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
2066
+ const pid = String(project_id ?? "").trim();
2067
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2068
+ requireProject(db, pid);
2069
+ if (!PROVENANCE_SOURCE_COMMIT_RE.test(source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
2070
+ if (!PROVENANCE_CREW_RELEASE_RE.test(crew_release ?? "")) {
1825
2071
  throw usageError("crew_release must be 40-char hex or pkg-<semver>.");
1826
2072
  }
1827
2073
  // task_id is optional; when present it must be 1-80 chars (matches dashboard contract).
1828
- const taskId = args.task_id ?? null;
1829
- if (taskId !== null && (typeof taskId !== "string" || taskId.trim().length < 1 || taskId.length > 80)) {
2074
+ const tid = task_id ?? null;
2075
+ if (tid !== null && (typeof tid !== "string" || tid.trim().length < 1 || tid.length > 80)) {
1830
2076
  throw usageError("task_id must be 1-80 chars when provided.");
1831
2077
  }
1832
- const upsert = db.prepare(
1833
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1834
- upsert.run("provenance.source_commit", args.source_commit);
1835
- upsert.run("provenance.crew_release", args.crew_release);
1836
- upsert.run("provenance.published_at", args.published_at ?? now());
1837
- if (taskId !== null) upsert.run("provenance.task_id", taskId);
1838
- return { ok: true };
2078
+ db.prepare(
2079
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
2080
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ?
2081
+ WHERE id = ?`
2082
+ ).run(source_commit, crew_release, published_at ?? now(), tid, now(), pid);
2083
+ return { ok: true, project_id: pid };
2084
+ }
2085
+
2086
+ commands["set-provenance"] = (db, args) => {
2087
+ return stampProvenance(db, args ?? {});
1839
2088
  };
1840
2089
 
1841
- commands["get-provenance"] = (db) => {
1842
- const rows = db.prepare("SELECT key, value FROM config WHERE key LIKE 'provenance.%'").all();
1843
- const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
1844
- const sc = byKey["provenance.source_commit"];
1845
- const cr = byKey["provenance.crew_release"];
1846
- const pa = byKey["provenance.published_at"];
1847
- if (!sc || !cr || !pa) return { provenance: null };
2090
+ commands["get-provenance"] = (db, args) => {
2091
+ const pid = String((args ?? {}).project_id ?? "").trim();
2092
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2093
+ const row = requireProject(db, pid);
2094
+ if (!row.provenance_source_commit || !row.provenance_crew_release || !row.provenance_published_at) {
2095
+ return { provenance: null };
2096
+ }
1848
2097
  return { provenance: {
1849
- source_commit: sc, crew_release: cr, published_at: pa,
1850
- task_id: byKey["provenance.task_id"] ?? null,
2098
+ source_commit: row.provenance_source_commit,
2099
+ crew_release: row.provenance_crew_release,
2100
+ published_at: row.provenance_published_at,
2101
+ task_id: row.provenance_task_id ?? null,
1851
2102
  } };
1852
2103
  };
1853
2104
 
package/lib/schema.sql CHANGED
@@ -34,6 +34,18 @@ CREATE TABLE IF NOT EXISTS projects (
34
34
  -- deploy_type is NOT reused for this (it names the deployment target, not
35
35
  -- the UX surface a user experiences).
36
36
  environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal')),
37
+ -- Per-project provenance (2026-09-18, room #15 blocker 8): each project
38
+ -- carries its own publish base. Provenance was crew-home-global
39
+ -- (config.provenance.*), which broke any multi-project home: the first
40
+ -- artifact project's stamp was read as the second project's base, parking
41
+ -- the second task with "not an ancestor". Nullable by design: a project
42
+ -- with no provenance reads null and its first publish diffs from the
43
+ -- empty tree. Never fabricated — the one-time backfill in openDb stamps
44
+ -- only an exactly-one-ancestor match, otherwise records deferred.
45
+ provenance_source_commit TEXT CHECK (provenance_source_commit IS NULL OR length(provenance_source_commit) = 40),
46
+ provenance_crew_release TEXT,
47
+ provenance_published_at TEXT,
48
+ provenance_task_id TEXT,
37
49
  created_at TEXT NOT NULL,
38
50
  updated_at TEXT NOT NULL
39
51
  );
@@ -81,7 +93,19 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
81
93
  notes TEXT NOT NULL DEFAULT '',
82
94
  caveats TEXT NOT NULL DEFAULT '[]',
83
95
  failure_reason TEXT,
84
- last_heartbeat TEXT
96
+ last_heartbeat TEXT,
97
+ -- Room #16 blocker 11 (2026-09-18): the workflow-verified already-merged
98
+ -- sha as structured control state. Build's `repo_diff: none
99
+ -- (already-merged: <sha>)` declaration used to travel to Review only
100
+ -- through capped prose (session notes are truncated at 3000 chars), so a
101
+ -- truthful declaration at the report's tail never arrived. The workflow
102
+ -- writes the sha here itself after the mechanical ancestor check — the
103
+ -- column is workflow-owned, never builder prose. NULL = no verified
104
+ -- declaration for this session. The CHECK enforces the full shape:
105
+ -- 7-40 lowercase hex chars (NOT GLOB '*[^0-9a-f]*' means no non-hex char
106
+ -- anywhere — GLOB '[0-9a-f]*' alone would only check the first char).
107
+ already_merged_sha TEXT CHECK (already_merged_sha IS NULL OR
108
+ (already_merged_sha NOT GLOB '*[^0-9a-f]*' AND length(already_merged_sha) BETWEEN 7 AND 40))
85
109
  );
86
110
 
87
111
  CREATE TABLE IF NOT EXISTS events (
@@ -239,6 +239,35 @@ if git -C "$T5" log --name-only --format= | grep -q '^\.worktrees/'; then
239
239
  fi
240
240
  rm -rf "$T5"
241
241
 
242
+ # post-deploy survives the crew's own gitignored non-empty .worktrees/
243
+ # (2026-09-18, room #15 J3): the old `git add -A -- ':!.worktrees/'`
244
+ # exited 1 on git 2.43.0 whenever setup's `.gitignore` entry ignored a
245
+ # non-empty .worktrees/, and set -e killed post-deploy before "DEPLOYED:".
246
+ T6=/tmp/crew-git-postdeploy-gitignore-test
247
+ rm -rf "$T6"
248
+ mkdir -p "$T6"
249
+ git init -q -b main "$T6"
250
+ git -C "$T6" config user.email test@test.t
251
+ git -C "$T6" config user.name test
252
+ echo base > "$T6/f.txt"
253
+ echo ".worktrees/" > "$T6/.gitignore"
254
+ git -C "$T6" add -A
255
+ git -C "$T6" commit -qm base
256
+ export CREW_REPO="$T6"
257
+ export CREW_LIB="$REPO_DIR/lib"
258
+ # Non-empty ignored .worktrees/, exactly the shape of real integrate state
259
+ mkdir -p "$T6/.worktrees/pd2"
260
+ printf 'task_id=pd2\nholder=test\n' > "$T6/.worktrees/pd2/.merge-lock"
261
+ # A genuine user change the post-deploy commit should pick up
262
+ echo userchange > "$T6/user.txt"
263
+ out=$(bash "$LIFECYCLE" post-deploy "pd2") || fail "postdeploy-gitignore-test: post-deploy failed: $out"
264
+ echo "$out" | grep -q '^DEPLOYED: ' || fail "postdeploy-gitignore-test: no DEPLOYED: line: $out"
265
+ git -C "$T6" log --name-only --format= -1 | grep -q '^user\.txt$' || fail "postdeploy-gitignore-test: user change not committed"
266
+ if git -C "$T6" log --name-only --format= | grep -q '^\.worktrees/'; then
267
+ fail "postdeploy-gitignore-test: .worktrees/ content committed into main"
268
+ fi
269
+ rm -rf "$T6"
270
+
242
271
  rm -rf "$T2" "$T3" "$T4"
243
272
  export CREW_REPO="$T"
244
273
  rm -rf "$T"
@@ -23,7 +23,7 @@
23
23
  // node verify-publish.js --crew-home <path> --task-id <uuid>
24
24
  // --commit <sha> --base <sha> --repo-path <path> --slug <slug>
25
25
  // --crew-release <release> --inspection-id <uuid> --result-file <path>
26
- // [--build-agent-id <uuid>]
26
+ // --project-id <project-id> [--build-agent-id <uuid>]
27
27
  //
28
28
  // --base is the previously-stamped provenance source_commit (or the
29
29
  // empty-tree sha 4b825dc642cb6eb9a060e54bf8d69288fbee4904 for a first
@@ -66,6 +66,7 @@ const inspectionId = arg("--inspection-id");
66
66
  const resultFile = arg("--result-file");
67
67
  const crewRelease = arg("--crew-release");
68
68
  const buildAgentId = arg("--build-agent-id", false);
69
+ const projectId = arg("--project-id");
69
70
 
70
71
  if (!/^[0-9a-f]{40}$/.test(commit)) fail("usage", "commit must be a 40-char hex sha.", 2);
71
72
  if (!/^[0-9a-f]{40}$/.test(base)) fail("usage", "base must be a 40-char hex sha.", 2);
@@ -334,7 +335,7 @@ if (buildAgentId && resultText.includes(buildAgentId)) buildNote = `build-id-cor
334
335
  // --- 7. Stamp (only after the machine-checked comparison succeeded) ---------
335
336
  let stamp;
336
337
  try {
337
- stamp = api("set-provenance", { source_commit: commit, crew_release: crewRelease, task_id: taskId });
338
+ stamp = api("set-provenance", { project_id: projectId, source_commit: commit, crew_release: crewRelease, task_id: taskId });
338
339
  } catch (e) {
339
340
  terminal("stamp-failed", `set-provenance threw: ${e.message}`);
340
341
  }
@@ -342,7 +343,7 @@ if (!stamp || stamp.ok !== true) terminal("stamp-failed", "set-provenance did no
342
343
  // Exact read-back: the stamp must match what we intended, or it is fiction.
343
344
  let prov;
344
345
  try {
345
- prov = api("get-provenance", {});
346
+ prov = api("get-provenance", { project_id: projectId });
346
347
  } catch (e) {
347
348
  terminal("stamp-failed", `get-provenance threw: ${e.message}`);
348
349
  }
@@ -416,9 +416,15 @@ cmd_post_deploy() {
416
416
 
417
417
  # Commit any changes the artifact builder left behind.
418
418
  # The crew's own .worktrees/ dir (merge lock, registry) is crew-owned
419
- # state, never user content — exclude it so a stray lock file can never
420
- # be committed into main (J1 canary: .merge-lock landed in main via add -A).
421
- git add -A -- ':!.worktrees/'
419
+ # state, never user content — unstage it after the add so a stray lock
420
+ # file can never be committed into main (J1 canary: .merge-lock landed
421
+ # in main via add -A). Never use an all-negative pathspec here: on git
422
+ # 2.43.0 `git add -A -- ':!.worktrees/'` still exits 1 against the
423
+ # crew's own gitignored .worktrees/ (room #15, J3), and set -e then
424
+ # kills post-deploy before "DEPLOYED:". A plain add never errors on
425
+ # ignored paths; the reset covers pre-gitignore-entry repos too.
426
+ git add -A
427
+ git reset -q -- .worktrees/ 2>/dev/null || true
422
428
  if ! git diff --cached --quiet 2>/dev/null; then
423
429
  git commit -m "rebuild: $task_id"
424
430
  echo "COMMITTED: artifact builder changes"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,