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.
- package/README.md +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +21 -0
- package/docs/testing.md +2 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +54 -3
- package/src/lib/state.ts +221 -1
- package/src/lib/types.ts +64 -1
- package/src/lib/v2-plugin.ts +12 -1
- package/src/lib/verify.ts +18 -1
- package/src/workflow-guard.ts +29 -2
- package/test/test.mts +686 -0
|
@@ -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
|
+
}
|
package/src/lib/evidence.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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;
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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) {
|