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,71 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { ContractError } from '@chati/core';
|
|
3
|
+
import { assertBrowserCapabilityForTask } from '@chati/browser-capability';
|
|
4
|
+
|
|
5
|
+
export const BROWSER_OBSERVER_TOOL_ID = 'chati-playwright-api-v1';
|
|
6
|
+
function fail(message, code = 'BROWSER_OBSERVATION_INVALID') { throw new ContractError(code, message); }
|
|
7
|
+
function exact(value, fields, label) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
9
|
+
|| Object.keys(value).length !== fields.length || Object.keys(value).some((key) => !fields.includes(key))) fail(`${label} has an invalid closed schema`);
|
|
10
|
+
}
|
|
11
|
+
function text(value, limit = 8192) { return typeof value === 'string' && value.length > 0 && !value.includes('\0') && Buffer.byteLength(value) <= limit; }
|
|
12
|
+
function localPath(value) { return text(value) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\'); }
|
|
13
|
+
function executable(value) {
|
|
14
|
+
exact(value, ['executable_path', 'executable_sha256'], 'executable binding');
|
|
15
|
+
if (!text(value.executable_path) || !isAbsolute(value.executable_path) || resolve(value.executable_path) !== value.executable_path
|
|
16
|
+
|| !/^[a-f0-9]{64}$/.test(value.executable_sha256)) fail('browser tools require absolute paths and SHA-256 bindings');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Shared schema for the frozen plan and its resolved, observed execution. */
|
|
20
|
+
export function validateBrowserObservationPlan(input, { frozen = false } = {}) {
|
|
21
|
+
exact(input, ['application', 'browser', 'hostname', 'ready_path', 'timeout_ms', 'steps'], 'browser observation plan');
|
|
22
|
+
exact(input.application, ['executable_binding', 'args', frozen ? 'stdin_ref' : 'stdin', 'port',
|
|
23
|
+
...(Object.hasOwn(input.application ?? {}, 'listener_transport') ? ['listener_transport'] : [])], 'application');
|
|
24
|
+
executable(input.application.executable_binding);
|
|
25
|
+
const app = input.application;
|
|
26
|
+
if (Object.hasOwn(app, 'listener_transport') && app.listener_transport !== 'darwin_prebound_tcp_v1') fail('unsupported explicit application listener transport');
|
|
27
|
+
const scriptValid = frozen ? /^local-artifact:\/\/sha256\/[a-f0-9]{64}$/.test(app.stdin_ref || '') : text(app.stdin, 1024 * 1024);
|
|
28
|
+
if (!Array.isArray(app.args) || app.args.length > 64 || app.args.some((arg) => typeof arg !== 'string' || arg.includes('\0') || Buffer.byteLength(arg) > 8192)
|
|
29
|
+
|| !scriptValid || !Number.isInteger(app.port) || app.port < 1024 || app.port > 65535) fail('application requires bounded literal arguments, frozen startup content and port');
|
|
30
|
+
exact(input.browser, ['executable_binding', 'playwright_version'], 'browser');
|
|
31
|
+
executable(input.browser.executable_binding);
|
|
32
|
+
if (!/^\d+\.\d+\.\d+$/.test(input.browser.playwright_version)) fail('browser driver version must be explicit');
|
|
33
|
+
if (!text(input.hostname, 253) || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(input.hostname)
|
|
34
|
+
|| !localPath(input.ready_path) || !Number.isInteger(input.timeout_ms) || input.timeout_ms < 1 || input.timeout_ms > 1800000) fail('browser requires a DNS hostname, local readiness path and bounded timeout');
|
|
35
|
+
if (!Array.isArray(input.steps) || input.steps.length < 2 || input.steps.length > 64 || input.steps[0]?.action !== 'navigate') fail('browser requires navigation and a bounded nonempty scenario');
|
|
36
|
+
let assertions = 0;
|
|
37
|
+
for (const step of input.steps) {
|
|
38
|
+
const fields = { navigate: ['action', 'path'], click: ['action', 'selector'], fill: ['action', 'selector', 'text'],
|
|
39
|
+
assert_text: ['action', 'selector', 'text'], assert_visible: ['action', 'selector'] }[step?.action];
|
|
40
|
+
if (!fields) fail('unsupported browser action');
|
|
41
|
+
exact(step, fields, 'browser step');
|
|
42
|
+
if (step.action === 'navigate' ? !localPath(step.path) : !text(step.selector, 2048)) fail('browser step has an invalid path or selector');
|
|
43
|
+
if ('text' in step && (typeof step.text !== 'string' || step.text.includes('\0') || Buffer.byteLength(step.text) > 8192)) fail('browser input and assertion text must be bounded');
|
|
44
|
+
if (step.action.startsWith('assert_')) assertions += 1;
|
|
45
|
+
}
|
|
46
|
+
if (assertions === 0) fail('browser scenario must contain an actual assertion');
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateTaskBrowserVerificationPlan(task, { required = false } = {}) {
|
|
51
|
+
const plan = task?.browser_verification_plan;
|
|
52
|
+
if (plan === undefined) {
|
|
53
|
+
if (required) fail('browser verification requires a council-approved frozen plan', 'BROWSER_VERIFICATION_PLAN_REQUIRED');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
exact(plan, ['schema_version', 'tool_id', 'evidence_kind', 'criterion_refs', 'observation'], 'frozen browser verification plan');
|
|
58
|
+
const policy = assertBrowserCapabilityForTask(task);
|
|
59
|
+
if (task.requires_browser !== true || !policy || policy.mode !== 'unauthenticated'
|
|
60
|
+
|| policy.tool !== 'approved-other' || policy.approved_tool_id !== BROWSER_OBSERVER_TOOL_ID
|
|
61
|
+
|| plan.tool_id !== BROWSER_OBSERVER_TOOL_ID || plan.schema_version !== 1 || plan.evidence_kind !== 'dom-assertions') {
|
|
62
|
+
fail('the local observer requires an explicit unauthenticated Playwright API policy and DOM-assertion evidence, not visual-quality approval');
|
|
63
|
+
}
|
|
64
|
+
const known = new Set(task.acceptance_criteria_refs);
|
|
65
|
+
if (known.size === 0 || !Array.isArray(plan.criterion_refs) || plan.criterion_refs.length !== known.size
|
|
66
|
+
|| new Set(plan.criterion_refs).size !== known.size || plan.criterion_refs.some((ref) => !known.has(ref))) fail('browser scenario must bind every task acceptance criterion exactly once');
|
|
67
|
+
validateBrowserObservationPlan(plan.observation, { frozen: true });
|
|
68
|
+
if (!policy.allowed_domains.some((domain) => plan.observation.hostname === domain || plan.observation.hostname.endsWith(`.${domain}`))) fail('browser scenario hostname is outside the task policy');
|
|
69
|
+
} catch (error) { fail(error.message, 'BROWSER_VERIFICATION_PLAN_INVALID'); }
|
|
70
|
+
return plan;
|
|
71
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { ContractError, canonicalize, sha256 } from '@chati/core';
|
|
3
3
|
import { assertBrowserCapabilityForTask } from '@chati/browser-capability';
|
|
4
|
+
import { validatePlanningMaterial } from './planning-material.js';
|
|
5
|
+
import { validateTaskVerificationPlan } from './verification-plan.js';
|
|
6
|
+
import { validateTaskBrowserVerificationPlan } from './browser-verification-plan.js';
|
|
7
|
+
export { validatePlanningMaterial, PLANNING_MATERIAL_MAX_BYTES } from './planning-material.js';
|
|
8
|
+
export { validateTaskVerificationPlan } from './verification-plan.js';
|
|
9
|
+
export { validateTaskBrowserVerificationPlan, validateBrowserObservationPlan, BROWSER_OBSERVER_TOOL_ID } from './browser-verification-plan.js';
|
|
4
10
|
|
|
5
11
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
6
12
|
const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
|
@@ -64,6 +70,21 @@ function validateTask(task, knownTaskIds) {
|
|
|
64
70
|
for (const field of ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']) {
|
|
65
71
|
assertString(task.execution_binding[field], 'TASK_EXECUTION_BINDING_REQUIRED', `task.execution_binding.${field}`);
|
|
66
72
|
}
|
|
73
|
+
if (task.review_binding !== undefined) {
|
|
74
|
+
assertObject(task.review_binding, 'TASK_REVIEW_BINDING_REQUIRED', 'task.review_binding');
|
|
75
|
+
for (const field of ['provider_id', 'harness_id', 'model_id', 'reasoning_configuration']) {
|
|
76
|
+
assertString(task.review_binding[field], 'TASK_REVIEW_BINDING_REQUIRED', `task.review_binding.${field}`);
|
|
77
|
+
}
|
|
78
|
+
if (task.review_binding.attained_independence_level !== undefined && !['A', 'B', 'C'].includes(task.review_binding.attained_independence_level)) {
|
|
79
|
+
fail('INVALID_HANDOFF_TASK', 'task.review_binding.attained_independence_level must be A, B or C');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (task.requirement_refs !== undefined) {
|
|
83
|
+
assertStringArray(task.requirement_refs, 'TASK_REQUIREMENT_TRACEABILITY_REQUIRED', 'task.requirement_refs', { allowEmpty: false });
|
|
84
|
+
if (new Set(task.requirement_refs).size !== task.requirement_refs.length) fail('TASK_REQUIREMENT_TRACEABILITY_REQUIRED', 'task.requirement_refs must be unique');
|
|
85
|
+
}
|
|
86
|
+
if (task.routing_profile !== undefined) assertObject(task.routing_profile, 'INVALID_HANDOFF_TASK', 'task.routing_profile');
|
|
87
|
+
if (task.effort_plan !== undefined) assertObject(task.effort_plan, 'INVALID_HANDOFF_TASK', 'task.effort_plan');
|
|
67
88
|
if (task.clickup_ref !== undefined) assertImmutableRef(task.clickup_ref, 'INVALID_HANDOFF_TASK', 'task.clickup_ref');
|
|
68
89
|
if (task.requires_browser !== undefined && typeof task.requires_browser !== 'boolean') fail('INVALID_HANDOFF_TASK', 'task.requires_browser must be boolean');
|
|
69
90
|
if (task.requires_browser === true && (!task.browser_policy || typeof task.browser_policy !== 'object' || Array.isArray(task.browser_policy))) {
|
|
@@ -132,6 +153,11 @@ export function validateSealedHandoff(handoff) {
|
|
|
132
153
|
taskIds.add(task.task_id);
|
|
133
154
|
}
|
|
134
155
|
for (const task of handoff.tasks) validateTask(task, taskIds);
|
|
156
|
+
for (const task of handoff.tasks) {
|
|
157
|
+
validateTaskVerificationPlan(task);
|
|
158
|
+
validateTaskBrowserVerificationPlan(task);
|
|
159
|
+
}
|
|
160
|
+
validatePlanningMaterial(handoff);
|
|
135
161
|
validateLineage(handoff.lineage, handoff);
|
|
136
162
|
assertObject(handoff.seal, 'INVALID_HANDOFF_SEAL', 'seal');
|
|
137
163
|
assertString(handoff.seal.sealed_by, 'INVALID_HANDOFF_SEAL', 'seal.sealed_by');
|
|
@@ -198,6 +224,7 @@ export function assertDispatchable({ handoff, task_id, completed_task_ids = [],
|
|
|
198
224
|
git_commit_ref: validated.seal.git_commit_ref,
|
|
199
225
|
task_id: task.task_id,
|
|
200
226
|
execution_binding: task.execution_binding,
|
|
227
|
+
...(task.review_binding === undefined ? {} : { review_binding: task.review_binding }),
|
|
201
228
|
}));
|
|
202
229
|
}
|
|
203
230
|
|
|
@@ -220,6 +247,12 @@ export function validateAttemptBinding({ attempt, handoff }) {
|
|
|
220
247
|
const task = validated.tasks.find((candidate) => candidate.task_id === attempt.task_id);
|
|
221
248
|
if (!task) fail('ATTEMPT_TASK_DRIFT', 'attempt task is not part of bound handoff');
|
|
222
249
|
if (sha256(attempt.execution_binding) !== sha256(task.execution_binding)) fail('ATTEMPT_EXECUTION_BINDING_DRIFT', 'attempt execution binding differs from the sealed task');
|
|
250
|
+
if (task.review_binding !== undefined && sha256(attempt.review_binding) !== sha256(task.review_binding)) {
|
|
251
|
+
fail('ATTEMPT_REVIEW_BINDING_DRIFT', 'attempt review binding differs from the sealed task');
|
|
252
|
+
}
|
|
253
|
+
if (task.review_binding === undefined && attempt.review_binding !== undefined) {
|
|
254
|
+
fail('ATTEMPT_REVIEW_BINDING_DRIFT', 'attempt has an unexpected review binding');
|
|
255
|
+
}
|
|
223
256
|
return deepFreeze(canonicalize(attempt));
|
|
224
257
|
}
|
|
225
258
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ContractError, canonicalSerialize, sha256 } from '@chati/core';
|
|
2
|
+
|
|
3
|
+
export const PLANNING_MATERIAL_MAX_BYTES = 1_000_000;
|
|
4
|
+
|
|
5
|
+
function invalid(message) {
|
|
6
|
+
throw new ContractError('PLANNING_MATERIAL_INVALID', message);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Validate the small inline bundle carried by the handoff, never live paths. */
|
|
10
|
+
export function validatePlanningMaterial(handoff, { required = false } = {}) {
|
|
11
|
+
const material = handoff?.planning_material;
|
|
12
|
+
if (material === undefined) {
|
|
13
|
+
if (required) throw new ContractError('PLANNING_MATERIAL_REQUIRED', 'this handoff has no frozen Planning content; regenerate it through Tasks before execution');
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (!material || material.schema_version !== 1 || !Array.isArray(material.artifacts)
|
|
17
|
+
|| material.artifacts.length === 0 || !Array.isArray(material.scope_bindings)) {
|
|
18
|
+
invalid('Planning material requires version 1, artifacts and scope bindings');
|
|
19
|
+
}
|
|
20
|
+
if (Buffer.byteLength(canonicalSerialize(material), 'utf8') > PLANNING_MATERIAL_MAX_BYTES) {
|
|
21
|
+
throw new ContractError('PLANNING_MATERIAL_SIZE_LIMIT', `frozen Planning content exceeds the ${PLANNING_MATERIAL_MAX_BYTES} byte budget`);
|
|
22
|
+
}
|
|
23
|
+
const byRef = new Map();
|
|
24
|
+
for (const artifact of material.artifacts) {
|
|
25
|
+
if (!artifact || typeof artifact.content !== 'string' || typeof artifact.ref !== 'string' || !artifact.ref.trim()
|
|
26
|
+
|| artifact.sha256 !== sha256(artifact.content) || byRef.has(artifact.ref)
|
|
27
|
+
|| (artifact.ref.startsWith('local-artifact://') && artifact.ref !== `local-artifact://sha256/${artifact.sha256}`)) {
|
|
28
|
+
invalid('each Planning artifact must have unique content matching its SHA-256 reference');
|
|
29
|
+
}
|
|
30
|
+
byRef.set(artifact.ref, artifact.content);
|
|
31
|
+
}
|
|
32
|
+
const requiredRefs = [
|
|
33
|
+
...Object.values(handoff.source_artifacts ?? {}), handoff.task_graph_ref,
|
|
34
|
+
...(handoff.tasks ?? []).flatMap((task) => task.acceptance_criteria_refs ?? []),
|
|
35
|
+
...(handoff.tasks ?? []).flatMap((task) => task.verification_plan?.checks?.map((check) => check.stdin_ref) ?? []),
|
|
36
|
+
...(handoff.tasks ?? []).flatMap((task) => task.browser_verification_plan ? [task.browser_verification_plan.observation.application.stdin_ref] : []),
|
|
37
|
+
];
|
|
38
|
+
if (!handoff.source_artifacts?.task_details_ref || requiredRefs.some((ref) => !byRef.has(ref))) {
|
|
39
|
+
invalid('Planning material must resolve the task graph, every source, task details and every acceptance criterion');
|
|
40
|
+
}
|
|
41
|
+
if (!byRef.get(handoff.source_artifacts.task_details_ref).trim()) {
|
|
42
|
+
invalid('frozen task details must contain explicit task scopes');
|
|
43
|
+
}
|
|
44
|
+
for (const task of handoff.tasks ?? []) {
|
|
45
|
+
if (task.verification_plan?.checks?.some((check) => !byRef.get(check.stdin_ref)?.trim())) {
|
|
46
|
+
invalid('frozen verification scripts must contain explicit checks');
|
|
47
|
+
}
|
|
48
|
+
if (task.browser_verification_plan && !byRef.get(task.browser_verification_plan.observation.application.stdin_ref)?.trim()) {
|
|
49
|
+
invalid('frozen browser startup content must be present and nonempty');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const tasks = new Map((handoff.tasks ?? []).map((task) => [task.task_id, task]));
|
|
53
|
+
const boundTasks = new Set();
|
|
54
|
+
for (const binding of material.scope_bindings) {
|
|
55
|
+
const task = tasks.get(binding?.task_id);
|
|
56
|
+
if (!task || boundTasks.has(task.task_id) || binding.ref !== task.execution_scope_ref
|
|
57
|
+
|| binding.artifact_ref !== handoff.source_artifacts.task_details_ref) {
|
|
58
|
+
invalid('each task scope must resolve to the frozen task-details document exactly once');
|
|
59
|
+
}
|
|
60
|
+
boundTasks.add(task.task_id);
|
|
61
|
+
}
|
|
62
|
+
if (boundTasks.size !== tasks.size) invalid('Planning material is missing a task scope binding');
|
|
63
|
+
return material;
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { ContractError } from '@chati/core';
|
|
3
|
+
|
|
4
|
+
function invalid(message) { throw new ContractError('VERIFICATION_PLAN_INVALID', message); }
|
|
5
|
+
function exact(value, fields, label) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
7
|
+
|| Object.keys(value).length !== fields.length || Object.keys(value).some((key) => !fields.includes(key))) {
|
|
8
|
+
invalid(`${label} has an invalid closed schema`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Frozen verification policy. Approval is supplied by the handoff council. */
|
|
13
|
+
export function validateTaskVerificationPlan(task, { required = false } = {}) {
|
|
14
|
+
const plan = task?.verification_plan;
|
|
15
|
+
if (plan === undefined) {
|
|
16
|
+
if (required) throw new ContractError('VERIFICATION_PLAN_REQUIRED', 'this task requires an approved frozen verification plan before local evidence can be issued');
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
exact(plan, ['schema_version', 'checks'], 'verification plan');
|
|
20
|
+
if (plan.schema_version !== 1 || !Array.isArray(plan.checks) || plan.checks.length < 2 || plan.checks.length > 32) {
|
|
21
|
+
invalid('verification plan requires version 1 and 2 to 32 mandatory checks');
|
|
22
|
+
}
|
|
23
|
+
const knownCriteria = new Set(task.acceptance_criteria_refs);
|
|
24
|
+
const covered = new Set();
|
|
25
|
+
const ids = new Set();
|
|
26
|
+
let ciCount = 0;
|
|
27
|
+
for (const check of plan.checks) {
|
|
28
|
+
exact(check, ['check_id', 'kind', 'criterion_refs', 'executable_binding', 'args', 'stdin_ref', 'timeout_ms'], 'verification check');
|
|
29
|
+
if (typeof check.check_id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(check.check_id) || ids.has(check.check_id)) {
|
|
30
|
+
invalid('check IDs must be unique, bounded and path-inert');
|
|
31
|
+
}
|
|
32
|
+
ids.add(check.check_id);
|
|
33
|
+
if (!['ci', 'acceptance'].includes(check.kind) || !Array.isArray(check.criterion_refs)
|
|
34
|
+
|| new Set(check.criterion_refs).size !== check.criterion_refs.length
|
|
35
|
+
|| check.criterion_refs.some((ref) => !knownCriteria.has(ref))) invalid('check criterion references must belong to this task');
|
|
36
|
+
if (check.kind === 'ci') {
|
|
37
|
+
ciCount += 1;
|
|
38
|
+
if (check.criterion_refs.length !== 0) invalid('CI checks cannot stand in for criterion-specific acceptance checks');
|
|
39
|
+
} else {
|
|
40
|
+
if (check.criterion_refs.length === 0) invalid('acceptance checks must bind one or more criteria');
|
|
41
|
+
check.criterion_refs.forEach((ref) => covered.add(ref));
|
|
42
|
+
}
|
|
43
|
+
exact(check.executable_binding, ['executable_path', 'executable_sha256'], 'check executable binding');
|
|
44
|
+
const binding = check.executable_binding;
|
|
45
|
+
if (typeof binding.executable_path !== 'string' || !isAbsolute(binding.executable_path)
|
|
46
|
+
|| resolve(binding.executable_path) !== binding.executable_path
|
|
47
|
+
|| !/^[a-f0-9]{64}$/.test(binding.executable_sha256 || '')) invalid('check executable must have a canonical absolute path and SHA-256');
|
|
48
|
+
if (!Array.isArray(check.args) || check.args.length > 64
|
|
49
|
+
|| check.args.some((arg) => typeof arg !== 'string' || arg.includes('\0') || Buffer.byteLength(arg) > 8192)) invalid('check arguments must be bounded literal strings');
|
|
50
|
+
if (typeof check.stdin_ref !== 'string' || !/^local-artifact:\/\/sha256\/[a-f0-9]{64}$/.test(check.stdin_ref)) invalid('check script must be frozen by content reference');
|
|
51
|
+
if (!Number.isInteger(check.timeout_ms) || check.timeout_ms < 1 || check.timeout_ms > 1800000) invalid('check timeout must be 1 to 1800000 milliseconds');
|
|
52
|
+
}
|
|
53
|
+
if (ciCount === 0 || knownCriteria.size === 0 || covered.size !== knownCriteria.size) {
|
|
54
|
+
invalid('verification plan must include CI and acceptance checks covering every task criterion');
|
|
55
|
+
}
|
|
56
|
+
return plan;
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ContractError, sha256, assertDarwinTerminalLaunch, compareDarwinHostBootIdentity } from '@chati/core';
|
|
2
|
+
|
|
3
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
4
|
+
const TERMINATION_ATTESTATION_KEYS = ['operator_id', 'attested_at', 'statement'];
|
|
5
|
+
const TERMINATION_ATTESTATION_STATEMENT = 'no-launch-processes-remain';
|
|
6
|
+
|
|
7
|
+
/** Shape only. Never authenticates the operator identity or the machine; the
|
|
8
|
+
* caller binds those separately (claim operator_id, captured host identity). */
|
|
9
|
+
export function isWellFormedTerminationAttestation(attestation) {
|
|
10
|
+
return Boolean(attestation) && typeof attestation === 'object' && !Array.isArray(attestation)
|
|
11
|
+
&& Object.keys(attestation).length === 3
|
|
12
|
+
&& TERMINATION_ATTESTATION_KEYS.every((key) => Object.hasOwn(attestation, key))
|
|
13
|
+
&& typeof attestation.operator_id === 'string' && attestation.operator_id.trim() !== ''
|
|
14
|
+
&& typeof attestation.attested_at === 'string' && RFC3339.test(attestation.attested_at)
|
|
15
|
+
&& attestation.statement === TERMINATION_ATTESTATION_STATEMENT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertExecutionInterruptionLifetime(dispatch, interruption) {
|
|
19
|
+
const launch = dispatch.payload.launch_receipt;
|
|
20
|
+
if (launch.process_containment_kind !== 'darwin_scoped_process_group_v1') return true;
|
|
21
|
+
const observed = interruption.payload.process;
|
|
22
|
+
try {
|
|
23
|
+
assertDarwinTerminalLaunch(launch);
|
|
24
|
+
if (!observed || Object.hasOwn(interruption.payload, 'process_namespace_identity_digest')
|
|
25
|
+
|| interruption.payload.darwin_host_boot_identity_digest !== sha256(launch.darwin_launch_binding.host_boot_identity)
|
|
26
|
+
|| observed.status !== 'exited' || observed.signal_sent !== false) {
|
|
27
|
+
throw new Error('interruption requires a native new-boot observation on the same machine');
|
|
28
|
+
}
|
|
29
|
+
// Same machine either way; a reboot (previous-boot) remains the strongest
|
|
30
|
+
// proof, but a same-boot crash may instead carry a human attestation.
|
|
31
|
+
const relation = compareDarwinHostBootIdentity(launch.darwin_launch_binding.host_boot_identity, observed.host_boot_identity);
|
|
32
|
+
if (observed.reason === 'human-attested') {
|
|
33
|
+
if (Object.keys(observed).length !== 5 || !['same-boot', 'previous-boot'].includes(relation)
|
|
34
|
+
|| !isWellFormedTerminationAttestation(observed.attestation)) {
|
|
35
|
+
throw new Error('human-attested interruption requires a well formed attestation on the same machine');
|
|
36
|
+
}
|
|
37
|
+
} else if (Object.keys(observed).length !== 4 || observed.reason !== 'previous-boot' || relation !== 'previous-boot') {
|
|
38
|
+
throw new Error('interruption requires a native new-boot observation on the same machine');
|
|
39
|
+
}
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
throw new ContractError('RECOVERY_RESTORATION_PROOF_INVALID', 'Darwin interruption cannot substitute group absence or another host/boot binding', { cause_code: cause.code });
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function executionCheckpointBinding(dispatch, interruption) {
|
|
47
|
+
const { attempt_id, invocation, claim_id, launch_receipt: launch } = dispatch.payload;
|
|
48
|
+
return { attempt_id, dispatch_event_id: dispatch.event_id, interruption_event_id: interruption.event_id,
|
|
49
|
+
launch_nonce: invocation.launch_nonce, claim_id, launch_receipt_digest: sha256(launch),
|
|
50
|
+
base_commit_ref: launch.execution_base_commit_ref,
|
|
51
|
+
repository_identity_digest: launch.execution_repository_identity_digest };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Only journal-linked restoration is evidence, never a caller's status flag. */
|
|
55
|
+
export function restoredExecutionProof(records, dispatch, { verifyAuthority, current = false, requireRecovered = false } = {}) {
|
|
56
|
+
const restored = records.find((record) => record.type === 'execution_worktree_restored'
|
|
57
|
+
&& record.payload.dispatch_event_id === dispatch.event_id);
|
|
58
|
+
if (!restored) return null;
|
|
59
|
+
const fail = () => { throw new ContractError('RECOVERY_RESTORATION_PROOF_INVALID', 'restoration must bind the exact ordered canonical interruption, checkpoint and dispatch'); };
|
|
60
|
+
const { attempt_id, invocation, launch_receipt: launch } = dispatch.payload;
|
|
61
|
+
const interruption = records.find((record) => record.type === 'execution_interruption_observed'
|
|
62
|
+
&& record.payload.dispatch_event_id === dispatch.event_id);
|
|
63
|
+
const checkpoint = records.find((record) => record.type === 'execution_checkpoint_preserved'
|
|
64
|
+
&& record.payload.dispatch_event_id === dispatch.event_id);
|
|
65
|
+
if (dispatch.type !== 'execution_dispatched' || !interruption || !checkpoint
|
|
66
|
+
|| ![interruption, checkpoint, restored].every((record) => record.payload.attempt_id === attempt_id
|
|
67
|
+
&& record.payload.launch_nonce === invocation.launch_nonce)
|
|
68
|
+
|| !(records.indexOf(dispatch) < records.indexOf(interruption)
|
|
69
|
+
&& records.indexOf(interruption) < records.indexOf(checkpoint) && records.indexOf(checkpoint) < records.indexOf(restored))
|
|
70
|
+
|| interruption.payload.status !== 'interrupted' || interruption.payload.process?.status !== 'exited'
|
|
71
|
+
|| interruption.payload.launch_receipt_digest !== sha256(launch)
|
|
72
|
+
|| interruption.payload.exit_status !== null || interruption.payload.process_elapsed_ms !== null
|
|
73
|
+
|| interruption.payload.result_commit_ref !== null || interruption.payload.output_status !== 'unavailable'
|
|
74
|
+
|| restored.payload.checkpoint_event_id !== checkpoint.event_id) fail();
|
|
75
|
+
const binding = executionCheckpointBinding(dispatch, interruption);
|
|
76
|
+
assertExecutionInterruptionLifetime(dispatch, interruption);
|
|
77
|
+
verifyAuthority('execution_checkpoint', checkpoint.payload.checkpoint_receipt, {
|
|
78
|
+
schema_version: 1, purpose: 'rail_execution_checkpoint', project_dir: launch.working_dir, binding });
|
|
79
|
+
verifyAuthority('execution_restoration', restored.payload.restoration_receipt, {
|
|
80
|
+
schema_version: 1, purpose: 'rail_execution_restoration', project_dir: launch.working_dir, binding,
|
|
81
|
+
checkpoint_receipt_digest: sha256(checkpoint.payload.checkpoint_receipt), restored_base_commit_ref: binding.base_commit_ref,
|
|
82
|
+
}, { checkpoint_receipt: checkpoint.payload.checkpoint_receipt, current });
|
|
83
|
+
const proof = { status: 'interrupted_restored', launch_nonce: invocation.launch_nonce,
|
|
84
|
+
dispatch_event_id: dispatch.event_id, interruption_event_id: interruption.event_id,
|
|
85
|
+
checkpoint_event_id: checkpoint.event_id, restoration_event_id: restored.event_id,
|
|
86
|
+
restoration_receipt_digest: sha256(restored.payload.restoration_receipt) };
|
|
87
|
+
if (!requireRecovered) return proof;
|
|
88
|
+
const recovered = records.find((record) => record.type === 'attempt_recovered'
|
|
89
|
+
&& record.payload.attempt_id === attempt_id && records.indexOf(record) > records.indexOf(restored)
|
|
90
|
+
&& record.payload.reconciliation?.execution_dispatches?.some((entry) => sha256(entry) === sha256(proof)));
|
|
91
|
+
return recovered ? { ...proof, recovery_event_id: recovered.event_id } : null;
|
|
92
|
+
}
|