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/API.md +38 -4
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +17 -5
- package/lib/AGENTS.md +4 -4
- package/lib/advance-publish-base.js +36 -15
- package/lib/compose-evidence-caption.js +4 -3
- package/lib/crew-api.js +303 -52
- package/lib/schema.sql +25 -1
- 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 +5 -3
- package/workflows/bugfix.js +184 -36
- package/workflows/chore.js +183 -35
- package/workflows/crew-init.js +59 -4
- package/workflows/standard.js +184 -36
- package/workflows/upgrade.js +8 -3
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
1476
|
+
`cannot resolve the active release: ${join(crewHome, "current")} is not a readable symlink (${e.message}).`
|
|
1273
1477
|
);
|
|
1274
1478
|
}
|
|
1275
|
-
|
|
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
|
|
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
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
|
1829
|
-
if (
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
|
1843
|
-
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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:
|
|
1850
|
-
|
|
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"
|
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