@agilno-tech/rivet 0.1.0-alpha.0 → 0.1.0-alpha.2

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,187 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { types } from 'node:util';
3
+ import { containsSecretMaterial } from '../clients/contract.js';
4
+
5
+ const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
+ const DIGEST = /^[a-f0-9]{64}$/;
7
+ const INPUT = ['id', 'tier', 'title', 'options', 'choice', 'rationale', 'approvedPlanDecisionId'];
8
+ const RECORD = ['schemaVersion', ...INPUT, 'runId', 'requestDigest', 'planDigest', 'actor', 'createdAt', 'status', 'approval', 'digest'];
9
+ const BINDINGS = ['runId', 'requestDigest', 'planDigest'];
10
+ const CONTEXT = [...BINDINGS, 'actor', 'now', 'approvedPlanDecisions', 'secretValues', 'humanConfirmed'];
11
+
12
+ export class DecisionError extends Error {
13
+ constructor(reason = 'invalid-decision') {
14
+ super({
15
+ 'invalid-decision': 'Decision data is invalid.',
16
+ 'secret-material': 'Decision data contains sensitive material. Remove credentials before recording it.',
17
+ 'binding-mismatch': 'Decision does not match the current task and approved plan.',
18
+ 'digest-mismatch': 'Decision content changed after it was recorded.',
19
+ 'plan-reference-required': 'Tier 0 requires a matching decision reference from the approved plan.',
20
+ 'human-confirmation-required': 'This decision requires explicit human confirmation.',
21
+ 'approval-conflict': 'Only a pending decision can be approved. Read the current decision before retrying.',
22
+ }[reason] ?? 'Decision data is invalid.');
23
+ this.name = 'DecisionError';
24
+ this.code = 'ERR_INVALID_DECISION';
25
+ this.safeMessage = this.message;
26
+ this.details = Object.freeze({ reason });
27
+ }
28
+ }
29
+ const fail = reason => { throw new DecisionError(reason); };
30
+
31
+ function record(value, allowed, required = allowed) {
32
+ if (!value || typeof value !== 'object' || types.isProxy(value) || Array.isArray(value)
33
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
34
+ const keys = Reflect.ownKeys(value);
35
+ if (keys.length > allowed.length || keys.some(key => typeof key !== 'string' || !allowed.includes(key))) fail();
36
+ const copy = {};
37
+ for (const key of keys) {
38
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
39
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) fail();
40
+ Object.defineProperty(copy, key, { value: descriptor.value, enumerable: true });
41
+ }
42
+ if (required.some(key => !Object.hasOwn(copy, key))) fail();
43
+ return copy;
44
+ }
45
+ function array(value, max, min = 0) {
46
+ if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) fail();
47
+ const length = Object.getOwnPropertyDescriptor(value, 'length')?.value;
48
+ if (!Number.isInteger(length) || length < min || length > max) fail();
49
+ const keys = Reflect.ownKeys(value);
50
+ if (keys.length !== length + 1) fail();
51
+ const result = [];
52
+ for (let index = 0; index < length; index++) {
53
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
54
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) fail();
55
+ result.push(descriptor.value);
56
+ }
57
+ return result;
58
+ }
59
+ function text(value, max, secrets) {
60
+ if (typeof value !== 'string' || value.length < 1 || value.length > max || value.trim() !== value
61
+ || /[\u0000-\u001f\u007f-\u009f\p{Cf}]/u.test(value)) fail();
62
+ if (containsSecretMaterial(value) || /\b(?:npm_|glpat-|hf_)[A-Za-z0-9_-]{16,}\b/.test(value) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)
63
+ || /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@/i.test(value)
64
+ || secrets.some(secret => value.includes(secret))) fail('secret-material');
65
+ return value;
66
+ }
67
+ function id(value, secrets = []) {
68
+ if (typeof value !== 'string' || value.length > 64 || !ID.test(value)) fail();
69
+ return text(value, 64, secrets);
70
+ }
71
+ function digest(value) { if (typeof value !== 'string' || !DIGEST.test(value)) fail(); return value; }
72
+ function instant(value) {
73
+ if (typeof value !== 'string' || value.length !== 24 || !Number.isFinite(Date.parse(value))
74
+ || new Date(value).toISOString() !== value) fail();
75
+ return value;
76
+ }
77
+ function actor(value, secrets) {
78
+ const result = record(value, ['kind', 'id']);
79
+ if (!['human', 'agent'].includes(result.kind)) fail();
80
+ return { kind: result.kind, id: text(result.id, 128, secrets) };
81
+ }
82
+ function context(value, mode = 'validate') {
83
+ const result = record(value, CONTEXT, mode === 'validate' ? BINDINGS : [...BINDINGS, 'actor', 'now']);
84
+ id(result.runId); digest(result.requestDigest); digest(result.planDigest);
85
+ const secrets = result.secretValues === undefined ? [] : array(result.secretValues, 64).map(secret => {
86
+ if (typeof secret !== 'string' || !secret || secret.length > 4096) fail();
87
+ return secret;
88
+ });
89
+ const approved = result.approvedPlanDecisions === undefined ? [] : array(result.approvedPlanDecisions, 128).map(item => {
90
+ const reference = record(item, ['id', 'choice']);
91
+ return { id: id(reference.id), choice: id(reference.choice) };
92
+ });
93
+ if (new Set(approved.map(item => item.id)).size !== approved.length) fail();
94
+ return { ...result, secretValues: secrets, approvedPlanDecisions: approved,
95
+ ...(result.actor === undefined ? {} : { actor: actor(result.actor, secrets) }),
96
+ ...(result.now === undefined ? {} : { now: instant(result.now) }),
97
+ };
98
+ }
99
+ function input(value, ctx, stored = false) {
100
+ const source = record(value, INPUT, stored ? INPUT : INPUT.filter(key => key !== 'approvedPlanDecisionId'));
101
+ if (!Number.isInteger(source.tier) || source.tier < 0 || source.tier > 3) fail();
102
+ const options = array(source.options, 8, 2).map(option => {
103
+ const candidate = record(option, ['id', 'description']);
104
+ return { id: id(candidate.id, ctx.secretValues), description: text(candidate.description, 1000, ctx.secretValues) };
105
+ });
106
+ if (new Set(options.map(option => option.id)).size !== options.length || !options.some(option => option.id === source.choice)) fail();
107
+ if (Object.hasOwn(source, 'approvedPlanDecisionId') && source.approvedPlanDecisionId !== null && typeof source.approvedPlanDecisionId !== 'string') fail();
108
+ const reference = source.approvedPlanDecisionId ?? null;
109
+ if (source.tier === 0) {
110
+ if (!reference || !ctx.approvedPlanDecisions.some(item => item.id === reference && item.choice === source.choice)) fail('plan-reference-required');
111
+ id(reference, ctx.secretValues);
112
+ } else if (reference !== null) fail();
113
+ return { id: id(source.id, ctx.secretValues), tier: source.tier, title: text(source.title, 160, ctx.secretValues), options,
114
+ choice: id(source.choice, ctx.secretValues), rationale: text(source.rationale, 4000, ctx.secretValues), approvedPlanDecisionId: reference };
115
+ }
116
+ function canonical(value) {
117
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
118
+ if (value !== null && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
119
+ return JSON.stringify(value);
120
+ }
121
+ function hash(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
122
+ function freeze(value) {
123
+ if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
124
+ return value;
125
+ }
126
+ function sealed(value) { return freeze({ ...value, digest: hash(value) }); }
127
+
128
+ export function createDecision(value, suppliedContext) {
129
+ const ctx = context(suppliedContext, 'create');
130
+ const fields = input(value, ctx);
131
+ return sealed({ schemaVersion: 1, ...fields, runId: ctx.runId, requestDigest: ctx.requestDigest,
132
+ planDigest: ctx.planDigest, actor: ctx.actor, createdAt: ctx.now,
133
+ status: fields.tier < 2 ? 'recorded' : 'pending', approval: null });
134
+ }
135
+
136
+ export function validateDecision(value, suppliedContext) {
137
+ const ctx = context(suppliedContext);
138
+ const source = record(value, RECORD);
139
+ if (source.schemaVersion !== 1) fail();
140
+ for (const key of BINDINGS) if (source[key] !== ctx[key]) fail('binding-mismatch');
141
+ const fields = input(Object.fromEntries(INPUT.map(key => [key, source[key]])), ctx, true);
142
+ if (!['recorded', 'pending', 'approved'].includes(source.status)
143
+ || (fields.tier < 2 ? source.status !== 'recorded' : source.status === 'recorded')) fail();
144
+ const result = { schemaVersion: 1, ...fields, runId: ctx.runId, requestDigest: ctx.requestDigest,
145
+ planDigest: ctx.planDigest, actor: actor(source.actor, ctx.secretValues), createdAt: instant(source.createdAt),
146
+ status: source.status, approval: null };
147
+ if (source.status === 'approved') {
148
+ const approval = record(source.approval, ['actor', 'approvedAt', 'decisionDigest']);
149
+ const approvedBy = actor(approval.actor, ctx.secretValues);
150
+ if (approvedBy.kind !== 'human' || instant(approval.approvedAt) < result.createdAt) fail();
151
+ if (digest(approval.decisionDigest) !== hash({ ...result, status: 'pending' })) fail('digest-mismatch');
152
+ result.approval = { actor: approvedBy, approvedAt: approval.approvedAt, decisionDigest: approval.decisionDigest };
153
+ } else if (source.approval !== null) fail();
154
+ if (digest(source.digest) !== hash(result)) fail('digest-mismatch');
155
+ return sealed(result);
156
+ }
157
+
158
+ export function approveDecision(value, suppliedContext) {
159
+ const ctx = context(suppliedContext, 'approve');
160
+ if (ctx.humanConfirmed !== true || ctx.actor.kind !== 'human') fail('human-confirmation-required');
161
+ const original = validateDecision(value, ctx);
162
+ if (original.status !== 'pending') fail('approval-conflict');
163
+ if (ctx.now < original.createdAt) fail();
164
+ const { digest: previousDigest, ...fields } = original;
165
+ return sealed({ ...fields, status: 'approved', approval: { actor: ctx.actor, approvedAt: ctx.now, decisionDigest: previousDigest } });
166
+ }
167
+
168
+ function markdown(value) {
169
+ // Encode Markdown punctuation and HTML to prevent links, mentions and injected structure.
170
+ return value.replace(/[&<>"'`*_{}[\]()#+.!|\\@~-]/g, character => `&#${character.codePointAt(0)};`);
171
+ }
172
+ export function renderDecisions(values, suppliedContext) {
173
+ const ctx = context(suppliedContext);
174
+ const decisions = array(values, 128).map(value => validateDecision(value, ctx));
175
+ if (new Set(decisions.map(item => item.id)).size !== decisions.length) fail();
176
+ const lines = ['## Decisions', '', 'Decision records do not grant execution, file access, merge, deployment, or other permissions.'];
177
+ if (!decisions.length) return `${lines.join('\n')}\n\nNo decisions recorded.\n`;
178
+ for (const decision of decisions) {
179
+ lines.push('', `### ${markdown(decision.title)}`, '', `Tier ${decision.tier} · ${decision.status}`,
180
+ `Decision: ${markdown(decision.id)}`, `Choice: ${markdown(decision.choice)}`, `Rationale: ${markdown(decision.rationale)}`,
181
+ `Recorded by: ${markdown(decision.actor.id)} (${decision.actor.kind}) at ${decision.createdAt}`, '', 'Options:');
182
+ for (const option of decision.options) lines.push(`- ${markdown(option.id)}: ${markdown(option.description)}`);
183
+ if (decision.approvedPlanDecisionId) lines.push('', `Approved plan reference: ${markdown(decision.approvedPlanDecisionId)}`);
184
+ if (decision.approval) lines.push('', `Confirmed by: ${markdown(decision.approval.actor.id)} at ${decision.approval.approvedAt}`);
185
+ }
186
+ return `${lines.join('\n')}\n`;
187
+ }
@@ -0,0 +1,203 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {isDeepStrictEqual} from 'node:util';
3
+ import {immutableJson,containsSecretMaterial} from '../clients/contract.js';
4
+ import {loadProjectConfig,parseYaml} from '../config/load.js';
5
+ import {createSnapshotStore,readSnapshotWithoutLock} from '../state/snapshot-store.js';
6
+ import {governancePaths,resolveExistingFeatureRunPaths,acceptedIntegrationPaths} from '../state/paths.js';
7
+ import {createFeatureRunStore} from './run-store.js';
8
+ import {createAcceptedIntegrationStore} from './accepted-integration.js';
9
+ import {acquireHostRunLock} from './host-run-lock.js';
10
+ import {createDecision,validateDecision,approveDecision,renderDecisions} from './decisions.js';
11
+ import {normalizeReviewPolicy,evaluateReviewReports} from '../quality/content-review.js';
12
+ import {scanChangedContent,validateContentScanPolicy} from '../quality/content-scan.js';
13
+
14
+ export class GovernanceError extends Error {
15
+ constructor(message='Task decision or review evidence is invalid or stale.') {
16
+ super(message);this.name='GovernanceError';this.code='ERR_TASK_GOVERNANCE';this.safeMessage=message;
17
+ }
18
+ }
19
+ const fail=message=>{throw new GovernanceError(message);};
20
+ const digest=value=>createHash('sha256').update(typeof value==='string'?value:JSON.stringify(value)).digest('hex');
21
+ const binding=run=>({runId:run.runId,requestDigest:run.workRequest.digest,planDigest:run.proposalDigest});
22
+ const sameBinding=(left,right)=>Object.entries(right).every(([key,value])=>left[key]===value);
23
+ const approved=decisions=>decisions.filter(value=>value.status==='approved').map(({id,choice})=>({id,choice}));
24
+
25
+ async function journal(paths,run) {
26
+ const selected=await governancePaths(paths), snapshot=await readSnapshotWithoutLock(selected);
27
+ const events=snapshot?.data?.events??[];
28
+ if(snapshot&&(snapshot.data.schemaVersion!==1||!sameBinding(snapshot.data,binding(run))
29
+ ||!Array.isArray(events)||events.length>1000||Buffer.byteLength(JSON.stringify(snapshot.data))>1024*1024))fail();
30
+ const decisions=new Map(),reviews=[];
31
+ for(const event of events){
32
+ if(!event||!['decision.recorded','decision.approved','review.recorded','scan.recorded'].includes(event.type))fail();
33
+ if(event.type.startsWith('decision.')){
34
+ const context={...binding(run),approvedPlanDecisions:approved([...decisions.values()])};
35
+ const value=validateDecision(event.data,context),prior=decisions.get(value.id);
36
+ if(event.type==='decision.recorded' ? prior!==undefined : !prior||prior.status!=='pending'||value.status!=='approved')fail();
37
+ if(prior&&value.approval?.decisionDigest!==prior.digest)fail();
38
+ decisions.set(value.id,value);
39
+ }else if(event.type==='review.recorded'){
40
+ if(!sameBinding(event.subject,binding(run)))fail();
41
+ evaluateReviewReports(event.policy,event.subject,[event.data]);reviews.push(event);
42
+ }
43
+ }
44
+ return {paths:selected,version:snapshot?.version??0,events,decisions:[...decisions.values()],reviews};
45
+ }
46
+ async function append(state,run,event){
47
+ const data={schemaVersion:1,...binding(run),events:[...state.events,event]};
48
+ if(data.events.length>1000||Buffer.byteLength(JSON.stringify(data))>1024*1024)fail('Task evidence storage limit reached. Start a new bounded task.');
49
+ await createSnapshotStore(state.paths,{environment:{}}).write(data,{expectedVersion:state.version});
50
+ }
51
+ async function inputs(project,runId){
52
+ const paths=await resolveExistingFeatureRunPaths(project,runId);if(!paths)fail('Task was not found.');
53
+ const run=await createFeatureRunStore(paths).readOnly();if(!run)fail();
54
+ return {paths,run};
55
+ }
56
+
57
+ export async function assertGovernancePolicy({project,baselineCommit,gitClient,config}) {
58
+ const baselineQuality=await gitClient.readCommittedFile(project,baselineCommit,'.rivet/quality.yaml');
59
+ if(!baselineQuality){
60
+ if(config.quality.review!==undefined||config.quality.contentScan!==undefined)
61
+ fail('Commit the quality policy before proposing a task with review or content scanning.');
62
+ return; // Preserve existing local-only configuration when governance is not enabled.
63
+ }
64
+ if(!isDeepStrictEqual(parseYaml(baselineQuality.content,'quality.yaml'),config.quality))
65
+ fail('Quality policy changed since the task was proposed. Restore the approved policy or propose a new task.');
66
+ }
67
+
68
+ export async function inspectGovernance({project,run,gitClient,config,phase,checkout,forceReview=false}) {
69
+ const paths=await resolveExistingFeatureRunPaths(project,run.runId);if(!paths)fail();
70
+ const state=await journal(paths,run);
71
+ const configured=config??await loadProjectConfig(project);
72
+ await assertGovernancePolicy({project,baselineCommit:run.featurePlan.baselineCommit,gitClient,config:configured});
73
+ const policy=normalizeReviewPolicy(forceReview?{...configured.quality.review,required:true}:configured.quality.review);
74
+ const selectedPhase=phase??(run.status==='proposed'?'plan':'final');
75
+ if(!['plan','final'].includes(selectedPhase))fail();
76
+ let target=checkout;
77
+ if(selectedPhase==='final'&&!target){
78
+ const accepted=await createAcceptedIntegrationStore(await acceptedIntegrationPaths(paths)).readOnly();
79
+ if(accepted)target={path:accepted.path,commitSha:accepted.commitSha,branch:accepted.branch};
80
+ }
81
+ const baseSha=run.featurePlan.baselineCommit;
82
+ let headSha=baseSha,patch='',changedPaths=[];
83
+ if(target){
84
+ const identity=await gitClient.inspectRepository(target.path);
85
+ if(identity.dirty||identity.headSha!==target.commitSha||identity.branch!==target.branch)fail('Review checkout changed. Inspect task status before continuing.');
86
+ headSha=target.commitSha;
87
+ changedPaths=await gitClient.changedPaths(target.path,baseSha,headSha);
88
+ if(policy.required||state.reviews.length)patch=(await gitClient.reviewDiff(target.path,{fromSha:baseSha,toSha:headSha})).patch;
89
+ }
90
+ const subject={phase:selectedPhase,...binding(run),baseSha,headSha,
91
+ diffDigest:digest(selectedPhase==='plan'?run.featurePlan:patch),
92
+ acceptanceCriteria:run.workRequest.acceptanceCriteria,
93
+ planNodeIds:run.featurePlan.nodes.filter(node=>node.role==='worker').map(node=>node.id),
94
+ criterionPlanNodeIds:run.workRequest.acceptanceCriteria.map(criterion=>run.featurePlan.nodes.filter(node=>node.role==='worker'&&node.acceptanceCriteria.includes(criterion)).map(node=>node.id)),changedPaths,
95
+ workerActorIds:['host-agent',...run.featurePlan.nodes.filter(node=>node.role==='worker').map(node=>node.id)]};
96
+ // Historical reports remain auditable; only reports for these exact inputs can satisfy a gate.
97
+ const phaseReviews=state.reviews.filter(event=>event.subject.phase===selectedPhase);
98
+ const reports=phaseReviews.filter(event=>event.subject.headSha===headSha
99
+ &&event.subject.diffDigest===subject.diffDigest).map(event=>event.data);
100
+ const evaluated=evaluateReviewReports(policy,subject,reports);
101
+ // Corrections consume the task phase's budget even when their new inputs invalidate old evidence.
102
+ const rounds=Math.max(0,...phaseReviews.map(event=>event.data.round));
103
+ const otherInputRound=Math.max(0,...phaseReviews.filter(event=>event.subject.headSha!==headSha
104
+ ||event.subject.diffDigest!==subject.diffDigest).map(event=>event.data.round));
105
+ const pendingAtCap=otherInputRound<policy.maxRounds?evaluated.requiredReviewerIds.filter(reviewerId=>
106
+ !reports.some(report=>report.reviewerId===reviewerId&&report.round===policy.maxRounds)):[];
107
+ const repairableAtCap=blocker=>['missing-review','blocking-review'].includes(blocker.code)
108
+ ?pendingAtCap.includes(blocker.reviewerId):blocker.code==='unreviewed-path'
109
+ &&pendingAtCap.some(reviewerId=>evaluated.reviewerPaths[reviewerId].includes(blocker.path));
110
+ const review={...evaluated,rounds,humanEscalation:!evaluated.valid&&rounds>=policy.maxRounds
111
+ &&!evaluated.blockers.every(repairableAtCap)};
112
+ const blockers=[...review.blockers];
113
+ if(selectedPhase==='final'&&!target&&policy.required)blockers.push({code:'integration-not-ready'});
114
+ for(const decision of state.decisions)if(decision.status==='pending')blockers.push({code:'decision-needs-human-approval',decisionId:decision.id});
115
+ let scan=null;
116
+ if(selectedPhase==='final'&&target&&configured.quality.contentScan!==undefined){
117
+ const scanPolicy=validateContentScanPolicy(configured.quality.contentScan);
118
+ if(changedPaths.length>scanPolicy.maxFiles)fail('Content scan file limit exceeded. Split the task or review its scan policy.');
119
+ const files=[];let bytes=0;
120
+ for(const path of changedPaths){
121
+ const file=await gitClient.readCommittedFile(target.path,headSha,path);
122
+ if(file){
123
+ bytes+=Buffer.byteLength(file.content);
124
+ if(bytes>scanPolicy.maxBytes)fail('Content scan byte limit exceeded. Split the task or review its scan policy.');
125
+ files.push({path,content:file.content});
126
+ }
127
+ }
128
+ scan=scanChangedContent({files,policy:configured.quality.contentScan});
129
+ if(scan.status!=='passed')blockers.push({code:'content-scan-failed'});
130
+ }
131
+ return immutableJson({subject,policy,decisions:state.decisions,decisionMarkdown:renderDecisions(state.decisions,{...binding(run),approvedPlanDecisions:approved(state.decisions)}),
132
+ review,scan,blockers,ready:blockers.length===0,patch,
133
+ nextAction:blockers.length?'Use rivet task decisions and rivet task review to inspect required evidence. Human approval never replaces failed required checks.':'Decision and content review requirements are satisfied.'});
134
+ }
135
+ export async function requireGovernance(input){
136
+ const result=await inspectGovernance(input);
137
+ if(result.scan){
138
+ const {paths}=await inputs(input.project,input.run.runId),state=await journal(paths,input.run);
139
+ const identity=digest({subject:result.subject,scan:result.scan});
140
+ if(!state.events.some(event=>event.type==='scan.recorded'&&event.digest===identity))
141
+ await append(state,input.run,{type:'scan.recorded',digest:identity,subject:result.subject,data:result.scan,checkedAt:new Date().toISOString()});
142
+ }
143
+ if(!result.ready)fail(`${result.nextAction} Blocking: ${result.blockers.map(item=>item.code).join(', ')}.`);
144
+ return result;
145
+ }
146
+
147
+ export async function taskGovernance({project,runId,gitClient,operation,input,phase,decisionId,confirm,signal,now=()=>new Date().toISOString()}){
148
+ const {paths}=await inputs(project,runId),lock=await acquireHostRunLock(paths);
149
+ try{
150
+ const run=await createFeatureRunStore(paths).readOnly(),state=await journal(paths,run);
151
+ if(['decide','approve-decision','review-submit'].includes(operation)&&['completed','cancelled'].includes(run.status))fail('Completed tasks cannot accept new decision or review evidence.');
152
+ const context={...binding(run),approvedPlanDecisions:approved(state.decisions)};
153
+ if(operation==='decisions')return {decisions:state.decisions,markdown:renderDecisions(state.decisions,context)};
154
+ signal?.throwIfAborted();
155
+ if(operation==='decide'){
156
+ if(state.decisions.length>=128)fail('Decision journal limit reached. Start a new bounded task.');
157
+ const record=createDecision(input,{...context,actor:{kind:'agent',id:'host-agent'},now:now()});
158
+ if(state.decisions.some(value=>value.id===record.id))fail('Decision ID already exists. Record a new decision that explains the change.');
159
+ await append(state,run,{type:'decision.recorded',data:record});return record;
160
+ }
161
+ if(operation==='approve-decision'){
162
+ const record=state.decisions.find(value=>value.id===decisionId);if(!record)fail('Decision was not found.');
163
+ if(typeof confirm!=='function'||await confirm(record)!==true)return {status:'declined'};
164
+ signal?.throwIfAborted();
165
+ const updated=approveDecision(record,{...context,actor:{kind:'human',id:'human-cli-operator'},now:now(),humanConfirmed:true});
166
+ await append(state,run,{type:'decision.approved',data:updated});return updated;
167
+ }
168
+ if(operation==='review'||operation==='review-submit'){
169
+ const config=await loadProjectConfig(project);
170
+ // Explicit review exports always include the actual diff, even for optional policies.
171
+ const observed=await inspectGovernance({project,run,gitClient,config,phase,forceReview:true});
172
+ if(operation==='review'){
173
+ const keys=['phase','runId','requestDigest','planDigest','baseSha','headSha','diffDigest'];
174
+ const identity=Object.fromEntries(keys.map(key=>[key,observed.subject[key]]));
175
+ const atCap=observed.review.rounds>=observed.policy.maxRounds;
176
+ const otherInputRound=Math.max(0,...state.reviews.filter(event=>event.subject.phase===observed.subject.phase
177
+ &&(event.subject.headSha!==observed.subject.headSha||event.subject.diffDigest!==observed.subject.diffDigest)).map(event=>event.data.round));
178
+ const templateReviewers=atCap&&observed.review.valid?[]:atCap?observed.review.requiredReviewerIds.filter(reviewerId=>
179
+ otherInputRound<observed.policy.maxRounds&&!observed.review.reports.some(report=>report.reviewerId===reviewerId&&report.round===observed.policy.maxRounds))
180
+ :observed.review.requiredReviewerIds;
181
+ const reportTemplates=templateReviewers.map(reviewerId=>({
182
+ ...identity,reviewerId,actorId:'replace-with-reviewer-identity',round:atCap?observed.policy.maxRounds:observed.review.rounds+1,
183
+ status:'FAIL',blocking:true,coverage:[],findings:[{kind:'general',summary:'Review has not been performed yet.',blocking:true}],
184
+ filesReviewed:[],commandsExecuted:[],checkedAt:now(),
185
+ }));
186
+ return {...observed,request:run.workRequest,plan:run.featurePlan,reportTemplates,
187
+ reportInstructions:'Complete the review before submitting. For each acceptance criterion provide its 1-based criterionIndex, assigned planNodeIds, reviewed paths and evidence [{kind:test|manual,reference,summary}]. Record missing or unrequested work as blocking findings. PASS requires complete coverage and no blocking findings. Templates intentionally do not claim a passing review.'};
188
+ }
189
+ const report=evaluateReviewReports(observed.policy,observed.subject,[input]).reports[0];
190
+ if(containsSecretMaterial(JSON.stringify(report)))fail('Review evidence contains credential-like material. Remove it before recording the report.');
191
+ const phaseReviews=state.reviews.filter(event=>event.subject.phase===observed.subject.phase);
192
+ const matchesCurrent=event=>event.subject.headSha===observed.subject.headSha&&event.subject.diffDigest===observed.subject.diffDigest;
193
+ const prior=phaseReviews.filter(matchesCurrent).map(event=>event.data);
194
+ const previousInputRound=Math.max(0,...phaseReviews.filter(event=>!matchesCurrent(event)).map(event=>event.data.round));
195
+ if(report.round<=previousInputRound)fail('A revised review subject must advance the phase review round.');
196
+ if(prior.some(value=>value.reviewerId===report.reviewerId&&value.round===report.round))fail('This reviewer round is already recorded.');
197
+ evaluateReviewReports(observed.policy,observed.subject,[...prior,report]);
198
+ await append(state,run,{type:'review.recorded',subject:observed.subject,policy:observed.policy,data:report});
199
+ return {status:'recorded',reviewerId:report.reviewerId,round:report.round};
200
+ }
201
+ fail('Unknown task evidence operation.');
202
+ }finally{await lock.release();}
203
+ }
@@ -1,3 +1,4 @@
1
+ import { inspectGovernance, requireGovernance } from './governance.js';
1
2
  import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
2
3
  import {createProtocolPresentation, protocolLookupContext} from '../protocols/presentation.js';
3
4
  import { lstat, realpath } from 'node:fs/promises';
@@ -260,6 +261,7 @@ export function createHostExecution(input) {
260
261
  const value = request(inputValue, 'expectedRunVersion');
261
262
  const { store, run } = await runRecord(value.project, value.runId);
262
263
  if (!['approved', 'running'].includes(run.status) || run.version !== value.expectedRunVersion) fail('state-conflict');
264
+ await requireGovernance({project:value.project,run,gitClient,phase:'plan'});
263
265
  const preparedContext = await runtimeContext(value.project, run);
264
266
  const running = run.status === 'running' ? run : await store.update({
265
267
  status: 'running',
@@ -299,6 +301,7 @@ export function createHostExecution(input) {
299
301
  try {
300
302
  const { run } = await runRecord(value.project, value.runId);
301
303
  if (run.status !== 'running') fail('state-conflict');
304
+ await requireGovernance({project:value.project,run,gitClient,phase:'plan'});
302
305
  const { instance, runtime } = await runtimeContext(value.project, run);
303
306
  const prepared = await runtime.prepareAction(instance, { expectedVersion: value.expectedRuntimeVersion });
304
307
  if (prepared.action === null) {
@@ -450,6 +453,7 @@ export function createHostExecution(input) {
450
453
  gates: await configuredFeatureGates(config, resolveCommandExecutable, integration.path),
451
454
  environment,
452
455
  }, { gitClient, now: () => featureNowMilliseconds(now) });
456
+ if(quality.status==='pass') await requireGovernance({project:value.project,run,gitClient,config,phase:'final',checkout:{path:integration.path,branch:integration.branch,commitSha:integrated.headSha}});
453
457
  } catch (error) {
454
458
  failure = typeof error?.safeMessage === 'string' ? error.safeMessage : 'Verification could not complete safely.';
455
459
  }
@@ -462,7 +466,7 @@ export function createHostExecution(input) {
462
466
  run, state, integration, commitSha: integrated.headSha, changedPaths,
463
467
  quality, failure, checkedAt: now(),
464
468
  }));
465
- if (quality?.status !== 'pass') fail('verification-failed');
469
+ if (failure || quality?.status !== 'pass') fail('verification-failed');
466
470
  const beforeFinal = await gitClient.inspectRepository(integration.path);
467
471
  if (beforeFinal.dirty || beforeFinal.branch !== integration.branch
468
472
  || beforeFinal.headSha !== accepted.commitSha) fail('repository');
@@ -592,7 +596,10 @@ export function createHostExecution(input) {
592
596
  const verificationStale = verification !== null && accepted !== null
593
597
  && (verification.commitSha !== accepted.commitSha || checkout?.status !== 'clean');
594
598
  const protocols = selectedProtocolStatus(value.project, run.workRequest.contextRefs);
595
- const deliverable = protocols.status === 'current' && run.status === 'awaiting-final-approval'
599
+ let governance;
600
+ try { governance = await inspectGovernance({project:value.project,run,gitClient,phase:readyForVerification?'final':'plan'}); }
601
+ catch (error) { governance = {ready:false,decisions:[],review:{humanEscalation:false},blockers:[{code:'governance-unavailable'}],nextAction:error.safeMessage ?? 'Decision or review evidence could not be read safely. Inspect private task state before continuing.'}; }
602
+ const deliverable = governance.ready && protocols.status === 'current' && run.status === 'awaiting-final-approval'
596
603
  && runtime !== null && readyForVerification
597
604
  && accepted !== null && checkout?.status === 'clean'
598
605
  && verification?.status === 'pass'
@@ -603,6 +610,7 @@ export function createHostExecution(input) {
603
610
  && verification.integration.branch === accepted.branch;
604
611
  const nextAction = protocols.status === 'changed' ? protocols.message : checkout?.status === 'stale' || verificationStale
605
612
  ? 'The integration checkout no longer matches the accepted tested commit. Do not deliver it; create a new reviewed proposal for source changes.'
613
+ : !governance.ready ? governance.nextAction
606
614
  : run.status === 'awaiting-final-approval' && !deliverable
607
615
  ? 'Final approval evidence is missing or inconsistent. Do not deliver this run; inspect private state and create a new reviewed proposal if it cannot be restored.'
608
616
  : deliverable
@@ -626,7 +634,7 @@ export function createHostExecution(input) {
626
634
  ? 'Use work prepare with run.version to create the isolated execution state.'
627
635
  : 'Use rivet task resume to continue the approved spawned task.'
628
636
  : 'Review the proposal and current run state before proceeding.';
629
- return immutableJson({ run, runtime, protocols, verification, checkout, workerCheckouts, deliveryReady: deliverable, blockedNodes, nextAction });
637
+ return immutableJson({ run, runtime, protocols, governance, verification, checkout, workerCheckouts, deliveryReady: deliverable, blockedNodes, nextAction });
630
638
  }
631
639
 
632
640
  return Object.freeze({ prepare, nextAction, submitResult, verify, status, recover });
@@ -1,3 +1,4 @@
1
+ import { requireGovernance } from './governance.js';
1
2
  import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
2
3
  import {createProtocolPresentation} from '../protocols/presentation.js';
3
4
  import { createHash } from 'node:crypto';
@@ -517,6 +518,7 @@ export function createFeatureExecutor(input) {
517
518
  if (repository.root !== project || repository.detached || repository.dirty
518
519
  || repository.branch !== config.project.repository.defaultBranch
519
520
  || repository.headSha !== run.featurePlan.baselineCommit) fail();
521
+ await requireGovernance({project,run,gitClient:configured.gitClient,config,phase:'plan'});
520
522
  // Only select adapters after the saved approval and current role configuration
521
523
  // are validated. Probe before creating integration or worker checkouts.
522
524
  const selectedClients = new Map();
@@ -743,6 +745,7 @@ export function createFeatureExecutor(input) {
743
745
  }
744
746
  const maximumTicks = run.featurePlan.nodes.length * 4 + 8;
745
747
  for (let tick = 0; tick < maximumTicks; tick += 1) {
748
+ await requireGovernance({project,run,gitClient:configured.gitClient,phase:'plan'});
746
749
  const outcome = await runtime.tick(instance, { expectedVersion: state.version, maxActiveNodes: 1, signal });
747
750
  state = await inspectFeatureRuntime(instance);
748
751
  if (['blocked', 'failed', 'budget-exhausted', 'cancelled'].includes(outcome.terminal)) {
@@ -803,6 +806,7 @@ export function createFeatureExecutor(input) {
803
806
  gates: await configuredFeatureGates(config, configured.resolveCommandExecutable, integration.path),
804
807
  environment: configured.environment,
805
808
  }, { gitClient: configured.gitClient, now: nowMs, signal });
809
+ if(quality.status==='pass') await requireGovernance({project,run,gitClient:configured.gitClient,config,phase:'final',checkout:{path:integration.path,branch:integration.branch,commitSha:identity.commitSha}});
806
810
  } catch (error) {
807
811
  failure = typeof error?.safeMessage === 'string' ? error.safeMessage : 'Configured quality gates could not complete safely.';
808
812
  }
@@ -1,3 +1,4 @@
1
+ import { assertGovernancePolicy, requireGovernance } from './governance.js';
1
2
  import {assertSelectedProtocolRefs, selectedProtocolStatus} from '../protocols/project.js';
2
3
  import { isAbsolute, relative, resolve, sep } from 'node:path';
3
4
 
@@ -212,6 +213,8 @@ export function createFeatureWorkflow(input) {
212
213
  new Set(['project', 'source']),
213
214
  );
214
215
  const { observed, config } = await repository(request.project);
216
+ if(config.quality.review!==undefined||config.quality.contentScan!==undefined)
217
+ await assertGovernancePolicy({project:request.project,baselineCommit:observed.headSha,gitClient,config});
215
218
  const selectedClient = request.client ?? 'claude';
216
219
  if (!CLIENTS.has(selectedClient)) fail('invalid-input');
217
220
  const source = capture(request.source, new Set(['kind', 'value']));
@@ -286,13 +289,14 @@ export function createFeatureWorkflow(input) {
286
289
  baselineCommit: record.featurePlan.baselineCommit, client: record.featurePlan.client });
287
290
  }
288
291
 
289
- async function start(raw) {
292
+ async function startLocked(raw) {
290
293
  const request = capture(raw, new Set(['project', 'runId', 'expectedVersion', 'proposalDigest']));
291
294
  const { store, record } = await readRun(request.project, request.runId);
292
295
  if (record.status !== 'proposed' || record.version !== expectedVersion(request.expectedVersion)) fail('state-conflict');
293
296
  if (typeof request.proposalDigest !== 'string' || !DIGEST.test(request.proposalDigest)
294
297
  || request.proposalDigest !== record.proposalDigest) fail('proposal-mismatch');
295
298
  await validateSavedPlan(request.project, record);
299
+ await requireGovernance({project:request.project,run:record,gitClient,config:await loadConfig(request.project),phase:'plan'});
296
300
  const at = now();
297
301
  const updated = await store.update({
298
302
  status: 'approved', updatedAt: at,
@@ -305,6 +309,13 @@ export function createFeatureWorkflow(input) {
305
309
  return lifecycleView(updated);
306
310
  }
307
311
 
312
+ async function start(raw) {
313
+ const request = capture(raw, new Set(['project', 'runId', 'expectedVersion', 'proposalDigest']));
314
+ const paths = await resolveFeatureRunPaths(absolute(request.project), exactRunId(request.runId));
315
+ const lock = await acquireHostRunLock(paths);
316
+ try { return await startLocked(raw); } finally { await lock.release(); }
317
+ }
318
+
308
319
  async function status(raw) {
309
320
  const request = capture(raw, new Set(['project', 'runId']));
310
321
  const record = (await readRun(request.project, request.runId)).record;
package/src/git/client.js CHANGED
@@ -425,6 +425,18 @@ export async function createGitClient(options = {}) {
425
425
  }
426
426
  },
427
427
 
428
+ async readCommittedFile(cwd, commit, path) {
429
+ if (!validSha(commit)) fail('invalid-input');
430
+ repositoryPath(path);
431
+ const tree = await run(absoluteInput(cwd), ['ls-tree', '-z', commit, '--', path]);
432
+ if (!tree.output) return null; // Deleted paths have no blob in the target tree.
433
+ const match = /^(100644|100755) blob ([0-9a-f]{40}|[0-9a-f]{64})\t([^\0]+)\0$/.exec(tree.output);
434
+ if (!match || match[3] !== path) fail('repository-path-unsafe');
435
+ const result = await run(absoluteInput(cwd), ['cat-file', 'blob', match[2]], [0], 1024 * 1024);
436
+ if (result.output.includes('\0')) fail('git-output-invalid');
437
+ return Object.freeze({path, blob:match[2], content:result.output});
438
+ },
439
+
428
440
  async inspectTrackedFile(cwd, commit, path) {
429
441
  if (!validSha(commit)) fail('invalid-input');
430
442
  repositoryPath(path);