@agilno-tech/rivet 0.1.0-alpha.1 → 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.
@@ -4,46 +4,314 @@
4
4
  "title": "Agilno tracked quality policy",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
- "required": ["schemaVersion", "providerRefs", "completionProfileRefs", "commandGates", "expectations", "evidence"],
7
+ "required": [
8
+ "schemaVersion",
9
+ "providerRefs",
10
+ "completionProfileRefs",
11
+ "commandGates",
12
+ "expectations",
13
+ "evidence"
14
+ ],
8
15
  "properties": {
9
- "schemaVersion": { "type": "integer", "const": 1 },
10
- "providerRefs": { "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, "items": { "$ref": "#/$defs/id" } },
11
- "completionProfileRefs": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/id" } },
12
- "commandGates": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/commandGate" } },
16
+ "contentScan": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "secrets": {
21
+ "type": "boolean"
22
+ },
23
+ "unicode": {
24
+ "enum": [
25
+ "warn",
26
+ "required",
27
+ "off"
28
+ ]
29
+ },
30
+ "maxFiles": {
31
+ "type": "integer",
32
+ "minimum": 1,
33
+ "maximum": 1000
34
+ },
35
+ "maxBytes": {
36
+ "type": "integer",
37
+ "minimum": 1,
38
+ "maximum": 2097152
39
+ },
40
+ "allowlist": {
41
+ "type": "array",
42
+ "maxItems": 100,
43
+ "items": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": [
47
+ "ruleId",
48
+ "path",
49
+ "reason"
50
+ ],
51
+ "properties": {
52
+ "ruleId": {
53
+ "enum": [
54
+ "private-key",
55
+ "github-token",
56
+ "npm-token",
57
+ "aws-access-key",
58
+ "unicode-bidi",
59
+ "unicode-mixed-identifier",
60
+ "type-suppression"
61
+ ]
62
+ },
63
+ "path": {
64
+ "type": "string",
65
+ "minLength": 1,
66
+ "maxLength": 1024,
67
+ "pattern": "^(?!/)(?![A-Za-z]:)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[/]$)[^\\\\*?\\[\\]{}\\u0000-\\u001f\\u007f]+$"
68
+ },
69
+ "reason": {
70
+ "type": "string",
71
+ "minLength": 1,
72
+ "maxLength": 500,
73
+ "pattern": "\\S"
74
+ }
75
+ }
76
+ }
77
+ },
78
+ "shortcutRules": {
79
+ "type": "array",
80
+ "maxItems": 100,
81
+ "items": {
82
+ "type": "object",
83
+ "additionalProperties": false,
84
+ "required": [
85
+ "ruleId",
86
+ "paths",
87
+ "reason"
88
+ ],
89
+ "properties": {
90
+ "ruleId": {
91
+ "const": "type-suppression"
92
+ },
93
+ "paths": {
94
+ "type": "array",
95
+ "minItems": 1,
96
+ "maxItems": 100,
97
+ "uniqueItems": true,
98
+ "items": {
99
+ "type": "string",
100
+ "minLength": 1,
101
+ "maxLength": 1024,
102
+ "pattern": "^(?!/)(?![A-Za-z]:)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*//)(?!.*[/]$)[^\\\\*?\\[\\]{}\\u0000-\\u001f\\u007f]+$"
103
+ }
104
+ },
105
+ "reason": {
106
+ "type": "string",
107
+ "minLength": 1,
108
+ "maxLength": 500,
109
+ "pattern": "\\S"
110
+ },
111
+ "required": {
112
+ "type": "boolean"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ },
119
+ "schemaVersion": {
120
+ "type": "integer",
121
+ "const": 1
122
+ },
123
+ "providerRefs": {
124
+ "type": "array",
125
+ "minItems": 1,
126
+ "maxItems": 64,
127
+ "uniqueItems": true,
128
+ "items": {
129
+ "$ref": "#/$defs/id"
130
+ }
131
+ },
132
+ "completionProfileRefs": {
133
+ "type": "array",
134
+ "minItems": 1,
135
+ "maxItems": 32,
136
+ "uniqueItems": true,
137
+ "items": {
138
+ "$ref": "#/$defs/id"
139
+ }
140
+ },
141
+ "commandGates": {
142
+ "type": "array",
143
+ "minItems": 1,
144
+ "maxItems": 32,
145
+ "items": {
146
+ "$ref": "#/$defs/commandGate"
147
+ }
148
+ },
13
149
  "expectations": {
14
150
  "type": "object",
15
151
  "additionalProperties": false,
16
- "required": ["storybook", "playwright", "accessibility", "security", "visual"],
152
+ "required": [
153
+ "storybook",
154
+ "playwright",
155
+ "accessibility",
156
+ "security",
157
+ "visual"
158
+ ],
17
159
  "properties": {
18
- "storybook": { "enum": ["optional", "required"] },
19
- "playwright": { "enum": ["optional", "required"] },
20
- "accessibility": { "enum": ["none", "wcag-a", "wcag-aa"] },
21
- "security": { "enum": ["required", "not-applicable-with-approval"] },
22
- "visual": { "enum": ["none", "automated", "human-baseline"] }
160
+ "storybook": {
161
+ "enum": [
162
+ "optional",
163
+ "required"
164
+ ]
165
+ },
166
+ "playwright": {
167
+ "enum": [
168
+ "optional",
169
+ "required"
170
+ ]
171
+ },
172
+ "accessibility": {
173
+ "enum": [
174
+ "none",
175
+ "wcag-a",
176
+ "wcag-aa"
177
+ ]
178
+ },
179
+ "security": {
180
+ "enum": [
181
+ "required",
182
+ "not-applicable-with-approval"
183
+ ]
184
+ },
185
+ "visual": {
186
+ "enum": [
187
+ "none",
188
+ "automated",
189
+ "human-baseline"
190
+ ]
191
+ }
23
192
  }
24
193
  },
25
194
  "evidence": {
26
195
  "type": "object",
27
196
  "additionalProperties": false,
28
- "required": ["requiredTypes", "requireHumanBaseline", "requireHumanFinal"],
197
+ "required": [
198
+ "requiredTypes",
199
+ "requireHumanBaseline",
200
+ "requireHumanFinal"
201
+ ],
29
202
  "properties": {
30
- "requiredTypes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "$ref": "#/$defs/evidenceType" } },
31
- "requireHumanBaseline": { "type": "boolean" },
32
- "requireHumanFinal": { "type": "boolean", "const": true }
203
+ "requiredTypes": {
204
+ "type": "array",
205
+ "minItems": 1,
206
+ "maxItems": 8,
207
+ "uniqueItems": true,
208
+ "items": {
209
+ "$ref": "#/$defs/evidenceType"
210
+ }
211
+ },
212
+ "requireHumanBaseline": {
213
+ "type": "boolean"
214
+ },
215
+ "requireHumanFinal": {
216
+ "type": "boolean",
217
+ "const": true
218
+ }
219
+ }
220
+ },
221
+ "review": {
222
+ "type": "object",
223
+ "additionalProperties": false,
224
+ "properties": {
225
+ "required": {
226
+ "type": "boolean"
227
+ },
228
+ "maxRounds": {
229
+ "type": "integer",
230
+ "minimum": 1,
231
+ "maximum": 10
232
+ },
233
+ "strictCoverage": {
234
+ "type": "boolean"
235
+ },
236
+ "reviewers": {
237
+ "type": "array",
238
+ "maxItems": 64,
239
+ "items": {
240
+ "type": "object",
241
+ "additionalProperties": false,
242
+ "required": [
243
+ "id",
244
+ "paths",
245
+ "required"
246
+ ],
247
+ "properties": {
248
+ "id": {
249
+ "$ref": "#/$defs/id"
250
+ },
251
+ "paths": {
252
+ "type": "array",
253
+ "minItems": 1,
254
+ "maxItems": 128,
255
+ "uniqueItems": true,
256
+ "items": {
257
+ "type": "string",
258
+ "minLength": 1,
259
+ "maxLength": 512
260
+ }
261
+ },
262
+ "required": {
263
+ "type": "boolean"
264
+ }
265
+ }
266
+ }
267
+ }
33
268
  }
34
269
  }
35
270
  },
36
271
  "$defs": {
37
- "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" },
38
- "evidenceType": { "enum": ["commit", "test", "journey", "screenshot", "report", "review", "human-approval", "external-update"] },
272
+ "id": {
273
+ "type": "string",
274
+ "minLength": 1,
275
+ "maxLength": 64,
276
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
277
+ },
278
+ "evidenceType": {
279
+ "enum": [
280
+ "commit",
281
+ "test",
282
+ "journey",
283
+ "screenshot",
284
+ "report",
285
+ "review",
286
+ "human-approval",
287
+ "external-update"
288
+ ]
289
+ },
39
290
  "commandGate": {
40
291
  "type": "object",
41
292
  "additionalProperties": false,
42
- "required": ["id", "command", "required"],
293
+ "required": [
294
+ "id",
295
+ "command",
296
+ "required"
297
+ ],
43
298
  "properties": {
44
- "id": { "$ref": "#/$defs/id" },
45
- "command": { "enum": ["build", "test", "lint", "typecheck", "dev", "check"] },
46
- "required": { "type": "boolean" }
299
+ "id": {
300
+ "$ref": "#/$defs/id"
301
+ },
302
+ "command": {
303
+ "enum": [
304
+ "build",
305
+ "test",
306
+ "lint",
307
+ "typecheck",
308
+ "dev",
309
+ "check"
310
+ ]
311
+ },
312
+ "required": {
313
+ "type": "boolean"
314
+ }
47
315
  }
48
316
  }
49
317
  }
package/src/cli/main.js CHANGED
@@ -115,6 +115,10 @@ Usage:
115
115
  rivet task start [--project=<path>] [--run=<id>]
116
116
  rivet task approve [--project=<path>] [--run=<id>]
117
117
  rivet task resume [--project=<path>] [--run=<id>]
118
+ rivet task decisions [--project=<path>] [--run=<id>]
119
+ rivet task decide --input=<decision.json> [--project=<path>] [--run=<id>]
120
+ rivet task approve-decision --decision=<id> [--project=<path>] [--run=<id>]
121
+ rivet task review [--phase=plan|final] [--input=<report.json>] [--project=<path>] [--run=<id>]
118
122
  rivet task deps [--project=<path>] [--run=<id>]
119
123
  rivet protocols add <slug> [--from=<path>] [--project=<path>] [--json]
120
124
  rivet protocols import <slug> --from=<path> [--project=<path>] [--json]
@@ -61,7 +61,7 @@ const STRICT_OPTIONS = {
61
61
  },
62
62
  task: {
63
63
  boolean: new Set(['details']),
64
- valued: new Set(['project', 'run']),
64
+ valued: new Set(['project', 'run', 'input', 'input-json', 'phase', 'decision']),
65
65
  },
66
66
  setup: {
67
67
  boolean: new Set(['global', 'write', 'json']),
@@ -233,7 +233,7 @@ export function parseArgs(argv) {
233
233
  if (command === 'work' && !['propose', 'prepare', 'next', 'status', 'submit', 'verify', 'recover'].includes(positionals[0])) {
234
234
  throw new ArgumentError('Unsupported work subcommand');
235
235
  }
236
- if (command === 'task' && !['status', 'start', 'approve', 'resume', 'deps', 'recover'].includes(positionals[0])) {
236
+ if (command === 'task' && !['status', 'start', 'approve', 'resume', 'deps', 'recover', 'decisions', 'decide', 'approve-decision', 'review'].includes(positionals[0])) {
237
237
  throw new ArgumentError('Unsupported task subcommand');
238
238
  }
239
239
 
@@ -1,3 +1,4 @@
1
+ import { taskGovernanceCommand } from './task-governance.js';
1
2
  import { taskState } from '../cli/task-presentation.js';
2
3
  import { finishTask } from './task-completion.js';
3
4
  import { resolveConfiguredProject } from '../cli/project-discovery.js';
@@ -172,13 +173,15 @@ function renderWorkerCheckouts(status, output) {
172
173
 
173
174
 
174
175
  export async function humanTaskCommand(parsed, dependencies) {
175
- if (parsed.command !== 'task' || !['status', 'start', 'approve', 'resume', 'deps', 'recover'].includes(parsed.subcommand)
176
- || parsed.operands.length !== 0 || Object.keys(parsed.flags).some(key => !['project', 'run', 'details'].includes(key))) {
176
+ if (parsed.command !== 'task' || !['status', 'start', 'approve', 'resume', 'deps', 'recover', 'decisions', 'decide', 'approve-decision', 'review'].includes(parsed.subcommand)
177
+ || parsed.operands.length !== 0 || Object.keys(parsed.flags).some(key => !['project', 'run', 'details', 'input', 'input-json', 'phase', 'decision'].includes(key))) {
177
178
  fail('Use rivet task status|start|approve|resume|deps|recover [--project=<path>] [--run=<id>].');
178
179
  }
179
180
  const project = await resolveConfiguredProject(dependencies.cwd(), parsed.flags.project, { env: dependencies.env });
180
181
  const record = await selectedRun(project.root, parsed.flags.run, parsed.subcommand, dependencies);
181
182
  if (!record) {dependencies.output.log('Task selection cancelled. No task action was taken.');return EXIT_CODES.SUCCESS;}
183
+ if (['decisions','decide','approve-decision','review'].includes(parsed.subcommand)) return taskGovernanceCommand(parsed,dependencies,project,record);
184
+ if (['input','input-json','phase','decision'].some(key=>parsed.flags[key]!==undefined)) fail('Evidence options require a decision or review command.');
182
185
  if (parsed.subcommand === 'approve') return finishTask(project, record, dependencies);
183
186
  if (parsed.subcommand === 'recover') {
184
187
  if (record.featurePlan.client !== 'host' && !['awaiting-final-approval','completed'].includes(record.status)) fail('Lock recovery is only available for host tasks or terminal tasks at final review; it never restarts a worker.', 'REPOSITORY_CONFLICT');
@@ -203,6 +206,11 @@ export async function humanTaskCommand(parsed, dependencies) {
203
206
  dependencies.output.log(`Worker harnesses: ${[...new Set(record.featurePlan.nodes.filter(node => node.role === 'worker').map(node => node.execution?.client ?? record.featurePlan.client))].join(', ')}`);
204
207
  } else dependencies.output.log(`Harness: ${record.featurePlan.client}`);
205
208
  dependencies.output.log(`Next: ${record.status === 'completed' ? 'Task completed. Inspect its recorded evidence and any separate delivery status.' : record.status === 'proposed' && record.featurePlan.client !== 'host' ? `rivet task start --run=${record.runId}` : record.status === 'awaiting-final-approval' && status.deliveryReady ? `rivet task approve --run=${record.runId}` : visible(status.nextAction)}`);
209
+ if (status.governance?.decisions.length) {
210
+ dependencies.output.log(`Decisions: ${status.governance.decisions.length}; use rivet task decisions to read the journal.`);
211
+ }
212
+ for (const blocker of status.governance?.blockers ?? []) dependencies.output.log(`Review: ${visible(blocker.code)}${blocker.reviewerId ? ` (${visible(blocker.reviewerId)})` : ''}`);
213
+ if (status.governance?.review.humanEscalation) dependencies.output.log('Review round limit reached. Ask a human to assess the findings and prepare a corrective task.');
206
214
  if (status.verification) {
207
215
  dependencies.output.log(`Verification: ${status.verification.status} at ${status.verification.commitSha}`);
208
216
  for (const path of status.verification.changedPaths) dependencies.output.log(`Changed: ${visible(path)}`);
@@ -0,0 +1,53 @@
1
+ import {withTerminalInterruption} from '../cli/interrupt.js';
2
+ import {resolve} from 'node:path';
3
+ import {createGitClient} from '../git/client.js';
4
+ import {taskGovernance} from '../feature/governance.js';
5
+ import {CliError,EXIT_CODES,observeOutputErrors} from '../cli/output.js';
6
+ import {readJson} from './work.js';
7
+
8
+ export async function taskGovernanceCommand(parsed,dependencies,project,run){
9
+ const {subcommand:operation,flags}=parsed;
10
+ const extra={decisions:[],decide:['input','input-json'],'approve-decision':['decision'],review:['input','input-json','phase']}[operation];
11
+ if(Object.keys(flags).some(key=>!['project','run',...extra].includes(key)))throw new CliError('Unexpected option for this task evidence command.','INVALID_INPUT');
12
+ if(operation==='decide'&&!flags.input&&!flags['input-json'])throw new CliError('Use rivet task decide --input=<decision.json>.','INVALID_INPUT');
13
+ if(operation==='approve-decision'&&!flags.decision)throw new CliError('Use rivet task approve-decision --decision=<id>.','INVALID_INPUT');
14
+ if(flags.phase!==undefined&&!['plan','final'].includes(flags.phase))throw new CliError('Review phase must be plan or final.','INVALID_INPUT');
15
+ if(flags.input!==undefined&&flags['input-json']!==undefined)throw new CliError('Choose one of --input or --input-json.','INVALID_INPUT');
16
+ let input;
17
+ if(flags['input-json']!==undefined){
18
+ const source=flags['input-json'];
19
+ if(typeof source!=='string'||!source.length||Buffer.byteLength(source)>64*1024)throw new CliError('Inline task evidence must be bounded JSON (up to 64 KiB).','INVALID_INPUT');
20
+ try{input=JSON.parse(source);}catch{throw new CliError('Task evidence is not valid JSON.','INVALID_INPUT');}
21
+ }
22
+ if(flags.input!==undefined)input=readJson(project.root,resolve(project.root,flags.input),dependencies.fs,'Task evidence');
23
+ let confirm,activeSignal;
24
+ if(operation==='approve-decision'){
25
+ if(dependencies.terminalIsInteractive?.()!==true)throw new CliError('Decision approval requires an interactive human terminal.','BLOCKED_AUTHORITY');
26
+ confirm=async record=>{
27
+ let failed=false;
28
+ const unobserve=observeOutputErrors(dependencies.output,()=>{failed=true;});
29
+ try{
30
+ activeSignal.throwIfAborted();
31
+ dependencies.output.log(JSON.stringify(record,null,2));
32
+ if(failed)throw new CliError('Could not display the decision. It remains pending.','REPOSITORY_CONFLICT');
33
+ const answer=await dependencies.taskApprovalPrompt({type:'select',signal:activeSignal,message:'Approve this exact decision? Existing execution and delivery permissions still apply.',choices:[{value:'approve',label:'Approve this decision'},{value:'leave',label:'Leave pending'}]});
34
+ activeSignal.throwIfAborted();
35
+ if(failed)throw new CliError('Decision output failed. It remains pending.','REPOSITORY_CONFLICT');
36
+ return answer==='approve';
37
+ }finally{unobserve?.();}
38
+ };
39
+ }
40
+ try{
41
+ const gitClient=await createGitClient({gitExecutable:await dependencies.resolveCommandExecutable('git')});
42
+ const result=await withTerminalInterruption(async signal=>{
43
+ activeSignal=signal;
44
+ return taskGovernance({project:project.root,runId:run.runId,gitClient,
45
+ operation:operation==='review'&&input!==undefined?'review-submit':operation,input,phase:flags.phase,decisionId:flags.decision,confirm,signal});
46
+ });
47
+ dependencies.output.log(operation==='decisions'?result.markdown:JSON.stringify(result,null,2));
48
+ return EXIT_CODES.SUCCESS;
49
+ }catch(error){
50
+ if(error instanceof CliError)throw error;
51
+ throw new CliError(error.safeMessage??'Task decision or review operation failed. Inspect task status and the input report.','FAILED_GATE');
52
+ }
53
+ }
@@ -40,7 +40,7 @@ function sameFile(left, right) {
40
40
  return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.nlink === right.nlink;
41
41
  }
42
42
 
43
- function readJson(project, path, fs, label) {
43
+ export function readJson(project, path, fs, label) {
44
44
  const target = absolute(path, `${label} path`);
45
45
  const contained = relative(project, target);
46
46
  if (!contained || contained === '..' || contained.startsWith(`..${sep}`) || isAbsolute(contained)) {
@@ -32,7 +32,7 @@ const DEFAULT_FILE_SYSTEM = Object.freeze({
32
32
  statSync,
33
33
  });
34
34
 
35
- function parseYaml(source, filename) {
35
+ export function parseYaml(source, filename) {
36
36
  let documents;
37
37
  try {
38
38
  documents = parseAllDocuments(source, {
@@ -79,7 +79,8 @@ function scanForSecrets(value, path = '') {
79
79
  if (!value || typeof value !== 'object') return;
80
80
  for (const [key, child] of Object.entries(value)) {
81
81
  const childPath = `${path}/${key}`;
82
- if (SECRET_KEY.test(key) && !SAFE_SECRET_KEYS.has(key)) fail(childPath, 'secret-key');
82
+ if (SECRET_KEY.test(key) && !SAFE_SECRET_KEYS.has(key)
83
+ && !(childPath === '/quality/contentScan/secrets' && typeof child === 'boolean')) fail(childPath, 'secret-key');
83
84
  if (typeof child === 'string' && SECRET_VALUE.test(child)) fail(childPath, 'secret-value');
84
85
  if (typeof child === 'string' && /^https?:\/\//i.test(child)) assertSafeUrl(child, childPath);
85
86
  scanForSecrets(child, childPath);
@@ -119,7 +119,7 @@ export function candidate(input) {
119
119
  exact(
120
120
  value,
121
121
  ['runId', 'repository', 'sourceBranch', 'targetBranch', 'localVerification'],
122
- ['reviewNumber']
122
+ ['reviewNumber', 'decisionSummary']
123
123
  );
124
124
  id(value.runId);
125
125
  ref(value.sourceBranch);
@@ -132,11 +132,22 @@ export function candidate(input) {
132
132
  'evidenceDigest',
133
133
  'verifiedAt',
134
134
  'status',
135
- ]);
135
+ ], ['governanceDigest', 'decisionSummaryDigest']);
136
136
  ensure(proof.runId === value.runId && proof.status === 'passed');
137
137
  sha(proof.headSha);
138
138
  digest(proof.evidenceDigest);
139
139
  timestamp(proof.verifiedAt);
140
+ if (proof.governanceDigest !== undefined) digest(proof.governanceDigest);
141
+ ensure((value.decisionSummary === undefined) === (proof.decisionSummaryDigest === undefined));
142
+ if (value.decisionSummary !== undefined) {
143
+ const summary = value.decisionSummary;
144
+ ensure(typeof summary === 'string' && summary.startsWith('## Decisions\n\n')
145
+ && Buffer.byteLength(summary) <= 32000 && summary.length > 16
146
+ && !/[\u0000-\u0009\u000b-\u001f\u007f<>[\]`@]/.test(summary), 'invalid-decision-summary');
147
+ digest(proof.decisionSummaryDigest);
148
+ ensure(proof.governanceDigest !== undefined && hash(summary) === proof.decisionSummaryDigest, 'decision-summary-mismatch');
149
+ }
150
+
140
151
  ensure(
141
152
  value.reviewNumber === undefined || (Number.isSafeInteger(value.reviewNumber) && value.reviewNumber > 0)
142
153
  );
@@ -5,6 +5,9 @@ import { resolveExistingFeatureRunPaths } from '../state/paths.js';
5
5
  import { loadProjectConfig } from '../config/load.js';
6
6
  import { discoverRepositoryRemotes, selectConfiguredRepositoryRemote } from '../repositories/index.js';
7
7
  import { immutableJson } from '../clients/contract.js';
8
+ import { inspectGovernance } from '../feature/governance.js';
9
+ import { renderDecisions } from '../feature/decisions.js';
10
+ import { hash } from './contract.js';
8
11
 
9
12
  export class DeliveryPreparationError extends Error {
10
13
  constructor(message = 'Delivery requires a verified run and unchanged integration checkout. Inspect work status and verification results.') {
@@ -18,6 +21,30 @@ function fail() {
18
21
  throw new DeliveryPreparationError();
19
22
  }
20
23
 
24
+ // Called only with application-observed governance, never a CLI-supplied summary.
25
+ export function deliveryGovernanceEvidence(governance, run, checkout) {
26
+ if (!governance || governance.ready !== true || !Array.isArray(governance.blockers) || governance.blockers.length
27
+ || !Array.isArray(governance.decisions) || governance.decisions.some(value => value.status === 'pending')
28
+ || governance.review?.valid !== true || !Array.isArray(governance.review.reports)
29
+ || governance.review.blockers?.length || (governance.scan !== null && governance.scan?.status !== 'passed')) fail();
30
+ const bindings = { runId: run.runId, requestDigest: run.workRequest.digest, planDigest: run.proposalDigest };
31
+ if (governance.subject?.phase !== 'final' || governance.subject.headSha !== checkout.acceptedCommit
32
+ || governance.subject.baseSha !== run.featurePlan.baselineCommit
33
+ || Object.entries(bindings).some(([key, value]) => governance.subject[key] !== value)) fail();
34
+ const decisionSummary = governance.decisions.length ? renderDecisions(governance.decisions, {
35
+ ...bindings, approvedPlanDecisions: governance.decisions.filter(value => value.status === 'approved').map(({ id, choice }) => ({ id, choice })),
36
+ }) : null;
37
+ if (decisionSummary !== null && Buffer.byteLength(decisionSummary) > 30000) {
38
+ throw new DeliveryPreparationError('Decision evidence exceeds the review body limit. Split the task before preparing delivery; evidence is never truncated.');
39
+ }
40
+ const hasEvidence = governance.decisions.length > 0 || governance.policy?.required === true
41
+ || governance.review.reports.length > 0 || governance.scan !== null;
42
+ if (!hasEvidence) return Object.freeze({});
43
+ const governanceDigest = hash({ subject: governance.subject, policy: governance.policy, decisions: governance.decisions,
44
+ review: governance.review, scan: governance.scan });
45
+ return immutableJson({ governanceDigest, ...(decisionSummary === null ? {} : { decisionSummary, decisionSummaryDigest: hash(decisionSummary) }) });
46
+ }
47
+
21
48
  // Only the application reads local verification. CLI inputs never stand in for evidence.
22
49
  export async function loadDeliveryCandidate({ project, runId, remoteName, gitClient, runner }) {
23
50
  const paths = await resolveExistingFeatureRunPaths(project, runId, runner ? { runner } : {});
@@ -28,6 +55,10 @@ export async function loadDeliveryCandidate({ project, runId, remoteName, gitCli
28
55
  const observed = await execution.status({ project, runId });
29
56
  const config = await loadProjectConfig(project);
30
57
  const { run, verification, checkout } = observed;
58
+ const governance = observed.governance ?? await inspectGovernance({ project, run, gitClient, config, phase: 'final',
59
+ ...(checkout?.acceptedCommit ? { checkout: { path: checkout.path, commitSha: checkout.acceptedCommit, branch: checkout.branch } } : {}) });
60
+ const governanceEvidence = deliveryGovernanceEvidence(governance, run, checkout ?? {});
61
+ const { decisionSummary, ...governanceProof } = governanceEvidence;
31
62
  const source = await gitClient.inspectRepository(project);
32
63
  if (
33
64
  source.dirty ||
@@ -64,17 +95,20 @@ export async function loadDeliveryCandidate({ project, runId, remoteName, gitCli
64
95
  return immutableJson({
65
96
  runId,
66
97
  repository,
98
+ ...(decisionSummary === undefined ? {} : { decisionSummary }),
67
99
  sourceBranch: checkout.branch,
68
100
  targetBranch: config.project.repository.defaultBranch,
69
101
  localVerification: {
70
102
  runId,
71
103
  status: 'passed',
104
+ ...governanceProof,
72
105
  headSha: checkout.acceptedCommit,
73
106
  evidenceDigest: createHash('sha256')
74
107
  .update(
75
108
  JSON.stringify({
76
109
  proposalDigest: run.proposalDigest,
77
110
  verification,
111
+ ...(governanceEvidence.governanceDigest ? { governanceDigest: governanceEvidence.governanceDigest } : {}),
78
112
  })
79
113
  )
80
114
  .digest('hex'),
@@ -4,15 +4,16 @@ import { parseRepositoryRemote } from '../repositories/identity.js';
4
4
  import { createTrustedDeliveryExecutor } from './service.js';
5
5
  import { digest, ensure, exact, factsDigest, hash, plain, sha, timestamp, validateCandidate } from './contract.js';
6
6
 
7
+ const REVIEW_MARKER_BYTES = Buffer.byteLength('\n\n<!-- rivet-review-operation:' + '0'.repeat(64) + ' -->');
7
8
  export function reviewRequestPayload(candidate) {
8
9
  validateCandidate(candidate);
9
- return plain({
10
- title: `Rivet: ${candidate.runId}`,
11
- body: ['Rivet verified delivery candidate', '', `Run: ${candidate.runId}`,
12
- `Source branch: ${candidate.sourceBranch}`, `Target branch: ${candidate.targetBranch}`,
13
- `Verified commit: ${candidate.headSha}`, `Verification evidence: ${candidate.localVerification.evidenceDigest}`,
14
- '', 'Review the changes and repository checks before making a separate merge decision.'].join('\n'),
15
- });
10
+ const body = ['Rivet verified delivery candidate', '', `Run: ${candidate.runId}`,
11
+ `Source branch: ${candidate.sourceBranch}`, `Target branch: ${candidate.targetBranch}`,
12
+ `Verified commit: ${candidate.headSha}`, `Verification evidence: ${candidate.localVerification.evidenceDigest}`,
13
+ ...(candidate.decisionSummary === undefined ? [] : ['', candidate.decisionSummary]),
14
+ '', 'Review the changes and repository checks before making a separate merge decision.'].join('\n');
15
+ ensure(Buffer.byteLength(body) + REVIEW_MARKER_BYTES <= 32000, 'review-content-too-large');
16
+ return plain({ title: `Rivet: ${candidate.runId}`, body });
16
17
  }
17
18
 
18
19
  export function reviewRequestContent(operation) {
@@ -20,9 +21,12 @@ export function reviewRequestContent(operation) {
20
21
  digest(operation.digest);
21
22
  const payload = plain(operation.payload);
22
23
  exact(payload, ['title', 'body']);
24
+ if (operation.candidate?.decisionSummary !== undefined) {
25
+ ensure(hash(payload) === hash(reviewRequestPayload(operation.candidate)), 'decision-summary-mismatch');
26
+ }
23
27
  ensure(typeof payload.title === 'string' && payload.title.trim() === payload.title && payload.title.length >= 1
24
28
  && payload.title.length <= 256 && !/[\u0000-\u001f\u007f]/.test(payload.title));
25
- ensure(typeof payload.body === 'string' && payload.body.length >= 1 && payload.body.length <= 32000
29
+ ensure(typeof payload.body === 'string' && payload.body.length >= 1 && Buffer.byteLength(payload.body) + REVIEW_MARKER_BYTES <= 32000
26
30
  && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(payload.body)
27
31
  && !payload.body.includes('rivet-review-operation:'));
28
32
  return plain({ title: payload.title, body: `${payload.body}\n\n<!-- rivet-review-operation:${operation.digest} -->` });