opencode-workflow-guard 1.18.0 → 1.19.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.
@@ -0,0 +1,147 @@
1
+ import { dogfoodEvidence } from "./evidence.ts";
2
+ import { getDogfoodDeployMatrix, getDogfoodRecordsForWorkspace, isDogfoodRequired } from "./state.ts";
3
+ import { getCurrentGitCommitHash, getTrackedWorktreeFingerprint } from "./verify.ts";
4
+ import type { DogfoodResult } from "./types.ts";
5
+
6
+ export interface DogfoodGateResult {
7
+ required: boolean;
8
+ /** Matrix hosts still missing a fresh passing record. */
9
+ missingHosts: string[];
10
+ /** The record that satisfied the gate, when one did. */
11
+ matched?: DogfoodResult;
12
+ /** Every record counted toward the gate, one per host. */
13
+ matchedRecords?: DogfoodResult[];
14
+ /** Why the gate is unsatisfied, cause-attributed. */
15
+ reason?: string;
16
+ /** Evidence id of the matched record, for audit correlation. */
17
+ evidenceId?: string;
18
+ }
19
+
20
+ /**
21
+ * Why one record cannot count for this worktree, or undefined when it can.
22
+ *
23
+ * Each cause is named separately because they have different remedies. A
24
+ * missing fingerprint is an environment problem, a fingerprint mismatch is a
25
+ * stale run, and a commit mismatch is an unrecorded commit. Collapsing them
26
+ * into one message sends the agent looking for the wrong fix.
27
+ */
28
+ function stalenessReason(record: DogfoodResult, commitHash: string | undefined, fingerprint: string | undefined): string | undefined {
29
+ if (!fingerprint) {
30
+ return "Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so dogfood evidence cannot be bound to this worktree; resolve worktree readability and re-run the dogfood scenario.";
31
+ }
32
+ if (!record.worktreeFingerprint) {
33
+ return `The recorded dogfood run ('${record.scenarioId}') has no worktree fingerprint binding (recorded against an unreadable worktree); re-run the dogfood scenario and record it with record_dogfood.`;
34
+ }
35
+ if (record.worktreeFingerprint !== fingerprint) {
36
+ return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') does not match the current worktree contents (tracked content changed after the run); re-run the dogfood scenario and record it with record_dogfood.`;
37
+ }
38
+ if (record.commitHash && commitHash && record.commitHash !== commitHash) {
39
+ return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') is bound to commit ${record.commitHash.slice(0, 12)} but this worktree is at commit ${commitHash.slice(0, 12)}; re-run the dogfood scenario at the current commit and record it with record_dogfood.`;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * Judges the dogfood gate for one worktree.
46
+ *
47
+ * Shared by PR preflight and `guard_status` so the two can never disagree about
48
+ * whether the gate is satisfied. Freshness is content-scoped, matching review:
49
+ * a record covers the tracked tree it ran against, so untracked scratch
50
+ * cleanup cannot invalidate it while any tracked edit or new commit does.
51
+ */
52
+ export function evaluateDogfoodGate(root: string): DogfoodGateResult {
53
+ if (!isDogfoodRequired(root)) return { required: false, missingHosts: [] };
54
+ const matrix = getDogfoodDeployMatrix(root);
55
+ const records = getDogfoodRecordsForWorkspace(root);
56
+ const commitHash = getCurrentGitCommitHash(root);
57
+ const fingerprint = getTrackedWorktreeFingerprint(root);
58
+
59
+ if (records.length === 0) {
60
+ return {
61
+ required: true,
62
+ missingHosts: matrix.length > 0 ? matrix : ["this host"],
63
+ reason: "Passing dogfood evidence is required (requireDogfood); run the dogfood runner and record the result with record_dogfood.",
64
+ };
65
+ }
66
+
67
+ // Only the newest record per host can count. An older pass is superseded by
68
+ // a newer run of the same host, including a newer HOLD, so a stale pass
69
+ // cannot hide a regression the same host later found. A same-millisecond
70
+ // tie resolves to the HOLD regardless of arrival order: re-running is cheap,
71
+ // and trusting a stale pass is the failure that matters.
72
+ const newestPerHost = new Map<string, DogfoodResult>();
73
+ for (const record of records) {
74
+ const seen = newestPerHost.get(record.host.label);
75
+ if (!seen || record.timestamp > seen.timestamp) {
76
+ newestPerHost.set(record.host.label, record);
77
+ } else if (record.timestamp === seen.timestamp && seen.verdict === "pass" && record.verdict === "hold") {
78
+ newestPerHost.set(record.host.label, record);
79
+ }
80
+ }
81
+
82
+ const unmatched: string[] = [];
83
+ const held: DogfoodResult[] = [];
84
+ const stale: Array<{ record: DogfoodResult; reason: string }> = [];
85
+ const matchedRecords: DogfoodResult[] = [];
86
+ for (const [host, record] of newestPerHost) {
87
+ // A host outside the deploy matrix cannot block the gate, so its HOLD or
88
+ // staleness must not be reported as the reason: that would name the wrong
89
+ // remedy. Only in-scope hosts collect their cause.
90
+ const inScope = matrix.length === 0 || matrix.includes(host);
91
+ if (record.verdict !== "pass") {
92
+ if (inScope) {
93
+ held.push(record);
94
+ unmatched.push(host);
95
+ }
96
+ continue;
97
+ }
98
+ const reason = stalenessReason(record, commitHash, fingerprint);
99
+ if (reason) {
100
+ if (inScope) {
101
+ stale.push({ record, reason });
102
+ unmatched.push(host);
103
+ }
104
+ continue;
105
+ }
106
+ if (!inScope) continue;
107
+ matchedRecords.push(record);
108
+ }
109
+
110
+ if (matrix.length > 0) {
111
+ for (const host of matrix) if (!newestPerHost.has(host)) unmatched.push(host);
112
+ }
113
+
114
+ if (unmatched.length > 0) {
115
+ const missingHosts = [...new Set(unmatched)];
116
+ // A HOLD is more actionable than a matrix gap, so name it first.
117
+ if (held.length > 0) {
118
+ const first = held[0];
119
+ return {
120
+ required: true,
121
+ missingHosts,
122
+ reason: `The most recent dogfood run for host '${first.host.label}' ('${first.scenarioId}') returned HOLD${first.taxonomyClass ? ` with class '${first.taxonomyClass}'` : ""}; fix the finding and re-run before opening a PR.`,
123
+ };
124
+ }
125
+ if (stale.length > 0) {
126
+ return { required: true, missingHosts, reason: stale[0].reason };
127
+ }
128
+ const outsideMatrix = [...newestPerHost.keys()].filter((host) => !matrix.includes(host));
129
+ if (matrix.length > 0 && matchedRecords.length === 0 && outsideMatrix.length > 0) {
130
+ return {
131
+ required: true,
132
+ missingHosts,
133
+ reason: `Dogfood evidence was recorded for host(s) ${outsideMatrix.join(", ")}, which are not in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each matrix host and record each result.`,
134
+ };
135
+ }
136
+ return {
137
+ required: true,
138
+ missingHosts,
139
+ reason: `Dogfood evidence is missing for host(s) ${missingHosts.join(", ")} in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each and record each result with record_dogfood.`,
140
+ };
141
+ }
142
+
143
+ // Satisfied. Report the newest contributing record, and keep the per-host
144
+ // set so the caller can journal every host that was actually covered.
145
+ const newest = matchedRecords.reduce<DogfoodResult | undefined>((best, record) => !best || record.timestamp > best.timestamp ? record : best, undefined);
146
+ return { required: true, missingHosts: [], matched: newest, matchedRecords, evidenceId: newest ? dogfoodEvidence(newest).id : undefined };
147
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolOutcome } from "./tool-outcomes.ts";
3
- import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
3
+ import type { DogfoodResult, EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
4
4
  import { isSameGitRepo } from "./project-config.ts";
5
5
 
6
6
  function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
@@ -19,6 +19,24 @@ export function reviewEvidence(result: ReviewResult): EvidenceRecord {
19
19
  return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
20
20
  }
21
21
 
22
+ export function dogfoodEvidence(result: DogfoodResult): EvidenceRecord {
23
+ const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.sessionID, actor: result.host.label };
24
+ // The digest pair is part of the source, so replaying a passing run onto a
25
+ // different scenario or taxonomy changes the evidence id.
26
+ const source = {
27
+ verdict: result.verdict,
28
+ scenarioId: result.scenarioId,
29
+ scenarioSha256: result.scenarioSha256,
30
+ taxonomySha256: result.taxonomySha256,
31
+ taxonomyClass: result.taxonomyClass,
32
+ checks: result.checks,
33
+ runCount: result.runCount,
34
+ host: result.host,
35
+ gitStatus: result.gitStatus,
36
+ };
37
+ return { id: evidenceId("dogfood", result.timestamp, subject, source), kind: "dogfood", confidence: "attestation", observedAt: result.timestamp, subject, source };
38
+ }
39
+
22
40
  export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
23
41
  const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
24
42
  const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { audit, loadVerifyCache } from "./audit.ts";
4
4
  import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
5
  import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
6
+ import { evaluateDogfoodGate } from "./dogfood.ts";
6
7
  import {
7
8
  getLastReviewResultForWorkspace,
8
9
  getLiveControlPlaneRoots,
@@ -153,6 +154,17 @@ function logBlock(message: string, simulate = false): void {
153
154
  } catch {}
154
155
  }
155
156
 
157
+ /**
158
+ * Render a verification timeout limit for the block reason.
159
+ *
160
+ * Sub-second limits are exercised by tests, and a bare `Math.round(ms/1000)`
161
+ * reports those as "0s", which reads as no limit at all.
162
+ */
163
+ function formatTimeoutLimit(timeoutMs: number | undefined): string {
164
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return "the verification timeout";
165
+ return timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
166
+ }
167
+
156
168
  export async function guardToolCallImpl(
157
169
  toolName: string,
158
170
  input: unknown,
@@ -256,10 +268,20 @@ export async function guardToolCallImpl(
256
268
  const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
257
269
  recordVerifyResult(command, result, sessionID, currentRoot);
258
270
  if (!result.passed) {
259
- const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
260
- const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
271
+ // A timeout is not a failing test. Both block
272
+ // finalization, but naming a failure sends the
273
+ // agent chasing an assertion that never ran, and
274
+ // the remedy is a longer limit rather than a code
275
+ // change. The tail is snipped as passing output on
276
+ // that path, because snipVerifyOutput labels a
277
+ // failing run with a "Verification Failed" header
278
+ // that would restate the misattribution.
279
+ const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
280
+ const reason = result.timedOut
281
+ ? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
282
+ : `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
261
283
  logPolicyBlock(`[workflow-guard] ${reason}`);
262
- return block("verification", "verification_failed", reason);
284
+ return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
263
285
  }
264
286
  }
265
287
  }
@@ -665,6 +687,35 @@ export async function guardToolCallImpl(
665
687
  }
666
688
  }
667
689
  if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
690
+ // Dogfood gate (pre-deployment evidence), off by default. The
691
+ // evaluation lives in lib/dogfood.ts so preflight and guard_status
692
+ // cannot disagree about whether the gate is satisfied.
693
+ const dogfoodGate = evaluateDogfoodGate(prRoot);
694
+ if (dogfoodGate.matched && !context?.simulate) {
695
+ audit({
696
+ ts: new Date().toISOString(),
697
+ sessionID: context?.sessionID,
698
+ tool: "pr-preflight.dogfood-binding",
699
+ decision: "allow",
700
+ phase: "event",
701
+ reason: "dogfood_evidence_matched",
702
+ evidence: {
703
+ evidenceId: dogfoodGate.evidenceId,
704
+ dogfoodScenarioId: dogfoodGate.matched.scenarioId,
705
+ dogfoodHost: dogfoodGate.matched.host.label,
706
+ // Journal every host the matrix required, not just the
707
+ // newest one. A single-host entry would understate a
708
+ // multi-host pass and mislead an auditor later.
709
+ dogfoodCoveredHosts: (dogfoodGate.matchedRecords ?? []).map((record) => record.host.label),
710
+ binding: {
711
+ workspace: projectRootKey(prRoot),
712
+ commitHash: getCurrentGitCommitHash(prRoot),
713
+ worktreeFingerprint: getTrackedWorktreeFingerprint(prRoot)?.slice(0, 16),
714
+ },
715
+ },
716
+ });
717
+ }
718
+ if (dogfoodGate.missingHosts.length > 0 && dogfoodGate.reason) preflightFailures.push(dogfoodGate.reason);
668
719
  const branchChangelog = branchHasChangelogChange(prRoot);
669
720
  let shellCwdKnown = true;
670
721
  const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
package/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, unlinkSync } from "node:fs";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { audit, getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
4
+ import { audit, getDogfoodCacheFilePath, getReviewCacheFilePath, loadDogfoodCache, loadReviewCache, persistDogfoodCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
7
7
  import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectConfig,
11
11
  VerifyResult,
12
12
  ReviewResult,
13
+ DogfoodResult,
13
14
  } from "./types.ts";
14
15
 
15
16
  export let workspaceRoot = process.cwd();
@@ -454,6 +455,177 @@ export function resetReviewState(): void {
454
455
  } catch {}
455
456
  }
456
457
 
458
+ let dogfoodRecords: DogfoodResult[] = [];
459
+ /**
460
+ * Retention per workspace and host. This bounds in-memory and merged history so
461
+ * one busy key cannot grow without limit. Only the newest record per key is ever
462
+ * durable, so this cap never decides what survives a restart.
463
+ */
464
+ const MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST = 3;
465
+
466
+ /**
467
+ * Retention key. Workspace is part of the key because the durable cache is
468
+ * shared across repositories: keying on the host label alone would let a busy
469
+ * host in one repository evict another repository's record for the same label.
470
+ */
471
+ function dogfoodRetentionKey(record: DogfoodResult): string {
472
+ return `${record.workspace ?? ""}\u0000${record.host.label}`;
473
+ }
474
+
475
+ function retainNewestPerWorkspaceHost(records: DogfoodResult[]): DogfoodResult[] {
476
+ const byKey = new Map<string, DogfoodResult[]>();
477
+ for (const record of records) {
478
+ const key = dogfoodRetentionKey(record);
479
+ byKey.set(key, [...(byKey.get(key) ?? []), record]);
480
+ }
481
+ return [...byKey.values()].flatMap((keyRecords) =>
482
+ keyRecords
483
+ .sort((left, right) => right.timestamp - left.timestamp)
484
+ .slice(0, MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST),
485
+ );
486
+ }
487
+
488
+ /**
489
+ * Every dogfood record this process can see: memory plus the durable cache,
490
+ * deduplicated and retained. The eviction path and the read path must agree
491
+ * about which records exist, so both call this rather than the pieces.
492
+ */
493
+ function mergedDogfoodRecords(): DogfoodResult[] {
494
+ return retainNewestPerWorkspaceHost(dedupeDogfoodRecords([...dogfoodRecords, ...loadDogfoodCache()]));
495
+ }
496
+
497
+ /**
498
+ * The records that may be durable, given everything currently known.
499
+ *
500
+ * One per workspace and host: the newest. Persisting an older pass would
501
+ * resurrect it after a restart even when a newer HOLD already superseded it,
502
+ * because the HOLD itself is never written to disk. Keeping only the newest
503
+ * record per key means a HOLD tombstones that key by leaving nothing behind.
504
+ */
505
+ function durableDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
506
+ const newest = new Map<string, DogfoodResult>();
507
+ for (const record of records) {
508
+ const key = dogfoodRetentionKey(record);
509
+ const seen = newest.get(key);
510
+ // A same-millisecond tie must resolve the same way the gate resolves it,
511
+ // to the HOLD. Otherwise a pass could be persisted alongside a HOLD that
512
+ // supersedes it, and a restart would serve the pass.
513
+ if (!seen || record.timestamp > seen.timestamp || (record.timestamp === seen.timestamp && record.verdict === "hold")) {
514
+ newest.set(key, record);
515
+ }
516
+ }
517
+ return [...newest.values()].filter((record) => record.verdict === "pass");
518
+ }
519
+
520
+ /**
521
+ * Records a dogfood run for a workspace.
522
+ *
523
+ * A HOLD is kept in memory so its taxonomy class stays diagnosable, and it is
524
+ * never persisted: caching a failed run would let a later preflight read it as
525
+ * evidence. A HOLD does evict that host's previously cached passes, because a
526
+ * restart would otherwise drop the in-memory HOLD and serve the older pass.
527
+ *
528
+ * Only the newest record per workspace and host is durable, but records for
529
+ * different hosts accumulate, because a deploy matrix needs one pass per host.
530
+ */
531
+ export function recordDogfoodResult(result: Omit<DogfoodResult, "workspace" | "commitHash" | "gitStatus" | "worktreeFingerprint" | "timestamp"> & { workspace?: string }, targetSessionID?: string): DogfoodResult {
532
+ const root = projectRootKey(result.workspace ?? getWorkspaceRoot());
533
+ const stored: DogfoodResult = {
534
+ ...result,
535
+ timestamp: Date.now(),
536
+ workspace: root,
537
+ commitHash: getCurrentGitCommitHash(root),
538
+ gitStatus: getGitStatusSummary(root),
539
+ // Bind to tracked content only, matching review: the dogfood run
540
+ // covered the change, and untracked scratch files are not part of it.
541
+ worktreeFingerprint: getTrackedWorktreeFingerprint(root),
542
+ sessionID: targetSessionID,
543
+ };
544
+ dogfoodRecords = retainNewestPerWorkspaceHost([stored, ...dogfoodRecords]);
545
+ // Only the newest record per workspace and host may be durable. A HOLD is
546
+ // never written, so it tombstones its key by leaving nothing behind. That
547
+ // also stops a later PASS for a different host from re-persisting a pass
548
+ // that a newer HOLD already superseded: after a restart the HOLD is gone,
549
+ // and that resurrected pass would re-open the gate on a held host.
550
+ //
551
+ // The durable set comes from merged state, never memory alone: in a cold
552
+ // process memory holds only the record just made, so using memory alone
553
+ // would delete every other host's cached pass.
554
+ const durable = durableDogfoodRecords(mergedDogfoodRecords());
555
+ // A failed write here is not cosmetic: after a HOLD it leaves the stale pass
556
+ // on disk, and a restart would serve it as evidence. Journal the failure so
557
+ // the durable state disagreeing with memory is visible.
558
+ if (!persistDogfoodCache(durable)) {
559
+ audit({
560
+ ts: new Date().toISOString(),
561
+ sessionID: targetSessionID,
562
+ tool: "dogfood.cache",
563
+ decision: "allow",
564
+ phase: "event",
565
+ reason: "dogfood_cache_write_failed",
566
+ evidence: { dogfoodVerdict: stored.verdict === "hold" ? "hold" : "pass", dogfoodHost: stored.host.label },
567
+ });
568
+ }
569
+ return stored;
570
+ }
571
+
572
+ /**
573
+ * Collapses records that describe the same run, so merging memory with disk
574
+ * does not double count. Retention and the durable cache both count records,
575
+ * so a duplicate would let itself evict a sibling host's only pass.
576
+ */
577
+ function dedupeDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
578
+ const seen = new Set<string>();
579
+ const unique: DogfoodResult[] = [];
580
+ for (const record of records) {
581
+ // Workspace is part of the identity: the cache is shared across
582
+ // repositories, so two runs of the same scenario on the same host label
583
+ // in different workspaces are distinct evidence. Verdict is part of it
584
+ // too, so a PASS and a HOLD recorded in the same millisecond stay
585
+ // distinct and the HOLD can win.
586
+ const identity = `${record.workspace ?? ""}\u0000${record.host.label}\u0000${record.scenarioId}\u0000${record.scenarioSha256}\u0000${record.taxonomyClass ?? ""}\u0000${record.verdict}\u0000${record.timestamp}\u0000${record.commitHash ?? ""}`;
587
+ if (seen.has(identity)) continue;
588
+ seen.add(identity);
589
+ unique.push(record);
590
+ }
591
+ return unique;
592
+ }
593
+
594
+ /**
595
+ * Candidate dogfood records for one worktree.
596
+ *
597
+ * Memory and disk are merged, so a pass recorded in a previous process is
598
+ * still a candidate next to one recorded now.
599
+ *
600
+ * Exact-worktree candidates win over same-repository ones, so a sibling
601
+ * worktree's run cannot displace this worktree's evidence. Same-repository
602
+ * candidates are returned when this worktree has none, which is what lets a
603
+ * run in a linked worktree satisfy the parent when the tracked content matches.
604
+ * Freshness is judged per record by the caller, never here.
605
+ */
606
+ export function getDogfoodRecordsForWorkspace(root: string): DogfoodResult[] {
607
+ const workspace = projectRootKey(root);
608
+ const all = mergedDogfoodRecords().filter(
609
+ (result): result is DogfoodResult & { workspace: string } => result?.workspace != null,
610
+ );
611
+ const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
612
+ if (exact.length > 0) return exact;
613
+ return all.filter((result) => isSameGitRepo(result.workspace, root));
614
+ }
615
+
616
+ export function getLastDogfoodResultForWorkspace(root: string): DogfoodResult | undefined {
617
+ const records = getDogfoodRecordsForWorkspace(root);
618
+ return records.reduce<DogfoodResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
619
+ }
620
+
621
+ export function resetDogfoodState(): void {
622
+ dogfoodRecords = [];
623
+ try {
624
+ const p = getDogfoodCacheFilePath();
625
+ if (existsSync(p)) unlinkSync(p);
626
+ } catch {}
627
+ }
628
+
457
629
  export async function resolveEffectiveWorkspace(options: {
458
630
  sessionID?: string;
459
631
  directory?: string;
@@ -543,6 +715,54 @@ export function isDocumentationRequired(root: string): boolean {
543
715
  return cfg.requireDocumentation === true;
544
716
  }
545
717
 
718
+ /**
719
+ * Dogfood gate (pre-deployment evidence). Off by default, per the rule that
720
+ * every write path ships opt-in first: a repository opts in only once its
721
+ * runner exists, and an unset flag keeps today's PR behavior.
722
+ */
723
+ export function isDogfoodRequired(root: string): boolean {
724
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD?.toLowerCase();
725
+ if (env === "0" || env === "false" || env === "off") return false;
726
+ if (env === "1" || env === "true" || env === "on") return true;
727
+ return getProjectConfig(root).requireDogfood === true;
728
+ }
729
+
730
+ /**
731
+ * Host labels that must each carry a passing dogfood record. Unset means the
732
+ * recorded host alone satisfies the gate, so a single-machine setup needs no
733
+ * matrix. Blank and duplicate labels are dropped; a non-array value is
734
+ * ignored rather than throwing, matching the other config readers.
735
+ */
736
+ export function getDogfoodDeployMatrix(root: string): string[] {
737
+ const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX;
738
+ const raw = env !== undefined && env.trim() !== "" ? env.split(",") : getProjectConfig(root).dogfoodDeployMatrix;
739
+ if (!Array.isArray(raw)) return [];
740
+ return [...new Set(raw.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean))];
741
+ }
742
+
743
+ /**
744
+ * Dogfood recursion depth for this process. The runner sets
745
+ * `WORKFLOW_GUARD_DOGFOOD_DEPTH` on the actor it spawns, so the marker is
746
+ * inherited rather than handshaken. Unparseable or negative values fail
747
+ * closed to depth 1, because a corrupt marker must not read as a clean root.
748
+ */
749
+ export function getDogfoodDepth(): number {
750
+ const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH;
751
+ if (env === undefined || env.trim() === "") return 0;
752
+ const parsed = Number(env.trim());
753
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
754
+ }
755
+
756
+ /**
757
+ * True when this process is a dogfood actor, that is, a session spawned by the
758
+ * runner to be tested. Every policy still applies; the marker only refuses
759
+ * self-certification and stops dogfood evidence from being persisted.
760
+ */
761
+ export function isDogfoodActor(): boolean {
762
+ if (process.env.WORKFLOW_GUARD_DOGFOOD_ACTOR === "1") return true;
763
+ return getDogfoodDepth() > 0;
764
+ }
765
+
546
766
  /**
547
767
  * Policy 25 (live-documentation research floor): enabled by default. The env
548
768
  * override mirrors `isReviewRequired`; project config can opt out. The floor
package/src/lib/types.ts CHANGED
@@ -50,6 +50,19 @@ export interface ProjectConfig {
50
50
  */
51
51
  requireSubagentReview?: boolean;
52
52
  requireDocumentation?: boolean;
53
+ /**
54
+ * Dogfood gate (pre-deployment evidence). Off by default: a repository
55
+ * opts in once its runner exists. When true, PR preflight requires a fresh
56
+ * passing dogfood record bound to the tree the PR would publish.
57
+ */
58
+ requireDogfood?: boolean;
59
+ /**
60
+ * Host labels that must each carry a fresh passing dogfood record before a
61
+ * PR. Unset means the recorded host alone satisfies the gate. A matrix is
62
+ * a configuration fact, not a code path: a Linux pass never vouches for
63
+ * Windows.
64
+ */
65
+ dogfoodDeployMatrix?: string[];
53
66
  /**
54
67
  * Policy 25 (live-documentation research floor). Enabled by default: after
55
68
  * `liveDocFailureThreshold` failed tool calls with no live-documentation
@@ -120,9 +133,53 @@ export interface ReviewResult {
120
133
 
121
134
  export type EvidenceConfidence = "deterministic_observation" | "attestation" | "derived_state" | "agent_assertion";
122
135
 
136
+ export type DogfoodVerdict = "pass" | "hold";
137
+
138
+ /**
139
+ * One deterministic check row. `observed` is what the runner actually saw and
140
+ * `expected` is what the scenario declared, so a HOLD is diagnosable without
141
+ * re-running the scenario. `detail` carries the failing artifact excerpt.
142
+ */
143
+ export interface DogfoodCheck {
144
+ id: string;
145
+ status: "pass" | "hold" | "skip";
146
+ expected?: string;
147
+ observed?: string;
148
+ detail?: string;
149
+ }
150
+
151
+ export interface DogfoodHost {
152
+ /** Stable per-machine label the deploy matrix is written against. */
153
+ label: string;
154
+ os?: string;
155
+ arch?: string;
156
+ opencodeVersion?: string;
157
+ actorModel?: string;
158
+ judgeModel?: string;
159
+ /** Hash of the staged harness tree the actor actually ran against. */
160
+ stagedTreeHash?: string;
161
+ }
162
+
163
+ export interface DogfoodResult {
164
+ verdict: DogfoodVerdict;
165
+ scenarioId: string;
166
+ scenarioSha256: string;
167
+ taxonomySha256: string;
168
+ taxonomyClass?: string;
169
+ checks: DogfoodCheck[];
170
+ runCount: number;
171
+ host: DogfoodHost;
172
+ timestamp: number;
173
+ workspace?: string;
174
+ commitHash?: string;
175
+ gitStatus?: string;
176
+ worktreeFingerprint?: string;
177
+ sessionID?: string;
178
+ }
179
+
123
180
  export interface EvidenceRecord {
124
181
  id: string;
125
- kind: "verification" | "review" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
182
+ kind: "verification" | "review" | "dogfood" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
126
183
  confidence: EvidenceConfidence;
127
184
  observedAt: number;
128
185
  subject: {
@@ -167,6 +224,12 @@ export interface AuditEntry {
167
224
  };
168
225
  allowLive?: boolean;
169
226
  reviewVerdict?: "approved" | "changes_requested" | "rejected";
227
+ /** Dogfood verdicts: pass/hold are recorded, rejected is refused. */
228
+ dogfoodVerdict?: "pass" | "hold" | "rejected";
229
+ dogfoodScenarioId?: string;
230
+ dogfoodHost?: string;
231
+ /** Every host a satisfied deploy matrix covered, not just the newest. */
232
+ dogfoodCoveredHosts?: string[];
170
233
  evidenceId?: string;
171
234
  reviewer?: string;
172
235
  reviewTimestamp?: number;
@@ -41,7 +41,9 @@ import {
41
41
  isLiveDocsRequired,
42
42
  getLiveDocFailureThreshold,
43
43
  getResearchToolPatterns,
44
+ isDogfoodRequired,
44
45
  } from "./state.ts";
46
+
45
47
  import {
46
48
  openProjectMemory,
47
49
  ensureProjectMemoryExcluded,
@@ -72,7 +74,7 @@ import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
72
74
  import { loadedPluginVersion } from "./version.ts";
73
75
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
74
76
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
75
- import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
77
+ import { buildCompactionContext, evaluateDogfoodGate, guardToolCall } from "../workflow-guard.ts";
76
78
  import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
77
79
  import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
78
80
  import { evaluatePermissionSeat } from "./permission-seat.ts";
@@ -459,6 +461,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
459
461
  projectMemoryEnabled,
460
462
  learningEnabled,
461
463
  recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
464
+ dogfoodRequired: isDogfoodRequired(effectiveRoot),
462
465
  isReadOnly,
463
466
  });
464
467
  event.system.push({ type: "text", text: guidance });
@@ -518,6 +521,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
518
521
  `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
519
522
  );
520
523
  }
524
+ const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
525
+ if (dogfoodCtxGate.required) {
526
+ stateLines.push(
527
+ dogfoodCtxGate.missingHosts.length === 0
528
+ ? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
529
+ : `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
530
+ );
531
+ }
521
532
  let openFollowups: ReturnType<typeof listReviewFollowups> = [];
522
533
  try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
523
534
  if (openFollowups.length > 0) {