chati-dev 4.5.27 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +137 -20
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { canonicalize, ContractError, sha256 } from '@chati/core';
|
|
2
|
+
import { restoredExecutionProof } from './execution-restoration.js';
|
|
3
|
+
import { assertTerminalCompletionContainment } from './terminal-containment.js';
|
|
4
|
+
|
|
5
|
+
const STAGES = Object.freeze({ execution_dispatched: 'execution', review_dispatched: 'review', adjudication_dispatched: 'adjudication' });
|
|
6
|
+
function fail(code, message) { throw new ContractError(code, message); }
|
|
7
|
+
|
|
8
|
+
/** Full observed text, never a summary or caller-declared result. */
|
|
9
|
+
export function assertProviderProcessOutput(output, receipt) {
|
|
10
|
+
if (!output || typeof output !== 'object' || Array.isArray(output)
|
|
11
|
+
|| Object.keys(output).length !== 2
|
|
12
|
+
|| typeof output.stdout !== 'string' || typeof output.stderr !== 'string') {
|
|
13
|
+
fail('RAIL_PROCESS_OUTPUT_MISMATCH', 'process output must contain exactly stdout and stderr strings');
|
|
14
|
+
}
|
|
15
|
+
if (Buffer.byteLength(output.stdout) > 1_000_000 || Buffer.byteLength(output.stderr) > 100_000) {
|
|
16
|
+
fail('RAIL_PROCESS_OUTPUT_TOO_LARGE', 'observed process output exceeds the journal-bound evidence limit');
|
|
17
|
+
}
|
|
18
|
+
if (sha256(output.stdout) !== receipt.stdout_digest || sha256(output.stderr) !== receipt.stderr_digest) {
|
|
19
|
+
fail('RAIL_PROCESS_OUTPUT_MISMATCH', 'process output must match both digests in its verified completion receipt');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function providerProcessDispatches(records, attemptId) {
|
|
24
|
+
return records.filter((record) => Object.hasOwn(STAGES, record.type) && record.payload?.attempt_id === attemptId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertProviderProcessReceipt(dispatch, receipt, verifyAuthority) {
|
|
28
|
+
const invocation = dispatch.payload.invocation;
|
|
29
|
+
const launch = dispatch.payload.launch_receipt;
|
|
30
|
+
const binding = { authority_kind: `rail_${STAGES[dispatch.type]}`, invocation_id: invocation.invocation_id,
|
|
31
|
+
launch_nonce: invocation.launch_nonce, terminal_id: launch.terminal_id, provider_id: invocation.provider_id,
|
|
32
|
+
harness_id: invocation.harness_id, model_id: invocation.model_pin.model_id,
|
|
33
|
+
reasoning_configuration: invocation.model_pin.reasoning_configuration };
|
|
34
|
+
verifyAuthority('provider_process_launch', launch, { ...binding, purpose: 'terminal_launch' });
|
|
35
|
+
verifyAuthority('provider_process_completion', receipt, { ...binding, purpose: 'terminal_completion',
|
|
36
|
+
launch_receipt_digest: sha256(launch) });
|
|
37
|
+
assertTerminalCompletionContainment(receipt, launch, 'RAIL_PROCESS_CONTAINMENT_MISMATCH');
|
|
38
|
+
if (receipt.process_time_kind !== 'provider-invocation-wall-time'
|
|
39
|
+
|| typeof receipt.process_elapsed_ms !== 'number' || !Number.isFinite(receipt.process_elapsed_ms)
|
|
40
|
+
|| receipt.process_elapsed_ms < 0 || !Number.isInteger(receipt.exit_status)) {
|
|
41
|
+
fail('RAIL_PROCESS_DURATION_REQUIRED', 'provider processing requires a signed monotonic duration and observed exit, never inferred wall-clock timestamps');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function completionReceipt(record) {
|
|
46
|
+
if (['provider_process_observed', 'review_decided'].includes(record.type)) return record.payload.terminal_authority_receipt;
|
|
47
|
+
if (record.type === 'execution_recorded') return record.payload.execution_evidence?.execution?.terminal_authority_receipt;
|
|
48
|
+
if (record.type === 'adjudication_observed') return record.payload.observation?.terminal_authority_receipt;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Called with a verified canonical journal by RailEngine, not caller evidence. */
|
|
53
|
+
export function collectProviderProcessing(records, { attempt_id, verifyAuthority }) {
|
|
54
|
+
const dispatches = providerProcessDispatches(records, attempt_id);
|
|
55
|
+
if (!dispatches.length) fail('RAIL_PROCESS_COVERAGE_INCOMPLETE', 'no dispatched provider process has been observed for this attempt');
|
|
56
|
+
const seenNonces = new Set();
|
|
57
|
+
const processes = dispatches.map((dispatch) => {
|
|
58
|
+
const { invocation } = dispatch.payload;
|
|
59
|
+
// Logical invocation IDs may be reused by a retry/resumed review round.
|
|
60
|
+
// The canonical launch nonce identifies the actual process invocation.
|
|
61
|
+
if (seenNonces.has(invocation.launch_nonce)) {
|
|
62
|
+
fail('RAIL_PROCESS_DISPATCH_CONFLICT', 'canonical provider dispatch identities must be unique');
|
|
63
|
+
}
|
|
64
|
+
seenNonces.add(invocation.launch_nonce);
|
|
65
|
+
const completions = records.filter((record) => record.payload?.attempt_id === attempt_id)
|
|
66
|
+
.map((record) => ({ record, receipt: completionReceipt(record) }))
|
|
67
|
+
.filter(({ receipt }) => receipt?.launch_nonce === invocation.launch_nonce);
|
|
68
|
+
if (!completions.length) {
|
|
69
|
+
const proof = restoredExecutionProof(records, dispatch, { verifyAuthority, requireRecovered: true });
|
|
70
|
+
if (!proof) fail('RAIL_PROCESS_COVERAGE_INCOMPLETE', `provider invocation ${invocation.invocation_id} has no observed completion or canonical restored recovery`);
|
|
71
|
+
verifyAuthority('provider_process_launch', dispatch.payload.launch_receipt, {
|
|
72
|
+
purpose: 'terminal_launch', authority_kind: 'rail_execution', invocation_id: invocation.invocation_id,
|
|
73
|
+
launch_nonce: invocation.launch_nonce, provider_id: invocation.provider_id, harness_id: invocation.harness_id,
|
|
74
|
+
model_id: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration });
|
|
75
|
+
return { stage: STAGES[dispatch.type], dispatch_event_id: dispatch.event_id,
|
|
76
|
+
source_event_ids: [proof.interruption_event_id, proof.checkpoint_event_id, proof.restoration_event_id, proof.recovery_event_id],
|
|
77
|
+
invocation_id: invocation.invocation_id, launch_nonce: invocation.launch_nonce,
|
|
78
|
+
provider_id: invocation.provider_id, harness_id: invocation.harness_id,
|
|
79
|
+
model_id: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration,
|
|
80
|
+
measurement_status: 'unavailable_interrupted', process_elapsed_ms: null, exit_status: null,
|
|
81
|
+
receipt_digest: null, reconciliation: proof };
|
|
82
|
+
}
|
|
83
|
+
if (records.some((record) => record.type === 'execution_worktree_restored'
|
|
84
|
+
&& record.payload.dispatch_event_id === dispatch.event_id)) {
|
|
85
|
+
fail('RAIL_PROCESS_RECEIPT_CONFLICT', 'restored interruption must not also claim an observed completion');
|
|
86
|
+
}
|
|
87
|
+
const receipt = completions[0].receipt;
|
|
88
|
+
assertProviderProcessReceipt(dispatch, receipt, verifyAuthority);
|
|
89
|
+
if (completions.some((entry) => sha256(entry.receipt) !== sha256(receipt))) {
|
|
90
|
+
fail('RAIL_PROCESS_RECEIPT_CONFLICT', 'one provider invocation has conflicting completion receipts');
|
|
91
|
+
}
|
|
92
|
+
return { stage: STAGES[dispatch.type], dispatch_event_id: dispatch.event_id,
|
|
93
|
+
source_event_ids: [...new Set(completions.map((entry) => entry.record.event_id))],
|
|
94
|
+
invocation_id: invocation.invocation_id, launch_nonce: invocation.launch_nonce,
|
|
95
|
+
provider_id: invocation.provider_id, harness_id: invocation.harness_id,
|
|
96
|
+
model_id: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration,
|
|
97
|
+
measurement_status: 'observed',
|
|
98
|
+
process_elapsed_ms: receipt.process_elapsed_ms, exit_status: receipt.exit_status,
|
|
99
|
+
receipt_digest: sha256(receipt) };
|
|
100
|
+
});
|
|
101
|
+
const observedValue = processes.filter((entry) => entry.measurement_status === 'observed')
|
|
102
|
+
.reduce((sum, entry) => sum + entry.process_elapsed_ms, 0);
|
|
103
|
+
if (!Number.isFinite(observedValue)) fail('RAIL_PROCESS_DURATION_REQUIRED', 'summed provider processing duration exceeds the finite measurement range');
|
|
104
|
+
const unknown = processes.filter((entry) => entry.measurement_status === 'unavailable_interrupted').map((entry) => entry.launch_nonce);
|
|
105
|
+
return Object.freeze(canonicalize({ schema_version: 2, evidence_class: 'observed-provider-invocation-wall-time',
|
|
106
|
+
attempt_id, counted_stages: [...new Set(processes.map((entry) => entry.stage))],
|
|
107
|
+
unit: 'milliseconds', value: unknown.length ? null : observedValue, observed_value: observedValue,
|
|
108
|
+
coverage: unknown.length ? 'reconciled_incomplete' : 'complete', unknown_launch_nonces: unknown, processes }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Preserve unknown duration explicitly when converting units for completion. */
|
|
112
|
+
export function providerProcessingMetric(report, evidence_ref) {
|
|
113
|
+
return canonicalize({ unit: 'minutes', value: report.value === null ? null : report.value / 60000, evidence_ref,
|
|
114
|
+
coverage: { schema_version: 2, status: report.coverage, observed_value: report.observed_value / 60000,
|
|
115
|
+
unknown_launch_nonces: report.unknown_launch_nonces,
|
|
116
|
+
reconciliation_proofs: report.processes.filter((entry) => entry.measurement_status === 'unavailable_interrupted')
|
|
117
|
+
.map((entry) => entry.reconciliation) } });
|
|
118
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ContractError, assertDarwinTerminalLaunch, assertDarwinTerminalCompletion } from '@chati/core';
|
|
2
|
+
|
|
3
|
+
function reject(code, message) { throw new ContractError(code, message); }
|
|
4
|
+
function typed(check, code) {
|
|
5
|
+
try { check(); } catch (error) { reject(code, `Darwin terminal contract rejected: ${error.message}`); }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Authentication remains with RailEngine's configured authority verifier. */
|
|
9
|
+
export function assertTerminalLaunchContainment(receipt, code) {
|
|
10
|
+
if (receipt.process_containment_kind === 'darwin_scoped_process_group_v1') {
|
|
11
|
+
typed(() => assertDarwinTerminalLaunch(receipt), code);
|
|
12
|
+
} else if (receipt.process_containment_kind !== 'bubblewrap_pid_namespace_v1'
|
|
13
|
+
|| Object.hasOwn(receipt, 'darwin_launch_binding')) reject(code, 'unsupported or mixed terminal containment variant');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertTerminalCompletionContainment(receipt, launch, code) {
|
|
17
|
+
if (launch.process_containment_kind === 'darwin_scoped_process_group_v1') {
|
|
18
|
+
typed(() => assertDarwinTerminalCompletion(receipt, launch), code);
|
|
19
|
+
} else if (Object.hasOwn(launch, 'darwin_launch_binding')
|
|
20
|
+
|| Object.hasOwn(receipt, 'darwin_completion_observation') || Object.hasOwn(receipt, 'darwin_launch_binding_digest')) {
|
|
21
|
+
reject(code, 'Darwin completion cannot be attached to another containment variant');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createPublicKey, randomUUID, verify as verifySignature } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
|
-
closeSync, constants,
|
|
4
|
-
|
|
3
|
+
closeSync, constants, fstatSync, fsyncSync, ftruncateSync,
|
|
4
|
+
openSync, readSync, writeSync,
|
|
5
5
|
} from 'node:fs';
|
|
6
6
|
import { hostname } from 'node:os';
|
|
7
|
-
import { basename, dirname,
|
|
8
|
-
import {
|
|
7
|
+
import { basename, dirname, parse, resolve, sep } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
captureHostBootMarker, ContractError, canonicalize, hostBootMarkerChanged, sha256, openAtSync, mkdirAtSync,
|
|
10
|
+
lstatAtSync, renameAtSync, unlinkAtSync,
|
|
11
|
+
} from '@chati/core';
|
|
9
12
|
|
|
10
13
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
11
14
|
const RELEASE_STATE_MAX_BYTES = 64 * 1024 * 1024;
|
|
@@ -324,16 +327,12 @@ function sameSnapshot(left, right) {
|
|
|
324
327
|
&& left?.ctimeMs === right?.ctimeMs;
|
|
325
328
|
}
|
|
326
329
|
|
|
327
|
-
function descriptorDirectoryPath(descriptor) {
|
|
328
|
-
return `/proc/self/fd/${descriptor}`;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
330
|
function openPinnedReleaseDirectory(path, { create = false } = {}) {
|
|
332
331
|
const absolute = resolve(path);
|
|
333
|
-
if (process.platform
|
|
332
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
334
333
|
fail(
|
|
335
334
|
'UNSUPPORTED_RELEASE_STATE_PLATFORM',
|
|
336
|
-
|
|
335
|
+
`durable release state requires descriptor-relative filesystem access; unsupported platform: ${process.platform}`,
|
|
337
336
|
);
|
|
338
337
|
}
|
|
339
338
|
const root = parse(absolute).root;
|
|
@@ -341,10 +340,9 @@ function openPinnedReleaseDirectory(path, { create = false } = {}) {
|
|
|
341
340
|
let descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
342
341
|
try {
|
|
343
342
|
for (const segment of segments) {
|
|
344
|
-
const childPath = join(descriptorDirectoryPath(descriptor), segment);
|
|
345
343
|
let childDescriptor;
|
|
346
344
|
try {
|
|
347
|
-
childDescriptor =
|
|
345
|
+
childDescriptor = openAtSync(descriptor, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
348
346
|
} catch (error) {
|
|
349
347
|
if (error?.code !== 'ENOENT' || !create) {
|
|
350
348
|
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
@@ -352,9 +350,9 @@ function openPinnedReleaseDirectory(path, { create = false } = {}) {
|
|
|
352
350
|
}
|
|
353
351
|
throw error;
|
|
354
352
|
}
|
|
355
|
-
try {
|
|
353
|
+
try { mkdirAtSync(descriptor, segment, { mode: 0o700 }); }
|
|
356
354
|
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
357
|
-
childDescriptor =
|
|
355
|
+
childDescriptor = openAtSync(descriptor, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
358
356
|
}
|
|
359
357
|
const childStat = fstatSync(childDescriptor);
|
|
360
358
|
if (!childStat.isDirectory()) {
|
|
@@ -366,7 +364,6 @@ function openPinnedReleaseDirectory(path, { create = false } = {}) {
|
|
|
366
364
|
}
|
|
367
365
|
return Object.freeze({
|
|
368
366
|
absolute, descriptor, identity: fstatSync(descriptor),
|
|
369
|
-
reference: descriptorDirectoryPath(descriptor),
|
|
370
367
|
});
|
|
371
368
|
} catch (error) {
|
|
372
369
|
closeSync(descriptor);
|
|
@@ -388,15 +385,11 @@ function assertPinnedReleaseDirectory(storage) {
|
|
|
388
385
|
closeSync(canonical.descriptor);
|
|
389
386
|
}
|
|
390
387
|
|
|
391
|
-
function
|
|
388
|
+
function openReleaseEntry(storage, name, flags, mode) {
|
|
392
389
|
if (basename(name) !== name || name === '.' || name === '..') {
|
|
393
390
|
fail('UNSAFE_RELEASE_STATE_FILE', 'release state entry must be one path segment');
|
|
394
391
|
}
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function openReleaseEntry(storage, name, flags, mode) {
|
|
399
|
-
try { return openSync(releaseEntryPath(storage, name), flags, mode); }
|
|
392
|
+
try { return openAtSync(storage.descriptor, name, flags, mode); }
|
|
400
393
|
catch (error) {
|
|
401
394
|
if (['ELOOP', 'ENXIO', 'EISDIR'].includes(error?.code)) {
|
|
402
395
|
fail('UNSAFE_RELEASE_STATE_FILE', 'release state must not be a symlink, FIFO or directory');
|
|
@@ -471,8 +464,6 @@ function writeReleaseCheckpoint(storage, stateName, checkpoint) {
|
|
|
471
464
|
if (bytes.length > RELEASE_CHECKPOINT_MAX_BYTES) fail('INVALID_RELEASE_STATE', 'release checkpoint exceeds its safety limit');
|
|
472
465
|
const targetName = checkpointName(stateName);
|
|
473
466
|
const temporaryName = `${targetName}.${process.pid}.${randomUUID()}.tmp`;
|
|
474
|
-
const temporaryPath = releaseEntryPath(storage, temporaryName);
|
|
475
|
-
const targetPath = releaseEntryPath(storage, targetName);
|
|
476
467
|
let fd;
|
|
477
468
|
try {
|
|
478
469
|
assertPinnedReleaseDirectory(storage);
|
|
@@ -487,12 +478,12 @@ function writeReleaseCheckpoint(storage, stateName, checkpoint) {
|
|
|
487
478
|
closeSync(fd);
|
|
488
479
|
fd = undefined;
|
|
489
480
|
assertPinnedReleaseDirectory(storage);
|
|
490
|
-
|
|
481
|
+
renameAtSync(storage.descriptor, temporaryName, storage.descriptor, targetName);
|
|
491
482
|
fsyncSync(storage.descriptor);
|
|
492
483
|
assertPinnedReleaseDirectory(storage);
|
|
493
484
|
} finally {
|
|
494
485
|
if (fd !== undefined) closeSync(fd);
|
|
495
|
-
try {
|
|
486
|
+
try { unlinkAtSync(storage.descriptor, temporaryName); } catch { /* temporary may already be renamed or absent */ }
|
|
496
487
|
}
|
|
497
488
|
}
|
|
498
489
|
|
|
@@ -543,22 +534,24 @@ function readLockOwner(storage, name, { assertCanonical = true } = {}) {
|
|
|
543
534
|
|
|
544
535
|
function recoverStaleLock(storage, name) {
|
|
545
536
|
assertPinnedReleaseDirectory(storage);
|
|
546
|
-
const path = releaseEntryPath(storage, name);
|
|
547
537
|
let stat;
|
|
548
|
-
try { stat =
|
|
538
|
+
try { stat = lstatAtSync(storage.descriptor, name); } catch (error) { return error?.code === 'ENOENT'; }
|
|
549
539
|
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) return false;
|
|
550
540
|
const owner = readLockOwner(storage, name);
|
|
551
|
-
|
|
541
|
+
// A rebooted host may recycle the owner's pid onto an unrelated live process.
|
|
542
|
+
// Prove the reboot from the boot marker before ever trusting PID liveness.
|
|
543
|
+
if (owner?.hostname === hostname() && !hostBootMarkerChanged(owner.host_boot_marker, captureHostBootMarker())
|
|
544
|
+
&& processIsAlive(owner.pid)) return false;
|
|
552
545
|
// A PID from another host cannot be proven dead locally. Never steal a
|
|
553
546
|
// remote lock automatically; explicit operator reconciliation is required.
|
|
554
547
|
if (owner?.hostname && owner.hostname !== hostname()) return false;
|
|
555
548
|
if (!owner && Date.now() - stat.mtimeMs < RELEASE_LOCK_STALE_AFTER_MS) return false;
|
|
556
549
|
const recoveryName = `${name}.recovery`;
|
|
557
|
-
const recoveryPath = releaseEntryPath(storage, recoveryName);
|
|
558
550
|
let recoveryFd;
|
|
559
551
|
try {
|
|
560
|
-
recoveryFd =
|
|
561
|
-
const
|
|
552
|
+
recoveryFd = openAtSync(storage.descriptor, recoveryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
553
|
+
const recoveryMarker = captureHostBootMarker();
|
|
554
|
+
const bytes = Buffer.from(JSON.stringify({ pid: process.pid, hostname: hostname(), token: randomUUID(), ...(recoveryMarker ? { host_boot_marker: recoveryMarker } : {}) }));
|
|
562
555
|
writeSync(recoveryFd, bytes, 0, bytes.length);
|
|
563
556
|
fsyncSync(recoveryFd);
|
|
564
557
|
} catch {
|
|
@@ -566,24 +559,28 @@ function recoverStaleLock(storage, name) {
|
|
|
566
559
|
return false;
|
|
567
560
|
}
|
|
568
561
|
const quarantineName = `${name}.stale.${process.pid}.${randomUUID()}`;
|
|
569
|
-
const quarantine = releaseEntryPath(storage, quarantineName);
|
|
570
562
|
try {
|
|
571
|
-
const current =
|
|
563
|
+
const current = lstatAtSync(storage.descriptor, name);
|
|
572
564
|
const currentOwner = readLockOwner(storage, name);
|
|
573
565
|
if (!sameIdentity(stat, current) || currentOwner?.token !== owner?.token) return false;
|
|
574
|
-
|
|
575
|
-
const quarantined =
|
|
566
|
+
renameAtSync(storage.descriptor, name, storage.descriptor, quarantineName);
|
|
567
|
+
const quarantined = lstatAtSync(storage.descriptor, quarantineName);
|
|
576
568
|
const quarantinedOwner = readLockOwner(storage, quarantineName);
|
|
577
569
|
if (!sameIdentity(stat, quarantined) || quarantinedOwner?.token !== owner?.token) {
|
|
578
|
-
try {
|
|
570
|
+
try { lstatAtSync(storage.descriptor, name); }
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (error?.code === 'ENOENT') {
|
|
573
|
+
try { renameAtSync(storage.descriptor, quarantineName, storage.descriptor, name); } catch { /* preserve quarantine fail-closed */ }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
579
576
|
return false;
|
|
580
577
|
}
|
|
581
|
-
|
|
578
|
+
unlinkAtSync(storage.descriptor, quarantineName);
|
|
582
579
|
return true;
|
|
583
580
|
} catch (error) { return error?.code === 'ENOENT'; }
|
|
584
581
|
finally {
|
|
585
582
|
if (recoveryFd !== undefined) closeSync(recoveryFd);
|
|
586
|
-
try {
|
|
583
|
+
try { unlinkAtSync(storage.descriptor, recoveryName); } catch { /* fail closed if recovery ownership changed */ }
|
|
587
584
|
}
|
|
588
585
|
}
|
|
589
586
|
|
|
@@ -591,7 +588,6 @@ function acquireReleaseLock(statePath) {
|
|
|
591
588
|
const storage = openPinnedReleaseDirectory(dirname(statePath), { create: true });
|
|
592
589
|
const stateName = basename(statePath);
|
|
593
590
|
const name = lockName(stateName);
|
|
594
|
-
const path = releaseEntryPath(storage, name);
|
|
595
591
|
let fd;
|
|
596
592
|
let token;
|
|
597
593
|
try {
|
|
@@ -599,7 +595,7 @@ function acquireReleaseLock(statePath) {
|
|
|
599
595
|
assertPinnedReleaseDirectory(storage);
|
|
600
596
|
let candidate;
|
|
601
597
|
try {
|
|
602
|
-
candidate =
|
|
598
|
+
candidate = openAtSync(storage.descriptor, name, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
603
599
|
} catch (error) {
|
|
604
600
|
if (error?.code !== 'EEXIST') throw error;
|
|
605
601
|
if (recoverStaleLock(storage, name)) continue;
|
|
@@ -608,7 +604,8 @@ function acquireReleaseLock(statePath) {
|
|
|
608
604
|
}
|
|
609
605
|
token = `${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
610
606
|
try {
|
|
611
|
-
const
|
|
607
|
+
const ownerMarker = captureHostBootMarker();
|
|
608
|
+
const bytes = Buffer.from(JSON.stringify({ schema_version: 1, pid: process.pid, hostname: hostname(), token, acquired_at: new Date().toISOString(), ...(ownerMarker ? { host_boot_marker: ownerMarker } : {}) }));
|
|
612
609
|
writeSync(candidate, bytes, 0, bytes.length);
|
|
613
610
|
fsyncSync(candidate);
|
|
614
611
|
fsyncSync(storage.descriptor);
|
|
@@ -616,7 +613,7 @@ function acquireReleaseLock(statePath) {
|
|
|
616
613
|
break;
|
|
617
614
|
} catch (error) {
|
|
618
615
|
closeSync(candidate);
|
|
619
|
-
try {
|
|
616
|
+
try { unlinkAtSync(storage.descriptor, name); } catch { /* the acquisition failed closed */ }
|
|
620
617
|
throw error;
|
|
621
618
|
}
|
|
622
619
|
}
|
|
@@ -633,7 +630,7 @@ function acquireReleaseLock(statePath) {
|
|
|
633
630
|
try { assertPinnedReleaseDirectory(storage); } catch (error) { safetyError = error; }
|
|
634
631
|
try {
|
|
635
632
|
if (readLockOwner(storage, name, { assertCanonical: false })?.token === token) {
|
|
636
|
-
|
|
633
|
+
unlinkAtSync(storage.descriptor, name);
|
|
637
634
|
fsyncSync(storage.descriptor);
|
|
638
635
|
}
|
|
639
636
|
} finally { closeSync(storage.descriptor); }
|
|
@@ -7,6 +7,21 @@ const DISPOSITIONS = new Set(['accepted', 'rejected', 'unresolved']);
|
|
|
7
7
|
const VERDICTS = new Set(['accept', 'rework', 'required-human-decision', 'blocked']);
|
|
8
8
|
const AUTHORITY_REASONS = new Set(['scope', 'security', 'privacy', 'cost', 'external-action']);
|
|
9
9
|
const IMPASSE_KINDS = new Set(['none', 'technical']);
|
|
10
|
+
const COUNCIL_REVIEW_VERDICTS = new Set(['accept', 'rework']);
|
|
11
|
+
const COUNCIL_BLOCKING_SEVERITIES = new Set(['critical', 'high']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Product-level default for planning-quality gates. It deliberately expresses
|
|
15
|
+
* a range rather than inventing a third provider when an installation has
|
|
16
|
+
* only two selected providers. One-provider installations cannot satisfy the
|
|
17
|
+
* autonomous council quorum.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_COUNCIL_POLICY = Object.freeze({
|
|
20
|
+
min_members: 2,
|
|
21
|
+
max_members: 3,
|
|
22
|
+
require_distinct_providers: true,
|
|
23
|
+
blocking_severities: Object.freeze(['critical', 'high']),
|
|
24
|
+
});
|
|
10
25
|
|
|
11
26
|
function fail(code, message, details = {}) {
|
|
12
27
|
throw new ContractError(code, message, details);
|
|
@@ -126,6 +141,214 @@ function nearestReviewReasoning(binding, model) {
|
|
|
126
141
|
})[0] || null;
|
|
127
142
|
}
|
|
128
143
|
|
|
144
|
+
function reviewPriority(model) {
|
|
145
|
+
return model.routing_priority?.['council:review']
|
|
146
|
+
?? model.routing_priority?.['planning:council']
|
|
147
|
+
?? model.routing_priority?.review
|
|
148
|
+
?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateCouncilPolicy(policy = DEFAULT_COUNCIL_POLICY) {
|
|
152
|
+
assertObject(policy, 'INVALID_COUNCIL_POLICY', 'council policy');
|
|
153
|
+
const min = policy.min_members;
|
|
154
|
+
const max = policy.max_members;
|
|
155
|
+
if (!Number.isInteger(min) || min < 2) {
|
|
156
|
+
fail('INVALID_COUNCIL_POLICY', 'council policy requires at least two members');
|
|
157
|
+
}
|
|
158
|
+
if (!Number.isInteger(max) || max < min || max > 3) {
|
|
159
|
+
fail('INVALID_COUNCIL_POLICY', 'council policy max_members must be between min_members and 3');
|
|
160
|
+
}
|
|
161
|
+
if (policy.require_distinct_providers !== true) {
|
|
162
|
+
fail('INVALID_COUNCIL_POLICY', 'council policy requires distinct providers');
|
|
163
|
+
}
|
|
164
|
+
const severities = policy.blocking_severities;
|
|
165
|
+
if (!Array.isArray(severities) || severities.length !== COUNCIL_BLOCKING_SEVERITIES.size
|
|
166
|
+
|| !severities.every((severity) => COUNCIL_BLOCKING_SEVERITIES.has(severity))
|
|
167
|
+
|| new Set(severities).size !== COUNCIL_BLOCKING_SEVERITIES.size) {
|
|
168
|
+
fail('INVALID_COUNCIL_POLICY', 'council blocking severities must contain exactly critical and high');
|
|
169
|
+
}
|
|
170
|
+
return freeze(canonicalize({
|
|
171
|
+
min_members: min,
|
|
172
|
+
max_members: max,
|
|
173
|
+
require_distinct_providers: true,
|
|
174
|
+
blocking_severities: [...new Set(severities)].sort(),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Validates the public policy without selecting a provider. */
|
|
179
|
+
export function validateCouncilGatePolicy(policy) {
|
|
180
|
+
return validateCouncilPolicy(policy);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function selectBestReviewerPerProvider({ installation, snapshot, builder_identity, clock }) {
|
|
184
|
+
assertObject(installation, 'INVALID_COUNCIL_SELECTION', 'installation');
|
|
185
|
+
assertObject(snapshot, 'INVALID_COUNCIL_SELECTION', 'snapshot');
|
|
186
|
+
if (builder_identity !== undefined) validateIdentity(builder_identity, 'builder_identity');
|
|
187
|
+
const byProvider = new Map();
|
|
188
|
+
for (const binding of installation.enabled_providers || []) {
|
|
189
|
+
for (const modelId of binding.allowed_models || []) {
|
|
190
|
+
const model = snapshot.models?.find((candidate) =>
|
|
191
|
+
candidate.provider_id === binding.provider_id && candidate.model_id === modelId);
|
|
192
|
+
if (!model?.actions?.includes('review')) continue;
|
|
193
|
+
const reasoning = nearestReviewReasoning(binding, model);
|
|
194
|
+
if (!reasoning) continue;
|
|
195
|
+
const reviewer_identity = { provider_id: binding.provider_id, model_id: model.model_id };
|
|
196
|
+
const invocation = {
|
|
197
|
+
provider_id: binding.provider_id,
|
|
198
|
+
harness_id: binding.harness_id,
|
|
199
|
+
action: 'review',
|
|
200
|
+
model_pin: {
|
|
201
|
+
model_id: model.model_id,
|
|
202
|
+
catalog_snapshot_ref: snapshot.snapshot_id,
|
|
203
|
+
reasoning_configuration: reasoning,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
assertEligibleInvocation({ installation, snapshot, invocation, clock });
|
|
207
|
+
const candidate = {
|
|
208
|
+
invocation,
|
|
209
|
+
reviewer_identity,
|
|
210
|
+
priority: reviewPriority(model),
|
|
211
|
+
adjudication_priority: model.adjudication_priority ?? 0,
|
|
212
|
+
independence_level: builder_identity
|
|
213
|
+
? computeIndependenceLevel({ builder_identity, reviewer_identity })
|
|
214
|
+
: null,
|
|
215
|
+
};
|
|
216
|
+
const current = byProvider.get(binding.provider_id);
|
|
217
|
+
if (!current
|
|
218
|
+
|| candidate.priority > current.priority
|
|
219
|
+
|| (candidate.priority === current.priority && candidate.adjudication_priority > current.adjudication_priority)
|
|
220
|
+
|| (candidate.priority === current.priority && candidate.adjudication_priority === current.adjudication_priority
|
|
221
|
+
&& `${candidate.invocation.harness_id}:${candidate.reviewer_identity.model_id}`
|
|
222
|
+
.localeCompare(`${current.invocation.harness_id}:${current.reviewer_identity.model_id}`) < 0)) {
|
|
223
|
+
byProvider.set(binding.provider_id, candidate);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return [...byProvider.values()].sort((left, right) =>
|
|
228
|
+
right.priority - left.priority
|
|
229
|
+
|| right.adjudication_priority - left.adjudication_priority
|
|
230
|
+
|| `${left.invocation.provider_id}:${left.invocation.harness_id}:${left.reviewer_identity.model_id}`
|
|
231
|
+
.localeCompare(`${right.invocation.provider_id}:${right.invocation.harness_id}:${right.reviewer_identity.model_id}`)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Selects the autonomous planning council from the installation allow-list.
|
|
237
|
+
* It chooses at most one model per provider and never pads the council with an
|
|
238
|
+
* unselected vendor. Two selected providers are sufficient; a third improves
|
|
239
|
+
* coverage when available.
|
|
240
|
+
*/
|
|
241
|
+
export function selectCouncilReviewers({
|
|
242
|
+
installation,
|
|
243
|
+
snapshot,
|
|
244
|
+
builder_identity,
|
|
245
|
+
policy = DEFAULT_COUNCIL_POLICY,
|
|
246
|
+
clock = () => new Date(),
|
|
247
|
+
} = {}) {
|
|
248
|
+
const normalizedPolicy = validateCouncilPolicy(policy);
|
|
249
|
+
const candidates = selectBestReviewerPerProvider({ installation, snapshot, builder_identity, clock });
|
|
250
|
+
if (candidates.length < normalizedPolicy.min_members) {
|
|
251
|
+
fail(
|
|
252
|
+
'COUNCIL_QUORUM_UNAVAILABLE',
|
|
253
|
+
`autonomous council requires ${normalizedPolicy.min_members} distinct selected providers with review capability, but only ${candidates.length} are eligible`,
|
|
254
|
+
{
|
|
255
|
+
required_members: normalizedPolicy.min_members,
|
|
256
|
+
eligible_providers: candidates.map(({ invocation }) => invocation.provider_id),
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const members = candidates.slice(0, normalizedPolicy.max_members).map((candidate, index) => ({
|
|
261
|
+
member_id: `council-${index + 1}-${candidate.invocation.provider_id}-${candidate.reviewer_identity.model_id}`,
|
|
262
|
+
...candidate.invocation,
|
|
263
|
+
reviewer_identity: candidate.reviewer_identity,
|
|
264
|
+
...(candidate.independence_level ? { independence_level: candidate.independence_level } : {}),
|
|
265
|
+
}));
|
|
266
|
+
return freeze(canonicalize({
|
|
267
|
+
policy: normalizedPolicy,
|
|
268
|
+
member_count: members.length,
|
|
269
|
+
members,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function validateCouncilFinding(finding) {
|
|
274
|
+
validateFinding(finding);
|
|
275
|
+
return finding;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Validates one member result. Low and medium findings are durable evidence
|
|
280
|
+
* but do not turn an otherwise successful council decision into rework.
|
|
281
|
+
*/
|
|
282
|
+
export function validateCouncilMemberReview(result, { blocking_severities = DEFAULT_COUNCIL_POLICY.blocking_severities } = {}) {
|
|
283
|
+
assertObject(result, 'INVALID_COUNCIL_REVIEW', 'council member review');
|
|
284
|
+
assertString(result.member_id, 'INVALID_COUNCIL_REVIEW', 'council member review.member_id');
|
|
285
|
+
validateIdentity(result.reviewer_identity, 'council member review.reviewer_identity');
|
|
286
|
+
if (!COUNCIL_REVIEW_VERDICTS.has(result.verdict)) {
|
|
287
|
+
fail('INVALID_COUNCIL_REVIEW', 'council member review.verdict must be accept or rework');
|
|
288
|
+
}
|
|
289
|
+
if (!Array.isArray(result.findings)) fail('INVALID_COUNCIL_REVIEW', 'council member review.findings must be an array');
|
|
290
|
+
const findingIds = new Set();
|
|
291
|
+
for (const finding of result.findings) {
|
|
292
|
+
validateCouncilFinding(finding);
|
|
293
|
+
if (findingIds.has(finding.finding_id)) {
|
|
294
|
+
fail('DUPLICATE_COUNCIL_FINDING_ID', `council member ${result.member_id} returned finding ${finding.finding_id} more than once`);
|
|
295
|
+
}
|
|
296
|
+
findingIds.add(finding.finding_id);
|
|
297
|
+
}
|
|
298
|
+
const acceptedBlockingFindings = result.findings.filter((finding) =>
|
|
299
|
+
finding.disposition === 'accepted' && blocking_severities.includes(finding.severity));
|
|
300
|
+
if (acceptedBlockingFindings.length > 0 && result.verdict !== 'rework') {
|
|
301
|
+
fail('COUNCIL_BLOCKER_REQUIRES_REWORK', 'an accepted high or critical council finding requires rework');
|
|
302
|
+
}
|
|
303
|
+
return freeze(canonicalize(result));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Applies unanimity to absence of blockers. This is intentionally not a vote:
|
|
308
|
+
* any accepted high or critical finding blocks, while lower-severity findings
|
|
309
|
+
* stay in the immutable decision trail without blocking the gate.
|
|
310
|
+
*/
|
|
311
|
+
export function decideCouncilGate({ selection, reviews } = {}) {
|
|
312
|
+
assertObject(selection, 'INVALID_COUNCIL_SELECTION', 'council selection');
|
|
313
|
+
const policy = validateCouncilPolicy(selection.policy);
|
|
314
|
+
if (!Array.isArray(selection.members) || selection.members.length < policy.min_members || selection.members.length > policy.max_members) {
|
|
315
|
+
fail('INVALID_COUNCIL_SELECTION', 'council selection does not satisfy its policy quorum');
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(reviews)) fail('INVALID_COUNCIL_REVIEW', 'council reviews must be an array');
|
|
318
|
+
const expected = new Map(selection.members.map((member) => [member.member_id, member]));
|
|
319
|
+
if (reviews.length !== expected.size) {
|
|
320
|
+
fail('COUNCIL_QUORUM_INCOMPLETE', 'every selected council member must return one result before the gate can decide');
|
|
321
|
+
}
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
const acceptedBlockingFindings = [];
|
|
324
|
+
const nonBlockingFindings = [];
|
|
325
|
+
const normalizedReviews = reviews.map((result) => {
|
|
326
|
+
const validated = validateCouncilMemberReview(result, { blocking_severities: policy.blocking_severities });
|
|
327
|
+
const member = expected.get(validated.member_id);
|
|
328
|
+
if (!member) fail('COUNCIL_MEMBER_UNEXPECTED', `review returned by unselected council member ${validated.member_id}`);
|
|
329
|
+
if (seen.has(validated.member_id)) fail('DUPLICATE_COUNCIL_MEMBER_REVIEW', `council member ${validated.member_id} returned more than once`);
|
|
330
|
+
seen.add(validated.member_id);
|
|
331
|
+
if (member.reviewer_identity.provider_id !== validated.reviewer_identity.provider_id
|
|
332
|
+
|| member.reviewer_identity.model_id !== validated.reviewer_identity.model_id) {
|
|
333
|
+
fail('COUNCIL_MEMBER_IDENTITY_MISMATCH', `council member ${validated.member_id} did not use its selected model`);
|
|
334
|
+
}
|
|
335
|
+
for (const finding of validated.findings) {
|
|
336
|
+
const entry = { member_id: validated.member_id, ...finding };
|
|
337
|
+
if (finding.disposition === 'accepted' && policy.blocking_severities.includes(finding.severity)) acceptedBlockingFindings.push(entry);
|
|
338
|
+
else if (finding.disposition === 'accepted') nonBlockingFindings.push(entry);
|
|
339
|
+
}
|
|
340
|
+
return validated;
|
|
341
|
+
});
|
|
342
|
+
if (seen.size !== expected.size) fail('COUNCIL_QUORUM_INCOMPLETE', 'one or more selected council members did not return a result');
|
|
343
|
+
return freeze(canonicalize({
|
|
344
|
+
verdict: acceptedBlockingFindings.length === 0 ? 'accept' : 'rework',
|
|
345
|
+
member_count: selection.members.length,
|
|
346
|
+
reviews: normalizedReviews,
|
|
347
|
+
blocking_findings: acceptedBlockingFindings,
|
|
348
|
+
non_blocking_findings: nonBlockingFindings,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
129
352
|
/** Selects the one canonical reviewer from the signed selected-only capability projection. */
|
|
130
353
|
export function selectCanonicalReviewer({ installation, snapshot, builder_identity, clock = () => new Date() } = {}) {
|
|
131
354
|
assertObject(installation, 'INVALID_REVIEW_SELECTION', 'installation');
|