@tea-agent/loop-agent 0.15.0 → 0.16.1-beta.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +7 -11
  2. package/dist/executors/dag-pi-executor.js +44 -4
  3. package/dist/worker/cli.js +6 -3
  4. package/dist/worker/delivery/final-verification.js +96 -8
  5. package/dist/worker/delivery/package.js +23 -4
  6. package/dist/worker/delivery/verification-bundle.js +510 -0
  7. package/dist/worker/feature/fullstack-validate.js +337 -0
  8. package/dist/worker/feature/profile-schema.js +44 -0
  9. package/dist/worker/feature/ready-plan-projection.js +1 -0
  10. package/dist/worker/feature/reducer.js +2 -0
  11. package/dist/worker/feature/review.js +105 -11
  12. package/dist/worker/materialize/harness-task-materializer.js +5 -0
  13. package/dist/worker/observability/read-model.js +7 -0
  14. package/dist/worker/observe/static/views/task.js +1 -0
  15. package/dist/worker/outcomes/adapters.js +141 -0
  16. package/dist/worker/outcomes/gate.js +41 -0
  17. package/dist/worker/outcomes/projector.js +176 -0
  18. package/dist/worker/outcomes/registry.js +1 -0
  19. package/dist/worker/outcomes/store.js +131 -0
  20. package/dist/worker/outcomes/types.js +76 -0
  21. package/dist/worker/report/morning-report.js +4 -3
  22. package/dist/worker/run-task/run-task.js +66 -2
  23. package/dist/worker/runner/run-ready.js +32 -1
  24. package/dist/worker/task-graph/acceptance-schema.js +12 -0
  25. package/dist/worker/task-graph/ready-planner.js +125 -0
  26. package/dist/worker/task-graph/task-graph-schema.js +29 -0
  27. package/dist/worker/task-graph/validate.js +44 -4
  28. package/dist/worker/task-spec/schema.js +9 -0
  29. package/dist/worker/task-spec/validate.js +39 -0
  30. package/dist/worker/task-spec/workflow-routing.js +149 -0
  31. package/dist/workflows/dag/init-hybrid.js +3 -2
  32. package/dist/workflows/dag/types.js +1 -0
  33. package/docs/templates/agent-dag.schema.json +5 -0
  34. package/harness.json +1 -1
  35. package/package.json +1 -1
  36. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  37. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -0,0 +1,510 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { controllerIdentitiesMatch } from "../loop-agent/loop-agent-client.js";
