opencode-workflow-guard 1.18.1 → 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.
@@ -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,
@@ -686,6 +687,35 @@ export async function guardToolCallImpl(
686
687
  }
687
688
  }
688
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);
689
719
  const branchChangelog = branchHasChangelogChange(prRoot);
690
720
  let shellCwdKnown = true;
691
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) {
@@ -24,9 +24,11 @@ import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
24
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
25
25
  import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
26
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
27
+ import { evaluateDogfoodGate } from "./lib/dogfood.ts";
27
28
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
28
29
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
29
30
  export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
31
+ export { evaluateDogfoodGate } from "./lib/dogfood.ts";
30
32
 
31
33
  // ── Types ────────────────────────────────────────────────────────────────────
32
34
  export type {
@@ -47,7 +49,7 @@ export type {
47
49
  LearningOpportunity,
48
50
  } from "./lib/types.ts";
49
51
  export type { TodoSdkClient };
50
- export { verificationEvidence, reviewEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
52
+ export { verificationEvidence, reviewEvidence, dogfoodEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
51
53
  export { getRalphOutcome } from "./policies/continuation.ts";
52
54
 
53
55
  // ── State & runtime context ──────────────────────────────────────────────────
@@ -76,6 +78,10 @@ import {
76
78
  isReviewRequired,
77
79
  isSubagentReviewRequired,
78
80
  isDocumentationRequired,
81
+ isDogfoodRequired,
82
+ getDogfoodDeployMatrix,
83
+ getDogfoodDepth,
84
+ isDogfoodActor,
79
85
  isLiveDocsRequired,
80
86
  getLiveDocFailureThreshold,
81
87
  getResearchToolPatterns,
@@ -88,6 +94,10 @@ import {
88
94
  getLastReviewResultForWorkspace,
89
95
  resetReviewState,
90
96
  sessionReviews,
97
+ recordDogfoodResult,
98
+ getLastDogfoodResultForWorkspace,
99
+ getDogfoodRecordsForWorkspace,
100
+ resetDogfoodState,
91
101
  setSessionWorkspace,
92
102
  getSessionWorkspace,
93
103
  getSessionBoundWorkspace,
@@ -117,6 +127,10 @@ export {
117
127
  isReviewRequired,
118
128
  isSubagentReviewRequired,
119
129
  isDocumentationRequired,
130
+ isDogfoodRequired,
131
+ getDogfoodDeployMatrix,
132
+ getDogfoodDepth,
133
+ isDogfoodActor,
120
134
  isLiveDocsRequired,
121
135
  getLiveDocFailureThreshold,
122
136
  getResearchToolPatterns,
@@ -127,6 +141,10 @@ export {
127
141
  getLastReviewResult,
128
142
  getLastReviewResultForWorkspace,
129
143
  resetReviewState,
144
+ recordDogfoodResult,
145
+ getLastDogfoodResultForWorkspace,
146
+ getDogfoodRecordsForWorkspace,
147
+ resetDogfoodState,
130
148
  setSessionWorkspace,
131
149
  getSessionWorkspace,
132
150
  getSessionBoundWorkspace,
@@ -194,7 +212,7 @@ import {
194
212
  summarizeInput,
195
213
  } from "./lib/audit.ts";
196
214
 
197
- export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
215
+ export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
198
216
  export { summarizeInput };
199
217
 
200
218
  export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
@@ -694,6 +712,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
694
712
  `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
695
713
  );
696
714
  }
715
+ const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
716
+ if (dogfoodCtxGate.required) {
717
+ stateLines.push(
718
+ dogfoodCtxGate.missingHosts.length === 0
719
+ ? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
720
+ : `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
721
+ );
722
+ }
697
723
  let openFollowups: ReturnType<typeof listReviewFollowups> = [];
698
724
  try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
699
725
  if (openFollowups.length > 0) {
@@ -838,6 +864,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
838
864
  projectMemoryEnabled,
839
865
  learningEnabled,
840
866
  recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
867
+ dogfoodRequired: isDogfoodRequired(effectiveRoot),
841
868
  isReadOnly,
842
869
  });
843
870
  output.system.push(guidance);