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/API.md +40 -7
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +24 -3
- package/docs/state-boundary-spec.md +37 -0
- package/lib/AGENTS.md +6 -3
- package/lib/advance-publish-base.js +271 -0
- package/lib/classify-surface.js +109 -0
- package/lib/compose-evidence-caption.js +59 -10
- package/lib/compute-publish-diff.js +145 -0
- package/lib/crew-api.js +283 -49
- package/lib/schema.sql +12 -0
- package/lib/test-worktree-backend.sh +29 -0
- package/lib/verify-publish.js +4 -3
- package/lib/worktree-lifecycle.sh +9 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +10 -7
- package/workflows/bugfix.js +67 -69
- package/workflows/chore.js +66 -68
- package/workflows/crew-dispatch.js +21 -6
- package/workflows/crew-init.js +69 -3
- package/workflows/standard.js +67 -69
- package/workflows/upgrade.js +8 -3
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(
|
|
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
|
-
|
|
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
|
-
`
|
|
1435
|
+
`cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}).`
|
|
1272
1436
|
);
|
|
1273
1437
|
}
|
|
1274
|
-
|
|
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
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
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
|
-
|
|
1603
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
|
1798
|
-
if (
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
|
1812
|
-
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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:
|
|
1819
|
-
|
|
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"
|
package/lib/verify-publish.js
CHANGED
|
@@ -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 —
|
|
420
|
-
# be committed into main (J1 canary: .merge-lock landed
|
|
421
|
-
|
|
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
|
@@ -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)
|
|
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
|
|
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 "
|
|
77
|
-
- Attach
|
|
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
|
``
|
|
79
81
|
``
|
|
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
|
|