chati-dev 4.5.28 → 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 +117 -12
- 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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
closeSync, constants, fstatSync, fsyncSync,
|
|
3
|
-
openSync, readSync, unlinkSync, writeSync,
|
|
2
|
+
closeSync, constants, fstatSync, fsyncSync, openSync, readSync, writeSync,
|
|
4
3
|
} from 'node:fs';
|
|
5
4
|
import { randomUUID } from 'node:crypto';
|
|
6
5
|
import { basename, dirname, join, parse, resolve, sep } from 'node:path';
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
canonicalize, directoryPathSync, linkAtSync, mkdirAtSync, openAtSync, sha256, unlinkAtSync,
|
|
8
|
+
} from '@chati/core';
|
|
8
9
|
|
|
9
10
|
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
10
11
|
const MAX_EVIDENCE_BYTES = 4 * 1024 * 1024;
|
|
@@ -41,8 +42,8 @@ function readDescriptorBounded(descriptor) {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
44
|
-
if (process.platform
|
|
45
|
-
fail('UNSUPPORTED_ADJUDICATION_EVIDENCE_PLATFORM',
|
|
45
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
46
|
+
fail('UNSUPPORTED_ADJUDICATION_EVIDENCE_PLATFORM', `RAIL adjudication evidence requires descriptor-relative operations; unsupported platform: ${process.platform}`);
|
|
46
47
|
}
|
|
47
48
|
const absolute = resolve(path);
|
|
48
49
|
const root = parse(absolute).root;
|
|
@@ -50,10 +51,9 @@ function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
|
50
51
|
let descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
51
52
|
try {
|
|
52
53
|
for (const segment of segments) {
|
|
53
|
-
const childPath = join(`/proc/self/fd/${descriptor}`, segment);
|
|
54
54
|
let childDescriptor;
|
|
55
55
|
try {
|
|
56
|
-
childDescriptor =
|
|
56
|
+
childDescriptor = openAtSync(descriptor, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
57
57
|
} catch (error) {
|
|
58
58
|
if (error?.code !== 'ENOENT' || !create) {
|
|
59
59
|
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
@@ -61,9 +61,9 @@ function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
throw error;
|
|
63
63
|
}
|
|
64
|
-
try {
|
|
64
|
+
try { mkdirAtSync(descriptor, segment, { mode: 0o700 }); }
|
|
65
65
|
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
66
|
-
childDescriptor =
|
|
66
|
+
childDescriptor = openAtSync(descriptor, segment, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
67
67
|
}
|
|
68
68
|
const stat = fstatSync(childDescriptor);
|
|
69
69
|
if (!stat.isDirectory()) {
|
|
@@ -77,7 +77,6 @@ function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
|
77
77
|
absolute,
|
|
78
78
|
descriptor,
|
|
79
79
|
identity: fstatSync(descriptor),
|
|
80
|
-
reference: `/proc/self/fd/${descriptor}`,
|
|
81
80
|
});
|
|
82
81
|
} catch (error) {
|
|
83
82
|
closeSync(descriptor);
|
|
@@ -85,13 +84,6 @@ function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
function evidenceEntryPath(directory, name) {
|
|
89
|
-
if (basename(name) !== name || name === '.' || name === '..') {
|
|
90
|
-
fail('UNSAFE_ADJUDICATION_EVIDENCE_FILE', 'adjudication evidence entry must be one path segment');
|
|
91
|
-
}
|
|
92
|
-
return join(directory.reference, name);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
87
|
function assertPinnedEvidenceDirectory(directory) {
|
|
96
88
|
const descriptorStat = fstatSync(directory.descriptor);
|
|
97
89
|
if (!descriptorStat.isDirectory() || !sameIdentity(descriptorStat, directory.identity)) {
|
|
@@ -117,10 +109,9 @@ function readRegular(path, { allowMissing = false, directory = null } = {}) {
|
|
|
117
109
|
try { activeDirectory = openPinnedEvidenceDirectory(dirname(path), { create: false }); }
|
|
118
110
|
catch (error) { if (allowMissing && error?.code === 'ENOENT') return null; throw error; }
|
|
119
111
|
}
|
|
120
|
-
const entry = evidenceEntryPath(activeDirectory, basename(path));
|
|
121
112
|
let descriptor;
|
|
122
113
|
try {
|
|
123
|
-
descriptor =
|
|
114
|
+
descriptor = openAtSync(activeDirectory.descriptor, basename(path), constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
124
115
|
} catch (error) {
|
|
125
116
|
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
126
117
|
if (allowMissing && error?.code === 'ENOENT') return null;
|
|
@@ -162,8 +153,8 @@ function persistImmutable(path, serialized) {
|
|
|
162
153
|
const directory = openPinnedEvidenceDirectory(dirname(path));
|
|
163
154
|
const name = basename(path);
|
|
164
155
|
const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
165
|
-
|
|
166
|
-
const
|
|
156
|
+
// Diagnostic/test-hook path only. Actual reads and mutations use *at ops.
|
|
157
|
+
const temporary = join(directoryPathSync(directory.descriptor), temporaryName);
|
|
167
158
|
let temporaryDescriptor;
|
|
168
159
|
try {
|
|
169
160
|
const existing = readRegular(path, { allowMissing: true, directory });
|
|
@@ -172,7 +163,7 @@ function persistImmutable(path, serialized) {
|
|
|
172
163
|
return false;
|
|
173
164
|
}
|
|
174
165
|
runSecurityTestHook('beforeEvidenceTempOpen', { path, temporary, directory: directory.absolute });
|
|
175
|
-
temporaryDescriptor =
|
|
166
|
+
temporaryDescriptor = openAtSync(directory.descriptor, temporaryName, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
176
167
|
runSecurityTestHook('afterEvidenceTempOpen', { path, temporary, directory: directory.absolute });
|
|
177
168
|
let offset = 0;
|
|
178
169
|
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
@@ -181,7 +172,7 @@ function persistImmutable(path, serialized) {
|
|
|
181
172
|
temporaryDescriptor = undefined;
|
|
182
173
|
assertPinnedEvidenceDirectory(directory);
|
|
183
174
|
runSecurityTestHook('beforeEvidenceCommit', { path, temporary, directory: directory.absolute });
|
|
184
|
-
try {
|
|
175
|
+
try { linkAtSync(directory.descriptor, temporaryName, directory.descriptor, name); }
|
|
185
176
|
catch (error) {
|
|
186
177
|
if (error?.code !== 'EEXIST') throw error;
|
|
187
178
|
const raced = readRegular(path, { directory });
|
|
@@ -194,7 +185,7 @@ function persistImmutable(path, serialized) {
|
|
|
194
185
|
return true;
|
|
195
186
|
} finally {
|
|
196
187
|
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
197
|
-
try {
|
|
188
|
+
try { unlinkAtSync(directory.descriptor, temporaryName); } catch { /* preserve the primary result */ }
|
|
198
189
|
fsyncSync(directory.descriptor);
|
|
199
190
|
closeSync(directory.descriptor);
|
|
200
191
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { canonicalSerialize } from '@chati/core';
|
|
2
|
+
import { installationAuthorityProjectBinding, verifyHostAuthorityPayload } from '../installer-v2/installation-authority.js';
|
|
3
|
+
|
|
4
|
+
export const HUMAN_EFFORT_AUTHORITY_DOMAIN = 'chati.rail-human-effort.v1';
|
|
5
|
+
const COMMON = ['schema_version', 'purpose', 'session_id', 'attempt_id', 'handoff_id', 'task_id',
|
|
6
|
+
'project_binding', 'operator_id', 'claim_id', 'account', 'observed_at', 'phase'];
|
|
7
|
+
const FINAL = ['opened_receipt_digest', 'declaration', 'intervals', 'elapsed_ms', 'value', 'unit'];
|
|
8
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
9
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
+
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
11
|
+
|
|
12
|
+
export function verifyHumanEffortReceipt(receipt, { projectDir, authorityDirectory, expected = {} } = {}) {
|
|
13
|
+
try {
|
|
14
|
+
if (!['opened', 'finished', 'interrupted'].includes(receipt?.phase)) return false;
|
|
15
|
+
const keys = [...COMMON, ...(receipt.phase === 'opened' ? [] : FINAL), 'authority_id', 'authority_signature'];
|
|
16
|
+
if (!exact(receipt, keys) || receipt.schema_version !== 1 || receipt.purpose !== 'rail_human_effort') return false;
|
|
17
|
+
for (const key of ['session_id', 'attempt_id', 'handoff_id', 'task_id', 'operator_id', 'claim_id', 'observed_at']) {
|
|
18
|
+
if (typeof receipt[key] !== 'string' || !receipt[key].trim()) return false;
|
|
19
|
+
}
|
|
20
|
+
if (!/^human-effort-[a-f0-9-]{36}$/.test(receipt.session_id) || !Number.isFinite(Date.parse(receipt.observed_at))) return false;
|
|
21
|
+
if (!exact(receipt.account, ['uid', 'username', 'hostname']) || !Number.isInteger(receipt.account.uid)
|
|
22
|
+
|| receipt.account.uid < 0 || !['username', 'hostname'].every((key) => typeof receipt.account[key] === 'string' && receipt.account[key])) return false;
|
|
23
|
+
if (canonicalSerialize(receipt.project_binding) !== canonicalSerialize(installationAuthorityProjectBinding(projectDir, receipt.project_binding.project_id))) return false;
|
|
24
|
+
if (Object.entries(expected).some(([key, value]) => canonicalSerialize(receipt[key]) !== canonicalSerialize(value))) return false;
|
|
25
|
+
if (receipt.phase !== 'opened') {
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(receipt.opened_receipt_digest) || !finite(receipt.elapsed_ms)
|
|
27
|
+
|| receipt.unit !== 'milliseconds' || !Array.isArray(receipt.intervals) || receipt.intervals.length > 1024) return false;
|
|
28
|
+
let prior = 0;
|
|
29
|
+
let value = 0;
|
|
30
|
+
for (const interval of receipt.intervals) {
|
|
31
|
+
if (!exact(interval, ['start_ms', 'end_ms']) || !finite(interval.start_ms) || !finite(interval.end_ms)
|
|
32
|
+
|| interval.start_ms < prior || interval.end_ms < interval.start_ms || interval.end_ms > receipt.elapsed_ms) return false;
|
|
33
|
+
prior = interval.end_ms;
|
|
34
|
+
value += interval.end_ms - interval.start_ms;
|
|
35
|
+
}
|
|
36
|
+
if (receipt.phase === 'interrupted') {
|
|
37
|
+
if (receipt.declaration !== 'incomplete' || receipt.value !== null) return false;
|
|
38
|
+
} else if (receipt.declaration === 'zero-effort') {
|
|
39
|
+
if (receipt.intervals.length !== 0 || receipt.value !== 0) return false;
|
|
40
|
+
} else if (receipt.declaration !== 'timed-effort' || receipt.intervals.length === 0 || !finite(receipt.value) || receipt.value !== value) return false;
|
|
41
|
+
}
|
|
42
|
+
const { authority_id, authority_signature, ...payload } = receipt;
|
|
43
|
+
return verifyHostAuthorityPayload(payload, { domain: HUMAN_EFFORT_AUTHORITY_DOMAIN,
|
|
44
|
+
authorityId: authority_id, signature: authority_signature, authorityDirectory });
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { hostname, userInfo } from 'node:os';
|
|
4
|
+
import { join, resolve, sep } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { sha256, tryExclusiveFileLockSync } from '@chati/core';
|
|
7
|
+
import { persistImmutableInstallationFile, readSafeInstallationStorageFile } from '../installer-v2/catalog-client.js';
|
|
8
|
+
import { installationAuthorityProjectBinding, installationAuthorityStateDirectory, signHostAuthorityPayload } from '../installer-v2/installation-authority.js';
|
|
9
|
+
import { createProjectRailEngine, resolveRailAttemptContext } from './rail-runtime.js';
|
|
10
|
+
import { loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
|
|
11
|
+
import { HUMAN_EFFORT_AUTHORITY_DOMAIN } from './rail-human-effort-authority.js';
|
|
12
|
+
|
|
13
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
14
|
+
|
|
15
|
+
function acquireEffortLock(projectDir, authorityDirectory) {
|
|
16
|
+
const root = installationAuthorityStateDirectory(authorityDirectory);
|
|
17
|
+
if (root === projectDir || root.startsWith(`${projectDir}${sep}`) || projectDir.startsWith(`${root}${sep}`)) {
|
|
18
|
+
fail('HUMAN_EFFORT_STORAGE_OVERLAP', 'human effort lock must remain outside the project in private authority storage');
|
|
19
|
+
}
|
|
20
|
+
const path = join(root, 'human-effort.lock');
|
|
21
|
+
const content = 'chati-human-effort-lock-v1\n';
|
|
22
|
+
persistImmutableInstallationFile(path, content, { label: 'human effort host lock', maxBytes: 1024 });
|
|
23
|
+
const source = readSafeInstallationStorageFile(path, { maxBytes: 1024 });
|
|
24
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
25
|
+
const same = (stat) => stat.dev === source.stat.dev && stat.ino === source.stat.ino
|
|
26
|
+
&& stat.isFile() && stat.nlink === 1n && (stat.mode & 0o777n) === 0o600n && stat.uid === BigInt(userInfo().uid);
|
|
27
|
+
const assertStable = () => {
|
|
28
|
+
const current = readSafeInstallationStorageFile(path, { maxBytes: 1024 });
|
|
29
|
+
if (!same(fstatSync(fd, { bigint: true })) || !same(current.stat) || current.content !== content) {
|
|
30
|
+
fail('HUMAN_EFFORT_LOCK_CHANGED', 'human effort lock identity or permissions changed during measurement');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
assertStable();
|
|
35
|
+
if (!tryExclusiveFileLockSync(fd)) fail('HUMAN_EFFORT_ALREADY_ACTIVE', 'this host account already has an active human effort session');
|
|
36
|
+
assertStable();
|
|
37
|
+
return { assertStable, close: () => closeSync(fd) };
|
|
38
|
+
} catch (error) { closeSync(fd); throw error; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Live, monotonic supervisor. No API accepts a duration or a finished report. */
|
|
42
|
+
export function openHumanEffortSession(input = {}) {
|
|
43
|
+
const keys = ['projectDir', 'handoff_id', 'attempt_id', 'authorityDirectory'];
|
|
44
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((key) => !keys.includes(key))
|
|
45
|
+
|| ['projectDir', 'handoff_id', 'attempt_id'].some((key) => typeof input[key] !== 'string' || !input[key].trim())) {
|
|
46
|
+
fail('HUMAN_EFFORT_INPUT_INVALID', 'human effort accepts only canonical project, handoff and attempt identifiers');
|
|
47
|
+
}
|
|
48
|
+
const projectDir = realpathSync(resolve(input.projectDir));
|
|
49
|
+
const context = resolveRailAttemptContext({ ...input, projectDir });
|
|
50
|
+
const artifact = loadRuntimeInstallationV2(projectDir);
|
|
51
|
+
if (!artifact || artifact.project_id !== context.handoff.project_id) fail('V2_INSTALLATION_REQUIRED', 'human effort requires the verified project installation');
|
|
52
|
+
if (!['active', 'ready_to_merge'].includes(context.state)) fail('HUMAN_EFFORT_ATTEMPT_INACTIVE', 'human effort session requires an unfinished attempt');
|
|
53
|
+
const account = userInfo();
|
|
54
|
+
const base = { schema_version: 1, purpose: 'rail_human_effort', session_id: `human-effort-${randomUUID()}`,
|
|
55
|
+
project_binding: installationAuthorityProjectBinding(projectDir, artifact.project_id),
|
|
56
|
+
attempt_id: input.attempt_id, handoff_id: input.handoff_id, task_id: context.task.task_id,
|
|
57
|
+
operator_id: context.claim.operator_id, claim_id: context.claim.claim_id,
|
|
58
|
+
account: { uid: account.uid, username: account.username, hostname: hostname() } };
|
|
59
|
+
const lock = acquireEffortLock(projectDir, input.authorityDirectory);
|
|
60
|
+
const origin = process.hrtime.bigint();
|
|
61
|
+
const offset = () => Number(process.hrtime.bigint() - origin) / 1e6;
|
|
62
|
+
let state = 'ready';
|
|
63
|
+
let activeStart;
|
|
64
|
+
const intervals = [];
|
|
65
|
+
let opened;
|
|
66
|
+
const persist = (payload) => {
|
|
67
|
+
lock.assertStable();
|
|
68
|
+
const proof = signHostAuthorityPayload(payload, { domain: HUMAN_EFFORT_AUTHORITY_DOMAIN, authorityDirectory: input.authorityDirectory });
|
|
69
|
+
const receipt = { ...payload, authority_id: proof.authority_id, authority_signature: proof.signature };
|
|
70
|
+
const record = createProjectRailEngine({ projectDir, humanAuthorityDirectory: input.authorityDirectory })
|
|
71
|
+
.recordHumanEffortObservation({ attempt_id: input.attempt_id, receipt });
|
|
72
|
+
return { receipt, event_id: record.event_id };
|
|
73
|
+
};
|
|
74
|
+
try { opened = persist({ ...base, phase: 'opened', observed_at: new Date().toISOString() }); }
|
|
75
|
+
catch (error) { lock.close(); throw error; }
|
|
76
|
+
const result = () => Object.freeze({ ok: true, session_id: base.session_id, status: state, language: context.language });
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
status: result,
|
|
79
|
+
transition(action) {
|
|
80
|
+
if (!['start', 'pause', 'resume', 'finish', 'zero', 'interrupt'].includes(action) || ['finished', 'interrupted'].includes(state)) {
|
|
81
|
+
fail('HUMAN_EFFORT_TRANSITION_INVALID', 'invalid human effort command or session already closed');
|
|
82
|
+
}
|
|
83
|
+
if ((action === 'start' || action === 'zero') && state !== 'ready'
|
|
84
|
+
|| action === 'pause' && state !== 'active' || action === 'resume' && state !== 'paused'
|
|
85
|
+
|| action === 'finish' && !['active', 'paused'].includes(state)) {
|
|
86
|
+
fail('HUMAN_EFFORT_TRANSITION_INVALID', `cannot ${action} while ${state}`);
|
|
87
|
+
}
|
|
88
|
+
try { lock.assertStable(); }
|
|
89
|
+
catch (error) { state = 'interrupted'; lock.close(); throw error; }
|
|
90
|
+
const now = offset();
|
|
91
|
+
if (action === 'start' || action === 'resume') {
|
|
92
|
+
if (intervals.length >= 1024) fail('HUMAN_EFFORT_INTERVAL_LIMIT', 'finish this bounded session before starting more intervals');
|
|
93
|
+
activeStart = now;
|
|
94
|
+
state = 'active';
|
|
95
|
+
return result();
|
|
96
|
+
}
|
|
97
|
+
if (state === 'active') { intervals.push({ start_ms: activeStart, end_ms: now }); activeStart = undefined; }
|
|
98
|
+
if (action === 'pause') { state = 'paused'; return result(); }
|
|
99
|
+
state = action === 'interrupt' ? 'interrupted' : 'finished';
|
|
100
|
+
try {
|
|
101
|
+
const final = persist({ ...base, phase: state, observed_at: new Date().toISOString(),
|
|
102
|
+
opened_receipt_digest: sha256(opened.receipt), declaration: action === 'interrupt' ? 'incomplete' : action === 'zero' ? 'zero-effort' : 'timed-effort',
|
|
103
|
+
intervals, elapsed_ms: now, unit: 'milliseconds',
|
|
104
|
+
value: action === 'interrupt' ? null : intervals.reduce((total, interval) => total + (interval.end_ms - interval.start_ms), 0) });
|
|
105
|
+
return Object.freeze({ ...result(), ok: state === 'finished', source_event_id: final.event_id,
|
|
106
|
+
receipt_ref: `rail-human-effort://sha256/${sha256(final.receipt)}` });
|
|
107
|
+
} finally { lock.close(); }
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function collectRailHumanEffort({ projectDir, attempt_id, authorityDirectory }) {
|
|
113
|
+
return createProjectRailEngine({ projectDir, humanAuthorityDirectory: authorityDirectory }).humanEffortForAttempt(attempt_id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Explicit operator interaction, never piped duration JSON or inferred presence. */
|
|
117
|
+
export async function runHumanEffortTerminal(input) {
|
|
118
|
+
if (!process.stdin.isTTY) fail('HUMAN_EFFORT_TTY_REQUIRED', 'human effort requires an interactive terminal declaration');
|
|
119
|
+
const session = openHumanEffortSession(input);
|
|
120
|
+
const instructions = {
|
|
121
|
+
en: 'Declare your active effort: start, pause, resume, finish. Use zero only to explicitly declare no human effort. EOF/interrupt leaves effort incomplete.',
|
|
122
|
+
pt: 'Declare seu esforço ativo: start, pause, resume, finish. Use zero somente para declarar explicitamente ausência de esforço humano. EOF/interrupção deixa o registro incompleto.',
|
|
123
|
+
fr: 'Déclarez votre effort actif : start, pause, resume, finish. Utilisez zero uniquement pour déclarer explicitement aucun effort humain. EOF/interruption laisse le relevé incomplet.',
|
|
124
|
+
es: 'Declare su esfuerzo activo: start, pause, resume, finish. Use zero solo para declarar explícitamente ningún esfuerzo humano. EOF/interrupción deja el registro incompleto.',
|
|
125
|
+
};
|
|
126
|
+
process.stderr.write(`${instructions[session.status().language] ?? instructions.en}\n`);
|
|
127
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
128
|
+
const stop = () => terminal.close();
|
|
129
|
+
terminal.on('SIGINT', stop);
|
|
130
|
+
process.on('SIGTERM', stop);
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
for await (const line of terminal) {
|
|
134
|
+
try { result = session.transition(line.trim()); }
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error.code !== 'HUMAN_EFFORT_TRANSITION_INVALID') throw error;
|
|
137
|
+
process.stderr.write(`${error.code}: ${error.message}\n`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (['finished', 'interrupted'].includes(result.status)) break;
|
|
141
|
+
process.stderr.write(`${result.status}\n`);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
terminal.close();
|
|
145
|
+
process.removeListener('SIGTERM', stop);
|
|
146
|
+
if (!['finished', 'interrupted'].includes(session.status().status)) result = session.transition('interrupt');
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|