6
+ import { readVerifiedOutcome } from "../outcomes/store.js";
7
+ import { getTaskPoolRoot, readFeatureTaskPoolStates } from "../pool/run-store.js";
8
+ import { workflowSchema } from "../task-spec/workflow-routing.js";
9
+ import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
10
+ /**
11
+ * Feature Verification Bundle v1 — a hash-bound aggregate of completed typed
12
+ * `backend-test` / `frontend-test` Task Outcome Envelopes that lets
13
+ * `feature verify-final` and Delivery consume direct typed test evidence
14
+ * without requiring a legacy `qa-execute` aggregate.
15
+ *
16
+ * The bundle is fail-closed on every drift dimension required by the W3.3
17
+ * contract: outcome hash/tamper, feature/task/workflow identity, controller
18
+ * identity, and Delivery HEAD. It never moves Git HEAD and never writes the
19
+ * worktree; persistence only writes the Feature evidence directory.
20
+ */
21
+ export const VERIFICATION_BUNDLE_SCHEMA_VERSION = 1;
22
+ const TYPED_VERIFICATION_WORKFLOWS = ["backend-test", "frontend-test"];
23
+ const bundleOutcomeSchema = z
24
+ .object({
25
+ featureId: z.string().min(1),
26
+ taskId: z.string().min(1),
27
+ workflow: z.enum(TYPED_VERIFICATION_WORKFLOWS),
28
+ workerRunId: z.string().min(1),
29
+ harnessTaskId: z.string().min(1).optional(),
30
+ outcomePath: z.string().min(1),
31
+ outcomeSha256: z.string().regex(/^[a-f0-9]{64}$/),
32
+ acceptanceCoverage: z.array(z.string().min(1)),
33
+ artifacts: z.array(z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), kind: z.string().min(1).optional() }).strict()),
34
+ integrationStatus: z.object({ mock: z.boolean(), real: z.boolean() }).strict(),
35
+ outcomeStatus: z.enum(["succeeded", "failed"]),
36
+ controllerIdentity: z
37
+ .object({
38
+ packageName: z.string().min(1),
39
+ packageVersion: z.string().min(1),
40
+ })
41
+ .strict()
42
+ .optional(),
43
+ })
44
+ .strict();
45
+ const bundleControllerIdentitySchema = z
46
+ .object({
47
+ packageName: z.string().min(1),
48
+ packageVersion: z.string().min(1),
49
+ })
50
+ .strict()
51
+ .optional();
52
+ const bundleAcceptanceItemSchema = z
53
+ .object({
54
+ acId: z.string().min(1),
55
+ status: z.enum(["covered", "partial", "blocked", "missing"]),
56
+ })
57
+ .strict();
58
+ export const featureVerificationBundleV1Schema = z
59
+ .object({
60
+ schemaVersion: z.literal(VERIFICATION_BUNDLE_SCHEMA_VERSION),
61
+ featureId: z.string().min(1),
62
+ controllerIdentity: bundleControllerIdentitySchema,
63
+ headSha: z.string().regex(/^[a-f0-9]{40}$/),
64
+ backendTests: z.array(bundleOutcomeSchema),
65
+ frontendTests: z.array(bundleOutcomeSchema),
66
+ acceptance: z.array(bundleAcceptanceItemSchema),
67
+ bundleSha256: z.string().regex(/^[a-f0-9]{64}$/),
68
+ })
69
+ .strict();
70
+ /**
71
+ * Pure projection that aggregates completed `backend-test` / `frontend-test`
72
+ * outcomes into a hash-bound Feature Verification Bundle v1.
73
+ *
74
+ * Fail-closed guarantees:
75
+ * - only typed verification workflows (`backend-test`, `frontend-test`) are accepted;
76
+ * - only `outcomeStatus === "succeeded"` outcomes contribute;
77
+ * - every contributing outcome's `identity.featureId` must equal the bundle featureId;
78
+ * - controller identity is asserted across outcome ↔ run ↔ input (all-present-or-all-absent
79
+ * tolerance is preserved for legacy packets that never recorded an identity);
80
+ * - required acceptance (`priority === "must"`) must be fully covered by the typed
81
+ * evidence union, otherwise the projection throws (fail closed — no silent
82
+ * downgrade to a partial bundle).
83
+ *
84
+ * The function never touches the filesystem; persistence is delegated to
85
+ * {@link writeFeatureVerificationBundle}.
86
+ */
87
+ export function buildFeatureVerificationBundle(input) {
88
+ const implementationTaskIds = input.implementationTaskIds ?? new Set();
89
+ const backendTests = [];
90
+ const frontendTests = [];
91
+ for (const run of input.runs) {
92
+ if (run.featureId !== input.featureId) {
93
+ throw new Error(`verification bundle run featureId mismatch: ${run.workerRunId}`);
94
+ }
95
+ if (run.status !== "succeeded") {
96
+ throw new Error(`verification bundle requires a succeeded run: ${run.workerRunId}`);
97
+ }
98
+ const workflow = run.workflow;
99
+ if (!workflow || !isTypedVerificationWorkflow(workflow)) {
100
+ // Non-typed runs (e.g. agent-dag, qa-execute) are simply ignored by the
101
+ // typed bundle — they belong to the legacy aggregate path.
102
+ continue;
103
+ }
104
+ const spec = input.specs.get(run.taskId);
105
+ const outcome = input.outcomes.get(run.workerRunId);
106
+ if (!spec || !outcome) {
107
+ throw new Error(`verification bundle is missing spec or outcome for run: ${run.workerRunId}`);
108
+ }
109
+ if (!isTypedVerificationWorkflow(outcome.identity.workflow)) {
110
+ throw new Error(`verification bundle outcome workflow is not a typed test: ${run.workerRunId}`);
111
+ }
112
+ if (spec.feature_id !== input.featureId || spec.execution?.workflow !== workflow || outcome.identity.workflow !== workflow) {
113
+ throw new Error(`verification bundle TaskSpec workflow identity mismatch: ${run.workerRunId}`);
114
+ }
115
+ if (outcome.identity.featureId !== input.featureId) {
116
+ throw new Error(`verification bundle outcome featureId mismatch: ${run.workerRunId}`);
117
+ }
118
+ if (outcome.identity.taskId !== run.taskId) {
119
+ throw new Error(`verification bundle outcome taskId mismatch: ${run.workerRunId}`);
120
+ }
121
+ if (outcome.identity.workerRunId !== run.workerRunId) {
122
+ throw new Error(`verification bundle outcome workerRunId mismatch: ${run.workerRunId}`);
123
+ }
124
+ if (outcome.outcomeStatus !== "succeeded") {
125
+ throw new Error(`verification bundle requires a succeeded outcome: ${run.workerRunId}`);
126
+ }
127
+ assertControllerIdentityTriple(outcome.controllerIdentity, run.controllerIdentity, input.controllerIdentity, run.workerRunId);
128
+ const bundleOutcome = {
129
+ featureId: outcome.identity.featureId,
130
+ taskId: outcome.identity.taskId,
131
+ workflow: outcome.identity.workflow,
132
+ workerRunId: outcome.identity.workerRunId,
133
+ ...(outcome.identity.harnessTaskId
134
+ ? { harnessTaskId: outcome.identity.harnessTaskId }
135
+ : {}),
136
+ outcomePath: run.outcomePath ?? "",
137
+ outcomeSha256: run.outcomeSha256 ?? "",
138
+ acceptanceCoverage: outcome.acceptanceCoverage,
139
+ artifacts: outcome.artifacts,
140
+ integrationStatus: outcome.integrationStatus,
141
+ outcomeStatus: outcome.outcomeStatus,
142
+ ...(outcome.controllerIdentity
143
+ ? {
144
+ controllerIdentity: {
145
+ packageName: outcome.controllerIdentity.packageName,
146
+ packageVersion: outcome.controllerIdentity.packageVersion,
147
+ },
148
+ }
149
+ : {}),
150
+ };
151
+ if (workflow === "backend-test")
152
+ backendTests.push(bundleOutcome);
153
+ else
154
+ frontendTests.push(bundleOutcome);
155
+ }
156
+ backendTests.sort(byTaskId);
157
+ frontendTests.sort(byTaskId);
158
+ const acceptanceItems = input.acceptance.map((item) => projectBundleAcceptance(item, backendTests, frontendTests, implementationTaskIds, input.featureId));
159
+ // Fail closed: a required AC that is not fully covered by typed evidence
160
+ // must never silently produce a bundle. Non-required ACs may remain partial
161
+ // or missing for informational reporting without blocking the bundle.
162
+ const requiredGaps = acceptanceItems.filter((item) => item.priority === "must" && item.status !== "covered");
163
+ if (requiredGaps.length > 0) {
164
+ throw new Error(`verification bundle does not cover required acceptance: ${requiredGaps
165
+ .map((item) => item.acId)
166
+ .join(", ")}`);
167
+ }
168
+ const bundle = {
169
+ schemaVersion: VERIFICATION_BUNDLE_SCHEMA_VERSION,
170
+ featureId: input.featureId,
171
+ ...(input.controllerIdentity
172
+ ? {
173
+ controllerIdentity: {
174
+ packageName: input.controllerIdentity.packageName,
175
+ packageVersion: input.controllerIdentity.packageVersion,
176
+ },
177
+ }
178
+ : { controllerIdentity: undefined }),
179
+ headSha: input.headSha,
180
+ backendTests,
181
+ frontendTests,
182
+ acceptance: acceptanceItems.map(({ acId, status }) => ({ acId, status })),
183
+ bundleSha256: "",
184
+ };
185
+ bundle.bundleSha256 = computeBundleSha256(bundle);
186
+ return featureVerificationBundleV1Schema.parse(bundle);
187
+ }
188
+ function projectBundleAcceptance(item, backendTests, frontendTests, implementationTaskIds, expectedFeatureId) {
189
+ const implRefs = item.verification.implementation_task_refs ?? [];
190
+ const verRefs = item.verification.verification_task_refs ?? [];
191
+ const requiredEvidence = item.verification.required_evidence ?? [];
192
+ const integration = item.verification.integration;
193
+ const allTests = [...backendTests, ...frontendTests];
194
+ const coveredAcIds = new Set(allTests.flatMap((entry) => entry.acceptanceCoverage));
195
+ // 1. implementation refs must all be present (Done).
196
+ const implDone = implRefs.length > 0
197
+ ? implRefs.every((id) => implementationTaskIds.has(id))
198
+ : true;
199
+ // 2. verification refs must each have a succeeded typed outcome.
200
+ const verEnvelopes = verRefs.length
201
+ ? verRefs
202
+ .map((taskId) => allTests.find((entry) => entry.taskId === taskId))
203
+ .filter((value) => Boolean(value))
204
+ : [];
205
+ const verSucceeded = verRefs.length === 0 ||
206
+ (verEnvelopes.length === verRefs.length &&
207
+ verEnvelopes.every((entry) => entry.outcomeStatus === "succeeded"));
208
+ if (!implDone) {
209
+ return { acId: item.id, status: implRefs.length ? "partial" : "missing", priority: item.priority };
210
+ }
211
+ if (verRefs.length > 0 && !verSucceeded) {
212
+ return {
213
+ acId: item.id,
214
+ status: verEnvelopes.length === 0 ? "missing" : "partial",
215
+ priority: item.priority,
216
+ };
217
+ }
218
+ // 3. identity consistency: every contributing typed outcome must belong to
219
+ // the bundle Feature. A cross-Feature outcome is a fail-closed signal.
220
+ const crossFeature = verEnvelopes.some((entry) => entry.featureId !== expectedFeatureId);
221
+ if (crossFeature) {
222
+ return { acId: item.id, status: "missing", priority: item.priority };
223
+ }
224
+ // 4. required evidence kinds must each appear on at least one envelope.
225
+ if (requiredEvidence.some((kind) => !verEnvelopes.some((entry) => evidenceKindPresent(entry, kind)))) {
226
+ return { acId: item.id, status: "partial", priority: item.priority };
227
+ }
228
+ // 5. real-required integration demands real evidence on every verification envelope.
229
+ if (integration === "real-required") {
230
+ if (verRefs.length === 0) {
231
+ return { acId: item.id, status: "missing", priority: item.priority };
232
+ }
233
+ const realOk = verEnvelopes.every((entry) => entry.integrationStatus.real === true);
234
+ if (!realOk) {
235
+ return { acId: item.id, status: "partial", priority: item.priority };
236
+ }
237
+ }
238
+ // 6. AC must be referenced by at least one typed outcome's acceptanceCoverage
239
+ // so a stray verification run cannot satisfy an unrelated AC.
240
+ if (!coveredAcIds.has(item.id)) {
241
+ return { acId: item.id, status: "missing", priority: item.priority };
242
+ }
243
+ return { acId: item.id, status: "covered", priority: item.priority };
244
+ }
245
+ function evidenceKindPresent(entry, kind) {
246
+ return entry.artifacts.some((artifact) => artifact.kind === kind);
247
+ }
248
+ function isTypedVerificationWorkflow(workflow) {
249
+ return TYPED_VERIFICATION_WORKFLOWS.includes(workflow);
250
+ }
251
+ function byTaskId(a, b) {
252
+ return a.taskId.localeCompare(b.taskId);
253
+ }
254
+ /**
255
+ * Canonical SHA-256 of the bundle, computed over a stable JSON serialization
256
+ * (sorted keys, no whitespace) that excludes the `bundleSha256` field itself.
257
+ * Any byte-level tamper therefore invalidates the stored hash.
258
+ */
259
+ export function computeBundleSha256(bundle) {
260
+ const canonical = {
261
+ schemaVersion: bundle.schemaVersion,
262
+ featureId: bundle.featureId,
263
+ ...(bundle.controllerIdentity
264
+ ? { controllerIdentity: bundle.controllerIdentity }
265
+ : {}),
266
+ headSha: bundle.headSha,
267
+ backendTests: bundle.backendTests,
268
+ frontendTests: bundle.frontendTests,
269
+ acceptance: bundle.acceptance,
270
+ };
271
+ return createHash("sha256")
272
+ .update(JSON.stringify(canonical, stableKeyReplacer))
273
+ .digest("hex");
274
+ }
275
+ function stableKeyReplacer(_key, value) {
276
+ if (value && typeof value === "object" && !Array.isArray(value)) {
277
+ return Object.keys(value)
278
+ .sort()
279
+ .reduce((acc, key) => {
280
+ acc[key] = value[key];
281
+ return acc;
282
+ }, {});
283
+ }
284
+ return value;
285
+ }
286
+ /**
287
+ * Assert the controller identity triple (outcome ↔ Task Pool run ↔ input) is
288
+ * consistent. All-absent is tolerated for legacy packets that never recorded
289
+ * an identity; any present/absent or present/present-mismatch is fail-closed.
290
+ */
291
+ export function assertControllerIdentityTriple(outcomeIdentity, runIdentity, inputIdentity, label) {
292
+ if (!outcomeIdentity && !runIdentity && !inputIdentity)
293
+ return;
294
+ if (!outcomeIdentity || !runIdentity || !inputIdentity) {
295
+ throw new Error(`controller identity mismatch in verification bundle: ${label}`);
296
+ }
297
+ if (outcomeIdentity.packageName !== runIdentity.packageName ||
298
+ outcomeIdentity.packageVersion !== runIdentity.packageVersion ||
299
+ !controllerIdentitiesMatch(runIdentity, inputIdentity)) {
300
+ throw new Error(`controller identity mismatch in verification bundle: ${label}`);
301
+ }
302
+ }
303
+ /** Resolve the evidence-directory path for a Feature Verification Bundle. */
304
+ export function bundleEvidencePath(repoRoot, featureId) {
305
+ return path.join(getTaskPoolRoot(repoRoot), "evidence", featureId, "feature-verification-bundle.json");
306
+ }
307
+ /**
308
+ * Persist a built bundle atomically under the Feature evidence directory. The
309
+ * stored bytes are validated against {@link featureVerificationBundleV1Schema}
310
+ * and the canonical hash is reasserted before the rename. Never moves HEAD and
311
+ * never writes outside the evidence directory.
312
+ */
313
+ export async function writeFeatureVerificationBundle(repoRoot, bundle) {
314
+ const parsed = featureVerificationBundleV1Schema.parse(bundle);
315
+ const recomputed = computeBundleSha256(parsed);
316
+ if (recomputed !== parsed.bundleSha256) {
317
+ throw new Error("verification bundle hash does not match its canonical bytes");
318
+ }
319
+ const canonicalRepoRoot = await realpath(repoRoot);
320
+ const evidenceDir = path.join(getTaskPoolRoot(canonicalRepoRoot), "evidence", parsed.featureId);
321
+ const target = path.join(evidenceDir, "feature-verification-bundle.json");
322
+ await mkdir(evidenceDir, { recursive: true });
323
+ const payload = `${JSON.stringify(parsed, null, 2)}\n`;
324
+ const temp = path.join(evidenceDir, `.feature-verification-bundle.${randomUUID()}.tmp`);
325
+ try {
326
+ await writeFile(temp, payload, "utf-8");
327
+ await rename(temp, target);
328
+ }
329
+ finally {
330
+ await unlink(temp).catch(() => { });
331
+ }
332
+ return {
333
+ path: path.relative(canonicalRepoRoot, target).replace(/\\/g, "/"),
334
+ sha256: parsed.bundleSha256,
335
+ };
336
+ }
337
+ /**
338
+ * Read and verify a Feature Verification Bundle by hashed ref. Returns
339
+ * `undefined` on any path-containment, hash, or schema failure so callers
340
+ * naturally fail the Delivery/Closeout gate closed.
341
+ */
342
+ export async function readVerifiedBundle(input) {
343
+ if (!input.ref?.path || !input.ref?.sha256)
344
+ return undefined;
345
+ const repoRoot = await realpath(input.repoRoot);
346
+ const candidate = path.resolve(repoRoot, input.ref.path);
347
+ const relative = path.relative(repoRoot, candidate);
348
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
349
+ return undefined;
350
+ }
351
+ let raw;
352
+ try {
353
+ raw = await readFile(candidate, "utf-8");
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ let parsed;
359
+ try {
360
+ parsed = featureVerificationBundleV1Schema.parse(JSON.parse(raw));
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ if (computeBundleSha256(parsed) !== parsed.bundleSha256)
366
+ return undefined;
367
+ if (parsed.bundleSha256 !== input.ref.sha256)
368
+ return undefined;
369
+ return parsed;
370
+ }
371
+ /**
372
+ * Verify that every outcome referenced by a persisted bundle still resolves to
373
+ * its hash-anchored bytes on disk. Used by Delivery/Closeout to detect outcome
374
+ * tamper or drift after the bundle was written.
375
+ */
376
+ export async function verifyBundleOutcomes(repoRoot, bundle) {
377
+ for (const outcome of [...bundle.backendTests, ...bundle.frontendTests]) {
378
+ const verified = await readVerifiedOutcome({
379
+ repoRoot,
380
+ workerRunId: outcome.workerRunId,
381
+ outcomePath: outcome.outcomePath,
382
+ outcomeSha256: outcome.outcomeSha256,
383
+ });
384
+ if (!verified)
385
+ return false;
386
+ if (verified.identity.featureId !== bundle.featureId)
387
+ return false;
388
+ if (verified.identity.taskId !== outcome.taskId)
389
+ return false;
390
+ if (verified.identity.workflow !== outcome.workflow)
391
+ return false;
392
+ if (verified.outcomeStatus !== "succeeded")
393
+ return false;
394
+ if (JSON.stringify(verified.artifacts) !== JSON.stringify(outcome.artifacts))
395
+ return false;
396
+ if (!await verifyArtifactRefs(repoRoot, outcome.artifacts))
397
+ return false;
398
+ }
399
+ return true;
400
+ }
401
+ /** Rebind a persisted bundle to the current Feature Packet TaskSpec contracts. */
402
+ export function verifyBundleTaskSpecBindings(bundle, specs) {
403
+ const taskIds = new Set();
404
+ for (const outcome of [...bundle.backendTests, ...bundle.frontendTests]) {
405
+ if (taskIds.has(outcome.taskId))
406
+ return false;
407
+ taskIds.add(outcome.taskId);
408
+ const spec = specs.get(outcome.taskId);
409
+ if (!spec ||
410
+ spec.feature_id !== bundle.featureId ||
411
+ spec.type !== "qa-execute" ||
412
+ spec.execution?.workflow !== outcome.workflow)
413
+ return false;
414
+ }
415
+ return true;
416
+ }
417
+ /**
418
+ * Delivery and Closeout revalidate that each bundle outcome still names the
419
+ * current canonical Done state for its Feature task. This prevents a
420
+ * historical successful run from remaining deliverable after a retry or state
421
+ * transition selected a different run.
422
+ */
423
+ export async function verifyBundleCurrentStateBindings(repoRoot, bundle) {
424
+ try {
425
+ const states = await readFeatureTaskPoolStates(repoRoot, bundle.featureId);
426
+ const taskIds = new Set();
427
+ for (const outcome of [...bundle.backendTests, ...bundle.frontendTests]) {
428
+ if (taskIds.has(outcome.taskId))
429
+ return false;
430
+ taskIds.add(outcome.taskId);
431
+ const state = states[outcome.taskId];
432
+ if (state?.featureId !== bundle.featureId ||
433
+ state.status !== "Done" ||
434
+ state.workerRunId !== outcome.workerRunId)
435
+ return false;
436
+ }
437
+ return true;
438
+ }
439
+ catch {
440
+ return false;
441
+ }
442
+ }
443
+ async function verifyArtifactRefs(repoRoot, artifacts) {
444
+ let canonicalRepoRoot;
445
+ try {
446
+ canonicalRepoRoot = await realpath(repoRoot);
447
+ }
448
+ catch {
449
+ return false;
450
+ }
451
+ for (const artifact of artifacts) {
452
+ if (path.isAbsolute(artifact.path))
453
+ return false;
454
+ const lexical = path.resolve(canonicalRepoRoot, artifact.path);
455
+ let resolved;
456
+ let content;
457
+ try {
458
+ resolved = await realpath(lexical);
459
+ content = await readFile(resolved);
460
+ }
461
+ catch {
462
+ return false;
463
+ }
464
+ const relative = path.relative(canonicalRepoRoot, resolved);
465
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
466
+ return false;
467
+ if (createHash("sha256").update(content).digest("hex") !== artifact.sha256)
468
+ return false;
469
+ }
470
+ return true;
471
+ }
472
+ /** Assert the clean-HEAD invariant required before building/consuming a bundle. */
473
+ export async function assertBundleCleanHead(repoRoot, featureId, expectedHeadSha, deps = {}) {
474
+ const record = await (deps.readGitRecord ?? readGitRecord)(repoRoot, featureId);
475
+ if (!record) {
476
+ throw new Error("Feature Git transaction record is missing or invalid");
477
+ }
478
+ const git = deps.git ?? defaultGit;
479
+ const branch = await git(repoRoot, ["branch", "--show-current"]);
480
+ const headSha = await git(repoRoot, ["rev-parse", "HEAD"]);
481
+ const status = await git(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
482
+ if (branch !== record.branch) {
483
+ throw new Error(`verification bundle requires the recorded Feature branch: ${record.branch}`);
484
+ }
485
+ if (headSha !== record.lastCheckpoint) {
486
+ throw new Error("verification bundle requires the HEAD at the recorded last checkpoint");
487
+ }
488
+ if (headSha !== expectedHeadSha) {
489
+ throw new Error("verification bundle headSha does not match the Delivery HEAD");
490
+ }
491
+ if (status.trim()) {
492
+ throw new Error(`verification bundle requires a clean worktree:\n${status.trim()}`);
493
+ }
494
+ }
495
+ async function readGitRecord(repoRoot, featureId) {
496
+ try {
497
+ return gitTransactionRecordSchema.parse(JSON.parse(await readFile(transactionRecordPath(repoRoot, featureId), "utf-8")));
498
+ }
499
+ catch {
500
+ return undefined;
501
+ }
502
+ }
503
+ async function defaultGit(repoRoot, args) {
504
+ const { execFile } = await import("node:child_process");
505
+ const { promisify } = await import("node:util");
506
+ const execFileAsync = promisify(execFile);
507
+ return (await execFileAsync("git", args, { cwd: repoRoot, encoding: "utf8" })).stdout.trim();
508
+ }
509
+ // Re-export the workflow schema so consumers can validate typed workflows.
510
+ export { workflowSchema };