muse-crew 0.11.1 → 0.13.0

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
@@ -28,6 +28,7 @@ import { randomUUID } from "node:crypto";
28
28
  import { execFileSync } from "node:child_process";
29
29
  import { DatabaseSync } from "node:sqlite";
30
30
  import { homedir } from "node:os";
31
+ import { classifySurface } from "./classify-surface.js";
31
32
 
32
33
  // ---------------------------------------------------------------------------
33
34
  // Errors
@@ -87,12 +88,148 @@ function resolveCrewHome(flagValue) {
87
88
 
88
89
  const SCHEMA_PATH = join(import.meta.dirname, "schema.sql");
89
90
 
91
+ // Provenance shape rules, shared by the writer and the backfill's
92
+ // pre-validation so they can never diverge (room #15, 2026-09-18).
93
+ const PROVENANCE_SOURCE_COMMIT_RE = /^[0-9a-f]{40}$/;
94
+ const PROVENANCE_CREW_RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
95
+
96
+ // One-time backfill of the legacy crew-home-global provenance triple
97
+ // (config.provenance.*) into the per-project provenance columns (room #15
98
+ // blocker 8, 2026-09-18). Attribution is never fabricated: the legacy base
99
+ // is stamped onto a project ONLY when it is an ancestor of exactly one
100
+ // project's repo HEAD (verified with git, never assumed). Zero or ambiguous
101
+ // matches are recorded as deferred:<reason> and the legacy keys are left
102
+ // untouched — new readers see null (empty-tree first publish) while old
103
+ // readers keep their global triple. Watermarked in config, so it runs at
104
+ // most once per crew home. Benign edge: a legacy key written by the very
105
+ // command whose open triggers the backfill is re-created after the DELETE
106
+ // and lingers in config — inert dead storage, never read by new code.
107
+ function backfillProjectProvenance(db) {
108
+ let wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
109
+ if (wm && wm.value === "none") {
110
+ // "none" means "no legacy triple at the last look". A triple that
111
+ // arrives later (hand-written via update-config — the key space is
112
+ // open) is new evidence, not a settled state: clear the watermark and
113
+ // evaluate it. Any other watermark (stamped:*, deferred:*) is terminal.
114
+ const legacy = db.prepare(
115
+ "SELECT key FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'"
116
+ ).all();
117
+ if (legacy.length > 0) {
118
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
119
+ wm = null;
120
+ } else {
121
+ return;
122
+ }
123
+ }
124
+ if (wm) return;
125
+ const setWatermark = (v) =>
126
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(v);
127
+ const rows = db.prepare(
128
+ "SELECT key, value FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at','provenance.task_id')"
129
+ ).all();
130
+ const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
131
+ const sc = byKey["provenance.source_commit"];
132
+ const cr = byKey["provenance.crew_release"];
133
+ const pa = byKey["provenance.published_at"];
134
+ if (!sc && !cr && !pa && !byKey["provenance.task_id"]) {
135
+ // No legacy provenance at all: nothing to attribute, ever.
136
+ setWatermark("none");
137
+ return;
138
+ }
139
+ if (!sc || !cr || !pa) {
140
+ // Incomplete triple (partial hand-write): do NOT watermark — the next
141
+ // open retries. Watermarking here would strand keys that arrive later.
142
+ return;
143
+ }
144
+ const matches = [];
145
+ for (const p of db.prepare("SELECT id, repo_path FROM projects").all()) {
146
+ try {
147
+ execFileSync("git", ["merge-base", "--is-ancestor", sc, "HEAD"],
148
+ { cwd: p.repo_path, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] });
149
+ matches.push(p.id);
150
+ } catch {
151
+ // Not an ancestor, or the repo is unreadable: not a match.
152
+ }
153
+ }
154
+ if (matches.length === 1) {
155
+ // Validate the legacy triple against the writer's shape rules BEFORE
156
+ // touching the database: a hand-written abbreviated sha passes
157
+ // `git merge-base --is-ancestor` (git resolves abbreviations) but
158
+ // violates the storage CHECK — and a throw here would escape openDb
159
+ // and hard-fail every CLI command against the home. The backfill's
160
+ // whole contract is best-effort attribution, so a bad shape is a
161
+ // deferred watermark, never an exception. Legacy keys are retained
162
+ // read-only for old readers, exactly like the other deferred cases.
163
+ const shapeProblem =
164
+ !PROVENANCE_SOURCE_COMMIT_RE.test(sc ?? "")
165
+ ? `source_commit not 40-char hex (${String(sc).slice(0, 24)})`
166
+ : !PROVENANCE_CREW_RELEASE_RE.test(cr ?? "")
167
+ ? `crew_release not 40-char hex or pkg-<semver> (${String(cr).slice(0, 24)})`
168
+ : null;
169
+ if (shapeProblem) {
170
+ setWatermark(`deferred:stamp-failed:${shapeProblem}`);
171
+ } else {
172
+ db.exec("BEGIN");
173
+ try {
174
+ db.prepare(
175
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
176
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ? WHERE id = ?`
177
+ ).run(sc, cr, pa, byKey["provenance.task_id"] ?? null, now(), matches[0]);
178
+ db.prepare("DELETE FROM config WHERE key LIKE 'provenance.%' AND key != 'provenance.backfill'").run();
179
+ setWatermark(`stamped:${matches[0]}`);
180
+ db.exec("COMMIT");
181
+ } catch (e) {
182
+ db.exec("ROLLBACK");
183
+ const reason = String((e && e.message) || e).replace(/[\r\n]+/g, " ").slice(0, 100);
184
+ setWatermark(`deferred:stamp-failed:${reason}`);
185
+ }
186
+ }
187
+ } else if (matches.length === 0) {
188
+ setWatermark(`deferred:no-ancestor-match:${String(sc).slice(0, 7)}`);
189
+ } else {
190
+ setWatermark(`deferred:ambiguous:${matches.length}`);
191
+ }
192
+ }
193
+
194
+ // Re-run a deferred backfill when the match set may have changed: a new
195
+ // project was just created, and a `deferred:no-ancestor-match` or
196
+ // `deferred:ambiguous` watermark from an earlier look can now resolve —
197
+ // e.g. a home whose legacy triple predates its first project. Only
198
+ // deferred:* watermarks are retried, and only while the legacy keys still
199
+ // exist (a successful stamp deleted them). Returns the stamped project id,
200
+ // or null. Never throws for a bad triple — the evaluation watermarks
201
+ // instead (see above); a throw here means the database itself is broken,
202
+ // and failing project creation is then the honest outcome.
203
+ function retryDeferredBackfill(db) {
204
+ const wm = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
205
+ if (!wm || !String(wm.value).startsWith("deferred:")) return null;
206
+ const legacy = db.prepare(
207
+ "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
208
+ ).all();
209
+ if (legacy.length === 0) return null;
210
+ // Lift the terminal watermark, re-evaluate, and let the evaluation write
211
+ // the new one. If the re-evaluation throws (it shouldn't — every path
212
+ // watermarks), restore the old watermark so the deferral isn't lost.
213
+ const old = wm.value;
214
+ db.prepare("DELETE FROM config WHERE key = 'provenance.backfill'").run();
215
+ try {
216
+ backfillProjectProvenance(db);
217
+ } catch (e) {
218
+ db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES ('provenance.backfill', ?)").run(old);
219
+ throw e;
220
+ }
221
+ const wm2 = db.prepare("SELECT value FROM config WHERE key = 'provenance.backfill'").get();
222
+ const m = wm2 && /^stamped:(.+)$/.exec(String(wm2.value));
223
+ return m ? m[1] : null;
224
+ }
225
+
90
226
  function openDb(crewHome) {
91
227
  const dbPath = join(crewHome, "crew-state.db");
92
228
  const fresh = !existsSync(dbPath);
93
229
  const db = new DatabaseSync(dbPath);
94
230
  db.exec("PRAGMA journal_mode = WAL;");
95
231
  db.exec("PRAGMA foreign_keys = ON;");
232
+ db.exec("PRAGMA busy_timeout = 5000;");
96
233
  if (fresh) {
97
234
  const schema = readFileSync(SCHEMA_PATH, "utf8");
98
235
  db.exec(schema);
@@ -138,6 +275,29 @@ function openDb(crewHome) {
138
275
  if (!/duplicate column name/i.test(e.message)) throw e;
139
276
  }
140
277
  }
278
+ // Migration: per-project provenance columns (room #15 blocker 8,
279
+ // 2026-09-18). Same idempotent PRAGMA-check pattern; ADD COLUMN without
280
+ // a default leaves existing rows NULL, which is "no provenance yet" by
281
+ // design — attribution is never fabricated, so there is no backfill step
282
+ // here beyond the watermarked legacy-triple attribution below.
283
+ const provMigration = [
284
+ ["provenance_source_commit", "TEXT CHECK (provenance_source_commit IS NULL OR length(provenance_source_commit) = 40)"],
285
+ ["provenance_crew_release", "TEXT"],
286
+ ["provenance_published_at", "TEXT"],
287
+ ["provenance_task_id", "TEXT"],
288
+ ];
289
+ const provColNames = new Set(db.prepare("PRAGMA table_info(projects)").all().map((c) => c.name));
290
+ for (const [colName, colType] of provMigration) {
291
+ if (!provColNames.has(colName)) {
292
+ try {
293
+ db.exec(`ALTER TABLE projects ADD COLUMN ${colName} ${colType}`);
294
+ } catch (e) {
295
+ if (!/duplicate column name/i.test(e.message)) throw e;
296
+ }
297
+ }
298
+ }
299
+ // Legacy global-provenance backfill (watermarked, runs at most once).
300
+ backfillProjectProvenance(db);
141
301
  }
142
302
  return db;
143
303
  }
@@ -275,7 +435,7 @@ function requireTask(db, id) {
275
435
 
276
436
  function requireProject(db, id) {
277
437
  const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
278
- if (!row) throw notFound("Project not found.");
438
+ if (!row) throw notFound(`Project not found: "${id}" — check list-projects, or create it with create-project.`);
279
439
  return row;
280
440
  }
281
441
 
@@ -1262,16 +1422,27 @@ commands["get-events"] = (db, args) => {
1262
1422
  // against this code's own realpath (the release it is actually running
1263
1423
  // from). Unresolvable or disputed => throw fail-closed BEFORE the claim
1264
1424
  // transaction: provenance is never stamped with an unknown release.
1265
- function resolveActiveRelease(crewHome) {
1425
+ //
1426
+ // Active-release resolution is split in two (room #15, 2026-09-18):
1427
+ // resolveActiveReleaseName (symlink only — for writers) below, and
1428
+ // resolveActiveRelease (symlink + self-path cross-check — for verifiers).
1429
+ function resolveActiveReleaseName(crewHome) {
1266
1430
  let linkTarget;
1267
1431
  try {
1268
1432
  linkTarget = readlinkSync(join(crewHome, "current"));
1269
1433
  } catch (e) {
1270
1434
  throw usageError(
1271
- `scan-verification-pending: cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}). Refusing to verify.`
1435
+ `cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}).`
1272
1436
  );
1273
1437
  }
1274
- const activeRelease = basename(resolve(crewHome, linkTarget));
1438
+ return basename(resolve(crewHome, linkTarget));
1439
+ }
1440
+ // The strict resolver for verifiers (scan-verification-pending): the symlink
1441
+ // answer PLUS the self-path cross-check — the running code must provably be
1442
+ // the active release's own crew-api.js before it stamps verification claims.
1443
+ // Stale cron bodies and mid-deploy swaps throw fail-closed here.
1444
+ function resolveActiveRelease(crewHome) {
1445
+ const activeRelease = resolveActiveReleaseName(crewHome);
1275
1446
  const selfPath = new URL(import.meta.url).pathname;
1276
1447
  const selfMatch = selfPath.match(/releases\/([^/]+)\/lib\/crew-api\.js$/);
1277
1448
  if (!selfMatch) {
@@ -1574,39 +1745,49 @@ commands["record-phase"] = (db, args) => {
1574
1745
  // the JSON transport and over the 200-line budget. The artifact is built from
1575
1746
  // the repo checkout at registration time, so create-project stamps
1576
1747
  // source_commit = repo HEAD for artifact-deploy projects; the first publish
1577
- // then diffs only the task's own changes. The stamp never clobbers an
1578
- // existing one (provenance storage is crew-home-global, so a second artifact
1579
- // project keeps the first stamp — per-project provenance is a known gap this
1580
- // does not introduce). A skipped stamp is reported, never silent, and never
1581
- // fails project creation.
1582
- function initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName }) {
1748
+ // The initial-provenance stamp at create-project time: an artifact project
1749
+ // opens with its publish base set to the repo HEAD at registration, so the
1750
+ // first publish diffs only the task's own changes. Per-project (room #15
1751
+ // blocker 8): the project row was just inserted, so it is unstamped by
1752
+ // construction — there is no "already stamped" skip; a second artifact
1753
+ // project gets its own row, never the first project's base. A skipped stamp
1754
+ // is reported, never silent, and never fails project creation.
1755
+ function initialProvenancePlan({ deployType, headSha, releaseName }) {
1583
1756
  if (deployType !== "artifact") return { stamped: false, reason: "deploy_type is not artifact" };
1584
- if (provenanceExists) return { stamped: false, reason: "provenance already stamped" };
1585
1757
  if (!headSha) return { stamped: false, reason: "repo HEAD is unresolvable (unborn repo?)" };
1586
1758
  if (!releaseName) return { stamped: false, reason: "active crew release is unresolvable" };
1587
1759
  return { stamped: true, source_commit: headSha, crew_release: releaseName };
1588
1760
  }
1589
1761
 
1590
- function tryStampInitialProvenance(db, crewHome, repoPath, deployType) {
1591
- const keys = db.prepare(
1592
- "SELECT key FROM config WHERE key IN ('provenance.source_commit','provenance.crew_release','provenance.published_at')"
1593
- ).all().map((r) => r.key);
1594
- const provenanceExists = keys.length === 3;
1762
+ function tryStampInitialProvenance(db, projectId, crewHome, repoPath, deployType) {
1595
1763
  let headSha = null;
1596
1764
  try {
1597
1765
  headSha = execFileSync("git", ["rev-parse", "HEAD"],
1598
- { cwd: repoPath, encoding: "utf8", timeout: 10000 }).trim();
1766
+ { cwd: repoPath, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
1599
1767
  if (!/^[0-9a-f]{40}$/.test(headSha)) headSha = null;
1600
1768
  } catch { headSha = null; }
1601
1769
  let releaseName = null;
1602
- try { releaseName = resolveActiveRelease(crewHome); } catch { releaseName = null; }
1603
- const plan = initialProvenancePlan({ deployType, provenanceExists, headSha, releaseName });
1770
+ // Symlink-only resolution: the stamp records the crew home's ACTIVE
1771
+ // release — it must not depend on which copy of this file is running.
1772
+ // The strict self-path cross-check lives in resolveActiveRelease, used by
1773
+ // scan-verification-pending; requiring it here silently skipped the stamp
1774
+ // for every setup path that invokes crew-api.js outside the release tree
1775
+ // (room #15: create-project through the npm package's own lib/).
1776
+ try { releaseName = resolveActiveReleaseName(crewHome); } catch { releaseName = null; }
1777
+ const plan = initialProvenancePlan({ deployType, headSha, releaseName });
1604
1778
  if (!plan.stamped) return plan;
1605
- const upsert = db.prepare(
1606
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1607
- upsert.run("provenance.source_commit", plan.source_commit);
1608
- upsert.run("provenance.crew_release", plan.crew_release);
1609
- upsert.run("provenance.published_at", now());
1779
+ try {
1780
+ stampProvenance(db, {
1781
+ project_id: projectId,
1782
+ source_commit: plan.source_commit,
1783
+ crew_release: plan.crew_release,
1784
+ });
1785
+ } catch (e) {
1786
+ // The stamp is best-effort: a writer rejection (e.g. a release name
1787
+ // outside the stampable shape) skips the stamp, never the project.
1788
+ // Reported via the reason, never silent.
1789
+ return { stamped: false, reason: `initial stamp rejected: ${e.message}` };
1790
+ }
1610
1791
  return plan;
1611
1792
  }
1612
1793
 
@@ -1618,17 +1799,47 @@ commands["create-project"] = (db, args, ctx) => {
1618
1799
  const repoPath = (args.repo_path ?? "").trim();
1619
1800
  if (!repoPath) throw usageError("repo_path is required.");
1620
1801
  if (!isGitRepoPath(repoPath)) throw usageError("repo_path must be a valid Git repository path.");
1621
- const deployType = args.deploy_type ?? "";
1802
+ let deployType = args.deploy_type ?? "";
1622
1803
  if (!["npm", "artifact", "vercel", ""].includes(deployType)) throw usageError("bad deploy_type.");
1623
1804
  if (db.prepare("SELECT id FROM projects WHERE id = ?").get(id)) {
1624
1805
  throw conflict("A project with this ID already exists.");
1625
1806
  }
1626
1807
  const timestamp = now();
1627
1808
  const visualProtocol = validateVisualProtocol(args.visual_protocol);
1628
- const environmentType = validateEnvironmentType(args.environment_type);
1809
+ // Surface classification default (room #14, 2026-09-17): the setup
1810
+ // conversation never classified the flashcards sample — environment_type
1811
+ // stayed null and no deploy target was set, so Capture skipped
1812
+ // ("surface unclassified"), Publish had nothing to build, and QA had no
1813
+ // runnable surface (J1/J2 parked). Fill OMITTED fields from the
1814
+ // deterministic repo scan (lib/classify-surface.js); explicit values
1815
+ // always win — including an explicit null environment_type, which still
1816
+ // means "unclassified". An artifact surface needs a deploy target:
1817
+ // classification alone doesn't close the loop, because Publish skips
1818
+ // when deploy_type is empty and QA can only run against the built
1819
+ // artifact. The initial-provenance stamp below then keys off the
1820
+ // defaulted deploy_type, so the sample's first publish diffs only the
1821
+ // task's changes instead of the whole tree.
1822
+ let environmentType = validateEnvironmentType(args.environment_type);
1823
+ let deploySlug = args.deploy_slug ?? null;
1824
+ if (args.environment_type === undefined || args.deploy_type === undefined) {
1825
+ let c = null;
1826
+ try { c = classifySurface(repoPath); } catch { c = null; }
1827
+ // Effective surface: an explicit environment_type always wins; the
1828
+ // classifier only speaks for omitted fields. Deploy defaults follow
1829
+ // the effective surface — an explicit "terminal" (or an explicit
1830
+ // null = "unclassified") never triggers artifact deploy defaults.
1831
+ // The slug is only auto-filled when we auto-set the artifact deploy;
1832
+ // a caller who chose their own deploy_type owns their slug.
1833
+ const effectiveSurface = args.environment_type === undefined ? (c && c.surface) : environmentType;
1834
+ if (args.environment_type === undefined && c && c.surface) environmentType = c.surface;
1835
+ if (effectiveSurface === "artifact" && args.deploy_type === undefined) {
1836
+ deployType = "artifact";
1837
+ if (args.deploy_slug === undefined && c && c.deploy_slug) deploySlug = c.deploy_slug;
1838
+ }
1839
+ }
1629
1840
  const row = {
1630
1841
  id, display_name: displayName, repo_path: repoPath, deploy_type: deployType,
1631
- deploy_slug: args.deploy_slug ?? null,
1842
+ deploy_slug: deploySlug,
1632
1843
  description: (args.description ?? "").trim().slice(0, 3000),
1633
1844
  simultaneity: args.simultaneity ?? 2,
1634
1845
  quiesced: args.quiesced ? 1 : 0,
@@ -1644,7 +1855,17 @@ commands["create-project"] = (db, args, ctx) => {
1644
1855
  description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
1645
1856
  VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
1646
1857
  @description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
1647
- const initialProvenance = tryStampInitialProvenance(db, ctx.crewHome, repoPath, deployType);
1858
+ // A deferred backfill (zero/ambiguous matches at the last look) gets one
1859
+ // more evaluation now that the match set changed: a new project whose
1860
+ // repo descends from the legacy commit inherits the attribution instead
1861
+ // of stranding the triple in config forever. When the backfill stamps
1862
+ // this project, the initial stamp below is skipped — the legacy triple
1863
+ // is a real publish record and must not be overwritten by the
1864
+ // creation-time HEAD.
1865
+ const backfillStampedId = retryDeferredBackfill(db);
1866
+ const initialProvenance = backfillStampedId === id
1867
+ ? { stamped: true, reason: "legacy backfill attributed this project; initial stamp skipped" }
1868
+ : tryStampInitialProvenance(db, id, ctx.crewHome, repoPath, deployType);
1648
1869
  return { project: mapProject(row), initial_provenance: initialProvenance };
1649
1870
  };
1650
1871
 
@@ -1788,35 +2009,48 @@ commands["update-config"] = (db, args) => {
1788
2009
  return { ok: true };
1789
2010
  };
1790
2011
 
1791
- commands["set-provenance"] = (db, args) => {
1792
- if (!/^[0-9a-f]{40}$/.test(args.source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
1793
- if (!/^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/.test(args.crew_release ?? "")) {
2012
+ // The single provenance writer (room #15 blocker 8). Every stamp flows
2013
+ // through here: set-provenance and create-project's initial stamp are thin
2014
+ // shells over it. Per-project by design — a crew home with N artifact
2015
+ // projects carries N independent publish bases, and stamping one project
2016
+ // never touches another's row.
2017
+ function stampProvenance(db, { project_id, source_commit, crew_release, published_at, task_id }) {
2018
+ const pid = String(project_id ?? "").trim();
2019
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2020
+ requireProject(db, pid);
2021
+ if (!PROVENANCE_SOURCE_COMMIT_RE.test(source_commit ?? "")) throw usageError("source_commit must be 40-char hex.");
2022
+ if (!PROVENANCE_CREW_RELEASE_RE.test(crew_release ?? "")) {
1794
2023
  throw usageError("crew_release must be 40-char hex or pkg-<semver>.");
1795
2024
  }
1796
2025
  // task_id is optional; when present it must be 1-80 chars (matches dashboard contract).
1797
- const taskId = args.task_id ?? null;
1798
- if (taskId !== null && (typeof taskId !== "string" || taskId.trim().length < 1 || taskId.length > 80)) {
2026
+ const tid = task_id ?? null;
2027
+ if (tid !== null && (typeof tid !== "string" || tid.trim().length < 1 || tid.length > 80)) {
1799
2028
  throw usageError("task_id must be 1-80 chars when provided.");
1800
2029
  }
1801
- const upsert = db.prepare(
1802
- "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value");
1803
- upsert.run("provenance.source_commit", args.source_commit);
1804
- upsert.run("provenance.crew_release", args.crew_release);
1805
- upsert.run("provenance.published_at", args.published_at ?? now());
1806
- if (taskId !== null) upsert.run("provenance.task_id", taskId);
1807
- return { ok: true };
2030
+ db.prepare(
2031
+ `UPDATE projects SET provenance_source_commit = ?, provenance_crew_release = ?,
2032
+ provenance_published_at = ?, provenance_task_id = ?, updated_at = ?
2033
+ WHERE id = ?`
2034
+ ).run(source_commit, crew_release, published_at ?? now(), tid, now(), pid);
2035
+ return { ok: true, project_id: pid };
2036
+ }
2037
+
2038
+ commands["set-provenance"] = (db, args) => {
2039
+ return stampProvenance(db, args ?? {});
1808
2040
  };
1809
2041
 
1810
- commands["get-provenance"] = (db) => {
1811
- const rows = db.prepare("SELECT key, value FROM config WHERE key LIKE 'provenance.%'").all();
1812
- const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
1813
- const sc = byKey["provenance.source_commit"];
1814
- const cr = byKey["provenance.crew_release"];
1815
- const pa = byKey["provenance.published_at"];
1816
- if (!sc || !cr || !pa) return { provenance: null };
2042
+ commands["get-provenance"] = (db, args) => {
2043
+ const pid = String((args ?? {}).project_id ?? "").trim();
2044
+ if (!pid) throw usageError("project_id is required — pass the project's id (see list-projects).");
2045
+ const row = requireProject(db, pid);
2046
+ if (!row.provenance_source_commit || !row.provenance_crew_release || !row.provenance_published_at) {
2047
+ return { provenance: null };
2048
+ }
1817
2049
  return { provenance: {
1818
- source_commit: sc, crew_release: cr, published_at: pa,
1819
- task_id: byKey["provenance.task_id"] ?? null,
2050
+ source_commit: row.provenance_source_commit,
2051
+ crew_release: row.provenance_crew_release,
2052
+ published_at: row.provenance_published_at,
2053
+ task_id: row.provenance_task_id ?? null,
1820
2054
  } };
1821
2055
  };
1822
2056
 
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
  );
@@ -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.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -40,11 +40,13 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
40
40
  4.5. **Parent publish verification (docs/publish-verification.md):** The publisher parks instead of stamping provenance; the parent — this tick, the live root agent — verifies content and stamps. Deterministic code detects, reads back, and certifies; you are only the ferry between the deterministic steps (scan → sensor → verifier).
41
41
  - Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-verification-pending`
42
42
  This atomically claims each verification-pending task (1-hour lease, so a second tick cannot double-verify) and reconciles verified-but-still-parked tasks to `in_progress`. It returns `{ to_verify: [...], reconciled: [...] }`. Log both lists. If the scan exits 2 (e.g. the active release cannot be resolved), log the error loudly and continue — do NOT work around it.
43
- - **Read-back (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish), then run:
43
+ - **Read-back (deterministic sensor, 2026-09-15):** no platform inspection tool is needed — `lib/readback-disk.js` reads the platform's on-disk working copy of the artifact source (`~/workspace/ts-spaces/<slug>/`) and emits the machine-readable findings block the verifier parses. For each entry in `to_verify`, resolve the base via `get-provenance` (`source_commit`; the empty-tree sha `4b825dc642cb6eb9a060e54bf8d69288fbee4904` when nothing is stamped yet — a first publish):
44
+ `node {crewHome}/lib/crew-api.js --crew-home {crewHome} get-provenance --json '{"project_id": "<project_id>"}'`
45
+ (use the entry's `project_id` verbatim), then run:
44
46
  `node {crewHome}/lib/readback-disk.js --repo-path "<repo_path>" --commit <commit> --base <base> --slug "<deploy_slug>" --task-id <task_id> > /tmp/readback-<task_id>.txt 2> /tmp/readback-<task_id>.err`
45
47
  Use the entry's `repo_path` and `deploy_slug` verbatim. If the sensor exits 0, the result file holds the findings block — hand it to the verify step below. If it exits non-zero, do NOT save or use stdout: log `publish: verification-procedural-error <commit> <first line of the .err file>` and leave the task parked — the next tick retries. A sensor failure is procedural (the read could not be performed), never a content verdict. Do NOT judge content yourself, and do NOT stamp provenance.
46
- - **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
47
- Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
48
+ - **Verify (code):** `node {crewHome}/lib/verify-publish.js --crew-home {crewHome} --task-id <task_id> --commit <commit> --base <base> --repo-path "<repo_path>" --slug "<deploy_slug>" --crew-release <crew_release> --project-id <project_id> --inspection-id <task_id>-disk --result-file /tmp/readback-<task_id>.txt`
49
+ Pass `--build-agent-id <id>` from the entry's `build_agent_id` when it is present. Use the SAME `<base>` the sensor ran with and the entry's `project_id` verbatim. The verifier parses the findings, compares mechanically against the base..commit diff, checks supersession, and stamps only on a match. Its terminal verdicts (`publish: verified` → task re-queued; `publish: verification-failed` → stays parked) are final — log them and continue.
48
50
  - Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The verify script's machine-checked comparison is the only certification.
49
51
 
50
52
  5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
@@ -70,13 +72,14 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
70
72
  - Dedupe: for each candidate, check `get-events --json '{"task_id": "<id>", "limit": 50}'` for an event whose message starts with `evidence: delivered`. Skip tasks already delivered.
71
73
  - For each remaining task:
72
74
  - Read its QA verdict: from get-state's `sessions`, the completed session for this task whose notes mention Hazel/QA. Quote one verdict line (pass/fail and the one-line reason).
73
- - Find the screenshots (platform audit harness): `AUDIT=$(readlink ~/workspace/ts-spaces/<project>/audits/latest)` where `<project>` is the task's project field. Confirm `$AUDIT/screenshot.png` and `$AUDIT/screenshot-mobile.png` exist AND are newer than the task's `created_at` (compare with `stat -c %Y`). If the audit dir predates the task, the capture is stale from an earlier run — say so and do NOT attach it. Never attach screenshots from a different run.
74
- - Write the transition BEFORE the attachments (2026-09-14 — Eric: screenshots arriving out of context need a problem→solution handoff). Do not hand-write it: run the deterministic composer and paste its stdout verbatim, then the two attachment lines:
75
+ - Find the screenshots (platform audit harness): `AUDIT=$(readlink ~/workspace/ts-spaces/<project>/audits/latest)` where `<project>` is the task's project field. Confirm `$AUDIT/screenshot.png` and `$AUDIT/screenshot-mobile.png` exist AND are newer than the task's `created_at` (compare with `stat -c %Y`). Set `CAPTURES_OK=1` only when both PNGs exist and are fresh; otherwise `CAPTURES_OK=0`. If the audit dir predates the task, the capture is stale from an earlier run — say so and do NOT attach it. Never attach screenshots from a different run. Terminal-surface tasks never have harness captures — expect CAPTURES_OK=0 for them.
76
+ - Write the transition BEFORE the attachments (2026-09-14 — Eric: screenshots arriving out of context need a problem→solution handoff). Do not hand-write it: run the deterministic composer and paste its stdout verbatim:
75
77
  `node {crewHome}/lib/compose-evidence-caption.js --crew-home {crewHome} --task-id "<id>" --audit-dir "<resolved_dir>"`
76
- The composer emits: the out-of-context acknowledgment, the task title, Problem: (from the task description), Shipped: (the merge commit's subject + short-sha, or an honest "provenance not yet stamped"), Evidence: (the audit dir + QA verdict, or "No QA verdict recorded"), and the visual-protocol-unavailable honest label when the run recorded one. If the composer exits non-zero, say so in one line and attach nothing for that task.
77
- - Attach both to your report, each on its own plain line, using the RESOLVED dir name (not the `latest` symlink) so the evidence is pinned to this run:
78
+ The composer emits: the out-of-context acknowledgment, the task title, Problem: (from the task description), Shipped: (the merge commit's subject + short-sha, or an honest "provenance not yet stamped"), Evidence: (the audit dir + QA verdict when captures exist, or the honest no-captures line — 2026-09-17: the composer verifies the captures exist before claiming them, so a terminal-surface task with no audit dir gets the honest line, never "desktop + mobile captures below"), and the visual-protocol-unavailable honest label when the run recorded one. If the composer exits non-zero, say so in one line and attach nothing for that task.
79
+ - Attach the two screenshot lines ONLY when CAPTURES_OK=1, each on its own plain line, using the RESOLVED dir name (not the `latest` symlink) so the evidence is pinned to this run:
78
80
  `![desktop](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot.png)`
79
81
  `![mobile](sandbox://workspace/ts-spaces/<project>/audits/<resolved_dir>/screenshot-mobile.png)`
82
+ When CAPTURES_OK=0, attach nothing — the composer's caption already says so honestly.
80
83
  - Log the delivery so the next tick skips it: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} log-event --json '{"task_id": "<id>", "type": "note", "message": "evidence: delivered <id> — screenshots attached to tick report"}'`.
81
84
  - If a done task has no fresh screenshots, say so in one line — never claim evidence you don't have.
82
85