@thebackstoryis/engineering-with-ai 0.2.9 → 0.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Docs/README.md +2 -0
- package/Docs/autonomous-intent-delivery.md +37 -0
- package/Docs/guide-catalogue.md +2 -0
- package/Docs/quality/persona-driven-test-scenarios.md +6 -0
- package/README.md +17 -0
- package/config/autonomy-worker.schema.json +70 -0
- package/config/autonomy.schema.json +25 -0
- package/package.json +3 -3
- package/public/app.js +7 -0
- package/public/autonomy.js +470 -0
- package/public/index.html +26 -0
- package/public/styles.css +64 -0
- package/skills-src/ewai-autonomy/SKILL.md +18 -0
- package/skills-src/ewai-autonomy/agents/openai.yaml +4 -0
- package/skills-src/ewai-deliver/SKILL.md +2 -0
- package/skills-src/ewai-test-scenarios/references/scenario-contract.md +6 -0
- package/src/autonomy-phase-contracts.mjs +88 -0
- package/src/autonomy-worker.mjs +15 -0
- package/src/autonomy.mjs +199 -0
- package/src/checkin.mjs +30 -6
- package/src/cli.mjs +67 -0
- package/src/delivery-gates.mjs +8 -1
- package/src/delivery.mjs +74 -3
- package/src/design-system-application.mjs +28 -0
- package/src/runtime/afk-conductor.mjs +191 -71
- package/src/runtime/autonomy-operations.mjs +250 -0
- package/src/runtime/autonomy-supervisor.mjs +426 -0
- package/src/runtime/autonomy-workers.mjs +333 -0
- package/src/runtime/autonomy-workspace.mjs +310 -0
- package/src/runtime/dashboard-actions.mjs +69 -0
- package/src/runtime/dashboard-server.mjs +25 -0
- package/src/runtime/database.mjs +25 -6
- package/src/runtime/intent-ownership.mjs +272 -0
- package/src/runtime/mcp-server.mjs +38 -0
- package/src/runtime/provider-adapters.mjs +365 -7
- package/src/runtime/work.mjs +21 -5
- package/src/test-scenarios.mjs +173 -20
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { constants, existsSync, lstatSync, openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, mkdirSync, readdirSync, unlinkSync, realpathSync, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { resolve, relative, dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { projectPaths } from '../paths.mjs';
|
|
9
|
+
import { deriveExecutionState } from '../execution-state.mjs';
|
|
10
|
+
import { ORGANISATION_POLICY_BASELINE_PATH } from '../organisation-policies.mjs';
|
|
11
|
+
|
|
12
|
+
export function autonomyError(code, statusCode = 409) {
|
|
13
|
+
const error = new Error(`Autonomy cannot continue: ${code}. Refresh the preview or resolve the recorded prerequisite.`);
|
|
14
|
+
error.code = code; error.statusCode = statusCode; error.autonomyError = true;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
export function autonomyGuard(operation) {
|
|
18
|
+
try { return operation(); } catch (error) {
|
|
19
|
+
if (error.autonomyError) throw error;
|
|
20
|
+
autonomyError('autonomy-evidence-unavailable');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function autonomyDigest(value) {
|
|
24
|
+
const stable = value => Array.isArray(value) ? value.map(stable) : value && typeof value === 'object'
|
|
25
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value;
|
|
26
|
+
return 'sha256:' + createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(stable(value))).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
export function autonomyPaths(root) {
|
|
29
|
+
const realRoot = realpathSync(root);
|
|
30
|
+
const locatorPath = resolve(realRoot, '.ewai-pipeline/project.json');
|
|
31
|
+
safeAutonomyPath(realRoot, locatorPath);
|
|
32
|
+
let locatorText = '', locator = {};
|
|
33
|
+
if (existsSync(locatorPath)) {
|
|
34
|
+
locatorText = readAutonomyFile(realRoot, locatorPath);
|
|
35
|
+
try { locator = JSON.parse(locatorText); } catch { autonomyError('autonomy-project-invalid', 422); }
|
|
36
|
+
if (!locator || typeof locator !== 'object' || Array.isArray(locator)
|
|
37
|
+
|| locator.schema !== 'ewai.runtime-project/v1'
|
|
38
|
+
|| locator.specsRoot !== undefined && (typeof locator.specsRoot !== 'string' || !locator.specsRoot.trim())
|
|
39
|
+
|| locator.projectRoot !== undefined && resolve(locator.projectRoot) !== realRoot) autonomyError('autonomy-project-invalid', 422);
|
|
40
|
+
}
|
|
41
|
+
const paths = projectPaths(realRoot, { specsRoot: locator.specsRoot ?? 'SPECS' });
|
|
42
|
+
return { ...paths, authorityRoot: resolve(paths.specsRoot, '3.Evidence/autonomy'),
|
|
43
|
+
controlRoot: resolve(paths.runtimeRoot, 'runtime/autonomy'), locatorDigest: autonomyDigest(locatorText) };
|
|
44
|
+
}
|
|
45
|
+
// Check every component, including dangling links. The local OS user remains trusted.
|
|
46
|
+
export function safeAutonomyPath(root, path) {
|
|
47
|
+
const rel = relative(root, path);
|
|
48
|
+
if (rel.startsWith('..') || isAbsolute(rel)) autonomyError('autonomy-unsafe-path', 403);
|
|
49
|
+
let cursor = root;
|
|
50
|
+
for (const part of rel.split('/').filter(Boolean)) {
|
|
51
|
+
cursor = resolve(cursor, part);
|
|
52
|
+
let stat; try { stat = lstatSync(cursor); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
53
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1))) autonomyError('autonomy-unsafe-path', 403);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function readAutonomyFile(root, path) {
|
|
57
|
+
return readAutonomyBytes(root, path).toString('utf8');
|
|
58
|
+
}
|
|
59
|
+
function readAutonomyBytes(root, path, limit = 2 * 1024 * 1024) {
|
|
60
|
+
safeAutonomyPath(root, path);
|
|
61
|
+
const before = lstatSync(path);
|
|
62
|
+
if (!before.isFile() || before.size > limit) autonomyError('autonomy-invalid-evidence', 422);
|
|
63
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
|
+
try {
|
|
65
|
+
const opened = fstatSync(fd);
|
|
66
|
+
if (before.ino !== opened.ino || before.dev !== opened.dev) autonomyError('autonomy-evidence-changed');
|
|
67
|
+
const bytes = readFileSync(fd), after = fstatSync(fd);
|
|
68
|
+
if (bytes.length > limit || after.mtimeMs !== opened.mtimeMs || after.size !== opened.size) autonomyError('autonomy-evidence-changed');
|
|
69
|
+
return bytes;
|
|
70
|
+
} finally { closeSync(fd); }
|
|
71
|
+
}
|
|
72
|
+
export function autonomyFiles(root, directory) {
|
|
73
|
+
safeAutonomyPath(root, directory);
|
|
74
|
+
if (!existsSync(directory)) return [];
|
|
75
|
+
const files = [];
|
|
76
|
+
const visit = (dir, depth) => {
|
|
77
|
+
if (depth > 20 || files.length > 10000) autonomyError('autonomy-evidence-limit', 413);
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
79
|
+
const path = resolve(dir, entry.name); safeAutonomyPath(root, path);
|
|
80
|
+
if (entry.isDirectory()) visit(path, depth + 1);
|
|
81
|
+
else if (entry.isFile()) files.push(path);
|
|
82
|
+
else autonomyError('autonomy-unsafe-path', 403);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
visit(directory, 0); return files;
|
|
86
|
+
}
|
|
87
|
+
export function autonomyRunIds(root) {
|
|
88
|
+
return autonomyGuard(() => {
|
|
89
|
+
const paths = autonomyPaths(root), directory = resolve(paths.controlRoot, 'supervisor/runs');
|
|
90
|
+
const ids = [...new Set(autonomyFiles(paths.projectRoot, directory).map(path => relative(directory, path).split('/')[0]))];
|
|
91
|
+
if (ids.length > 256) autonomyError('autonomy-run-evidence-limit');
|
|
92
|
+
if (ids.some(id => !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))) autonomyError('autonomy-run-evidence-invalid');
|
|
93
|
+
return ids.sort();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function writeAutonomyRecord(root, path, value) {
|
|
97
|
+
safeAutonomyPath(root, path); mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
98
|
+
safeAutonomyPath(root, path);
|
|
99
|
+
const text = JSON.stringify(value, null, 2) + '\n';
|
|
100
|
+
if (existsSync(path)) {
|
|
101
|
+
if (readAutonomyFile(root, path) !== text) autonomyError('autonomy-record-conflict');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
105
|
+
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
106
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
107
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
108
|
+
}
|
|
109
|
+
export function withAutonomyLock(paths, operation) {
|
|
110
|
+
safeAutonomyPath(paths.projectRoot, paths.controlRoot); mkdirSync(paths.controlRoot, { recursive: true, mode: 0o700 });
|
|
111
|
+
const path = resolve(paths.controlRoot, 'authority.lock'); let fd;
|
|
112
|
+
try {
|
|
113
|
+
try { fd = openSync(path, 'wx', 0o600); } catch { autonomyError('autonomy-authority-busy'); }
|
|
114
|
+
return operation();
|
|
115
|
+
} finally { if (fd !== undefined) { closeSync(fd); unlinkSync(path); } }
|
|
116
|
+
}
|
|
117
|
+
function operationalSnapshot(paths) {
|
|
118
|
+
const path = resolve(paths.runtimeRoot, 'data/pipeline.sqlite'); safeAutonomyPath(paths.projectRoot, path);
|
|
119
|
+
if (!existsSync(path)) return { priorities: new Map(), owners: [], leases: [] };
|
|
120
|
+
// SQLite readOnly still creates WAL/SHM files. Query a verified private copy so
|
|
121
|
+
// shadow does not mutate the project or recover its disposable projection.
|
|
122
|
+
const temporary = mkdtempSync(resolve(tmpdir(), 'ewai-autonomy-snapshot-'));
|
|
123
|
+
let db;
|
|
124
|
+
try {
|
|
125
|
+
const sources = [path, `${path}-wal`].filter(file => existsSync(file));
|
|
126
|
+
const signatures = sources.map(file => {
|
|
127
|
+
safeAutonomyPath(paths.projectRoot, file);
|
|
128
|
+
const before = lstatSync(file);
|
|
129
|
+
if (before.size > 128 * 1024 * 1024) autonomyError('autonomy-evidence-limit', 413);
|
|
130
|
+
const bytes = readFileSync(file), after = lstatSync(file);
|
|
131
|
+
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) autonomyError('autonomy-evidence-changed');
|
|
132
|
+
writeFileSync(resolve(temporary, file === path ? 'snapshot.sqlite' : 'snapshot.sqlite-wal'), bytes, { flag: 'wx', mode: 0o600 });
|
|
133
|
+
return [file, after];
|
|
134
|
+
});
|
|
135
|
+
if (existsSync(`${path}-wal`) !== sources.includes(`${path}-wal`)) autonomyError('autonomy-evidence-changed');
|
|
136
|
+
for (const [file, before] of signatures) {
|
|
137
|
+
const after = lstatSync(file);
|
|
138
|
+
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) autonomyError('autonomy-evidence-changed');
|
|
139
|
+
}
|
|
140
|
+
db = new DatabaseSync(resolve(temporary, 'snapshot.sqlite'), { readOnly: true });
|
|
141
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name));
|
|
142
|
+
const priorities = tables.has('work_items') ? new Map(db.prepare('SELECT intent_id, priority FROM work_items').all().map(row => [row.intent_id, row.priority])) : new Map();
|
|
143
|
+
const owners = tables.has('active_sessions') ? db.prepare("SELECT work_item_id, status FROM active_sessions WHERE status NOT IN ('completed','cancelled')").all() : [];
|
|
144
|
+
const leases = tables.has('execution_leases') ? db.prepare("SELECT work_item_id, task_id FROM execution_leases WHERE status='active'").all() : [];
|
|
145
|
+
return { priorities, owners, leases };
|
|
146
|
+
} finally { db?.close(); rmSync(temporary, { recursive: true, force: true }); }
|
|
147
|
+
}
|
|
148
|
+
export const AUTONOMY_PHASES = Object.freeze(['ideate', 'intent', 'reconcile', 'ui-design', 'plan', 'pattern-validation', 'test-plan',
|
|
149
|
+
'validate-external-plan', 'validate-external-test-plan', 'fit-check', 'build', 'standards-sweep', 'test-execute',
|
|
150
|
+
'validate-external-code', 'delivery', 'manual-qa', 'retro', 'complete', 'backlog']);
|
|
151
|
+
|
|
152
|
+
export function readAutonomySnapshot(root) {
|
|
153
|
+
const temporary = realpathSync(mkdtempSync(resolve(tmpdir(), 'ewai-autonomy-canonical-')));
|
|
154
|
+
try { return readSnapshot(root, temporary); }
|
|
155
|
+
finally { rmSync(temporary, { recursive: true, force: true }); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readAutonomyRepositoryState(root, ownedFiles = []) {
|
|
159
|
+
const paths = autonomyPaths(root), config = YAML.parse(readAutonomyFile(paths.projectRoot, paths.configPath));
|
|
160
|
+
const repositories = config.repositories;
|
|
161
|
+
if (!Array.isArray(repositories) || !repositories.length) autonomyError('autonomy-repository-config-invalid');
|
|
162
|
+
const roots = [], records = [], reasons = [];
|
|
163
|
+
for (const repository of repositories) {
|
|
164
|
+
if (typeof repository.path !== 'string') autonomyError('autonomy-repository-config-invalid');
|
|
165
|
+
const configured = resolve(paths.projectRoot, repository.path); safeAutonomyPath(paths.projectRoot, configured);
|
|
166
|
+
let actual, status;
|
|
167
|
+
try {
|
|
168
|
+
actual = realpathSync(execFileSync('git', ['-C', configured, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim());
|
|
169
|
+
if (actual !== realpathSync(configured)) autonomyError('autonomy-repository-root-mismatch');
|
|
170
|
+
status = execFileSync('git', ['-C', actual, 'status', '--porcelain=v1', '--untracked-files=all', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
171
|
+
} catch { autonomyError('autonomy-repository-unavailable'); }
|
|
172
|
+
if (roots.some(other => actual === other || actual.startsWith(other + '/') || other.startsWith(actual + '/'))) reasons.push('autonomy-repository-topology-overlap');
|
|
173
|
+
roots.push(actual);
|
|
174
|
+
// Only exact, unchanged files from this supervisor's canonical receipt may
|
|
175
|
+
// be carried into its next action. Staged edits, renames and deletions block.
|
|
176
|
+
const dirty = status.split('\0').filter(Boolean).some(entry => {
|
|
177
|
+
if (!['??', ' M'].includes(entry.slice(0, 2))) return true;
|
|
178
|
+
const path = resolve(actual, entry.slice(3)), rel = relative(paths.projectRoot, path);
|
|
179
|
+
const owned = ownedFiles.find(file => file.path === rel);
|
|
180
|
+
return !owned || autonomyDigest(readAutonomyFile(paths.projectRoot, path)) !== owned.digest;
|
|
181
|
+
});
|
|
182
|
+
if (dirty) reasons.push('autonomy-repository-dirty');
|
|
183
|
+
const identity = execFileSync('git', ['-C', actual, 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
184
|
+
records.push({ root: relative(paths.projectRoot, actual), identity });
|
|
185
|
+
}
|
|
186
|
+
return { status: reasons.length ? 'blocked' : 'ready', reasons: [...new Set(reasons)], digest: autonomyDigest(records), records };
|
|
187
|
+
}
|
|
188
|
+
function readSnapshot(root, temporary) {
|
|
189
|
+
const paths = autonomyPaths(root);
|
|
190
|
+
const requireNoRecovery = () => {
|
|
191
|
+
if (autonomyFiles(paths.projectRoot, resolve(paths.runtimeRoot, 'runtime/intent-transactions')).some(path => path.endsWith('.json'))) autonomyError('autonomy-recovery-required');
|
|
192
|
+
};
|
|
193
|
+
requireNoRecovery();
|
|
194
|
+
const captured = new Map(); let totalBytes = 0;
|
|
195
|
+
const capture = path => {
|
|
196
|
+
const before = lstatSync(path);
|
|
197
|
+
const bytes = readAutonomyBytes(paths.projectRoot, path, 128 * 1024 * 1024);
|
|
198
|
+
const after = lstatSync(path);
|
|
199
|
+
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs) autonomyError('autonomy-evidence-changed');
|
|
200
|
+
totalBytes += bytes.length;
|
|
201
|
+
if (totalBytes > 128 * 1024 * 1024 || captured.size >= 10000) autonomyError('autonomy-evidence-limit', 413);
|
|
202
|
+
const target = resolve(temporary, relative(paths.projectRoot, path));
|
|
203
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
204
|
+
writeFileSync(target, bytes, { flag: 'wx', mode: 0o600 });
|
|
205
|
+
captured.set(path, before); return target;
|
|
206
|
+
};
|
|
207
|
+
mkdirSync(resolve(temporary, '.ewai-pipeline'), { mode: 0o700 });
|
|
208
|
+
writeFileSync(resolve(temporary, '.ewai-pipeline/project.json'), JSON.stringify({ schema: 'ewai.runtime-project/v1', projectRoot: temporary, specsRoot: paths.specsRelative }), { flag: 'wx', mode: 0o600 });
|
|
209
|
+
const configText = readAutonomyFile(temporary, capture(paths.configPath));
|
|
210
|
+
const config = YAML.parse(configText, { maxAliasCount: 100 });
|
|
211
|
+
if (config?.schema !== 'ewai.project/v1' || config.specs?.root !== paths.specsRelative) autonomyError('autonomy-project-invalid', 422);
|
|
212
|
+
const intentsRoot = resolve(paths.specsRoot, '2.Purpose/intents');
|
|
213
|
+
const intents = autonomyFiles(paths.projectRoot, intentsRoot).filter(path => path.endsWith('.md')).map(path => {
|
|
214
|
+
const snapshotPath = capture(path);
|
|
215
|
+
const source = readAutonomyFile(temporary, snapshotPath), match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
216
|
+
if (!match) autonomyError('autonomy-intent-invalid', 422);
|
|
217
|
+
const metadata = YAML.parse(match[1], { maxAliasCount: 100 });
|
|
218
|
+
const id = relative(intentsRoot, path).replace(/\.md$/, '');
|
|
219
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)
|
|
220
|
+
|| metadata.schema !== 'ewai.intent/v1' || id !== `${metadata.domain}/${metadata.slug}`) autonomyError('autonomy-intent-invalid', 422);
|
|
221
|
+
const sidecarText = readAutonomyFile(temporary, capture(path.replace(/\.md$/, '.json')));
|
|
222
|
+
const sidecar = JSON.parse(sidecarText);
|
|
223
|
+
if (sidecar?.schema !== 'ewai.intent-state/v1' || sidecar.slug !== metadata.slug || sidecar.domain !== metadata.domain) autonomyError('autonomy-intent-invalid', 422);
|
|
224
|
+
const comparisons = { status: metadata.status, currentPhase: metadata.current_phase,
|
|
225
|
+
deliveryStatus: metadata.delivery_status, deliveryStatePath: metadata.delivery_state_path ?? null,
|
|
226
|
+
relationships: metadata.relationships ?? [], personas: metadata.personas ?? [],
|
|
227
|
+
intentMap: metadata.intent_map ?? null, deliveryShape: metadata.delivery_shape ?? null };
|
|
228
|
+
const consistent = Object.entries(comparisons).every(([key, value]) => JSON.stringify(sidecar[key]) === JSON.stringify(value));
|
|
229
|
+
return { id, slug: metadata.slug, domain: metadata.domain, status: metadata.status, path: snapshotPath,
|
|
230
|
+
...comparisons, sourceDigest: autonomyDigest([source, sidecarText]), consistent };
|
|
231
|
+
});
|
|
232
|
+
const catalog = new Map(intents.map(intent => [intent.id, intent]));
|
|
233
|
+
const operational = operationalSnapshot(paths);
|
|
234
|
+
const policyInventories = [resolve(paths.projectRoot, dirname(ORGANISATION_POLICY_BASELINE_PATH)), resolve(paths.specsRoot, '3.Evidence/policy')].map(directory => {
|
|
235
|
+
const files = autonomyFiles(paths.projectRoot, directory);
|
|
236
|
+
for (const file of files) capture(file);
|
|
237
|
+
return [directory, files];
|
|
238
|
+
});
|
|
239
|
+
const deliveryInventories = intents.map(intent => {
|
|
240
|
+
const directory = resolve(paths.buildRoot, intent.slug);
|
|
241
|
+
const files = autonomyFiles(paths.projectRoot, directory);
|
|
242
|
+
for (const file of files) capture(file);
|
|
243
|
+
return [directory, files];
|
|
244
|
+
});
|
|
245
|
+
// Canonical Plan controls may cite files outside SPECS. Follow only their
|
|
246
|
+
// bounded data references, never commands, and let canonical validation
|
|
247
|
+
// decide whether the copied evidence actually satisfies the accepted gate.
|
|
248
|
+
const traceEvidence = new Map();
|
|
249
|
+
for (const intent of intents) {
|
|
250
|
+
const gatePath = resolve(temporary, paths.specsRelative, '6.Build', intent.slug, 'gates/plan/organisation-policy-design.json');
|
|
251
|
+
if (!existsSync(gatePath)) continue;
|
|
252
|
+
const gate = JSON.parse(readAutonomyFile(temporary, gatePath));
|
|
253
|
+
if (gate.schema !== 'ewai.policy-phase-gate/v1' || gate.phase !== 'plan' || gate.controlTrace?.status !== 'pass') continue;
|
|
254
|
+
if (!Array.isArray(gate.controlTrace.traces)) autonomyError('autonomy-invalid-evidence', 422);
|
|
255
|
+
for (const trace of gate.controlTrace.traces) {
|
|
256
|
+
if (!['automated-test', 'named-human', 'specialist-review'].includes(trace.route)) continue;
|
|
257
|
+
const reference = trace.reference;
|
|
258
|
+
if (typeof reference !== 'string' || !reference || reference.length > 4096 || /[\\\x00-\x1f\x7f]/.test(reference)
|
|
259
|
+
|| isAbsolute(reference) || reference.split('/').includes('..')) autonomyError('autonomy-unsafe-path', 403);
|
|
260
|
+
const path = resolve(paths.projectRoot, reference);
|
|
261
|
+
safeAutonomyPath(paths.projectRoot, path);
|
|
262
|
+
if (traceEvidence.has(path)) continue;
|
|
263
|
+
if (!existsSync(path)) { traceEvidence.set(path, null); continue; }
|
|
264
|
+
const copied = captured.has(path) ? resolve(temporary, relative(paths.projectRoot, path)) : capture(path);
|
|
265
|
+
traceEvidence.set(path, autonomyDigest(readAutonomyFile(temporary, copied)));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
requireNoRecovery();
|
|
269
|
+
const items = intents.map(intent => {
|
|
270
|
+
const deliveryRoot = resolve(temporary, paths.specsRelative, '6.Build', intent.slug);
|
|
271
|
+
const statePath = resolve(deliveryRoot, 'delivery-state.json');
|
|
272
|
+
const stateText = existsSync(statePath) ? readAutonomyFile(temporary, statePath) : '';
|
|
273
|
+
const state = stateText ? JSON.parse(stateText) : null;
|
|
274
|
+
const phasesValid = !state || [...AUTONOMY_PHASES, 'shelf-ready', 'complete-dry-run'].includes(state.currentPhase)
|
|
275
|
+
&& [...(state.phases ?? []), ...(state.adjuncts ?? []), ...(state.humanGates ?? [])].every(phase => AUTONOMY_PHASES.includes(phase.id));
|
|
276
|
+
// Recovery-capable canonical checks see only the disposable private copy.
|
|
277
|
+
const execution = phasesValid ? deriveExecutionState(temporary, intent, { intentCatalog: catalog }) : {
|
|
278
|
+
valid: false, blockers: [{ code: 'canonical-phase-invalid' }], lifecycle: { nextPhase: null },
|
|
279
|
+
actions: Object.fromEntries(['beginHarness', 'continueHarness', 'approveBuild', 'enterBuild', 'acquireTask'].map(key => [key, { permitted: false, blockers: [] }])),
|
|
280
|
+
};
|
|
281
|
+
if (!intent.consistent) { execution.valid = false; execution.blockers.push({ code: 'intent-state-drift' }); }
|
|
282
|
+
const rawPriority = operational.priorities.get(intent.id);
|
|
283
|
+
// Existing P2/string labels are not inferred numeric authority: neutral priority is zero.
|
|
284
|
+
const priority = (typeof rawPriority === 'number' || typeof rawPriority === 'string' && /^-?\d+(?:\.\d+)?$/.test(rawPriority))
|
|
285
|
+
&& Number.isFinite(Number(rawPriority)) ? Number(rawPriority) : 0;
|
|
286
|
+
const accepted = ['ready', 'approved'].includes(intent.status)
|
|
287
|
+
|| intent.status === 'in-progress' && state?.phases?.some(phase => phase.id === 'intent' && phase.status === 'completed');
|
|
288
|
+
return { intentId: intent.id, status: intent.status, accepted: Boolean(accepted), execution, priority,
|
|
289
|
+
sourceDigest: autonomyDigest([intent.sourceDigest, stateText]),
|
|
290
|
+
repositoryBusy: operational.owners.length > 0 || operational.leases.length > 0 };
|
|
291
|
+
});
|
|
292
|
+
requireNoRecovery();
|
|
293
|
+
if (autonomyPaths(root).locatorDigest !== paths.locatorDigest) autonomyError('autonomy-evidence-changed');
|
|
294
|
+
for (const [path, before] of captured) {
|
|
295
|
+
const after = lstatSync(path);
|
|
296
|
+
if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) autonomyError('autonomy-evidence-changed');
|
|
297
|
+
}
|
|
298
|
+
for (const [path, digest] of traceEvidence) if (digest === null && existsSync(path)) autonomyError('autonomy-evidence-changed');
|
|
299
|
+
for (const [directory, files] of [...deliveryInventories, ...policyInventories]) if (JSON.stringify(autonomyFiles(paths.projectRoot, directory)) !== JSON.stringify(files)) autonomyError('autonomy-evidence-changed');
|
|
300
|
+
const configDigest = autonomyDigest(configText);
|
|
301
|
+
return { items, configDigest, projectIdentity: autonomyDigest(paths.projectRoot),
|
|
302
|
+
revision: autonomyDigest({ configDigest, locatorDigest: paths.locatorDigest,
|
|
303
|
+
traceEvidence: [...traceEvidence].map(([path, digest]) => [relative(paths.projectRoot, path), digest]), items: items.map(item => ({ ...item, execution: {
|
|
304
|
+
valid: item.execution.valid, lifecycle: item.execution.lifecycle, actions: Object.fromEntries(Object.entries(item.execution.actions).map(([key, action]) => [key, {
|
|
305
|
+
permitted: action.permitted, phase: action.phase ?? null, nextPhase: action.nextPhase ?? null,
|
|
306
|
+
candidates: action.candidates ?? [], blockers: action.blockers.map(blocker => blocker.code),
|
|
307
|
+
}])),
|
|
308
|
+
blockers: item.execution.blockers.map(blocker => blocker.code),
|
|
309
|
+
} })) }) };
|
|
310
|
+
}
|
|
@@ -13,6 +13,75 @@ import {
|
|
|
13
13
|
startAfkRun,
|
|
14
14
|
} from './afk-conductor.mjs';
|
|
15
15
|
import { queueDashboardHandoff } from './dashboard-handoffs.mjs';
|
|
16
|
+
import { readAutonomyPolicy, previewAutonomy, approveAutonomyGrant, revokeAutonomyGrant } from '../autonomy.mjs';
|
|
17
|
+
import { readAutonomyRun, runAutonomyOnce, startAutonomyService, controlAutonomyRun, answerAutonomyQuestion } from './autonomy-supervisor.mjs';
|
|
18
|
+
import { autonomyError, autonomyRunIds } from './autonomy-workspace.mjs';
|
|
19
|
+
|
|
20
|
+
// All three transports retain the complete request until this closed validator
|
|
21
|
+
// accepts it. No adapter may strip unknown authority-bearing fields first.
|
|
22
|
+
export const AUTONOMY_INTERFACE_FIELDS = Object.freeze({
|
|
23
|
+
status: ['runId'], preview: ['proposal', 'record', 'confirmed'],
|
|
24
|
+
approve: ['expectedDigest', 'approvedBy', 'confirmed'], revoke: ['expectedDigest', 'revokedBy', 'confirmed'],
|
|
25
|
+
run: ['expectedDigest', 'provider', 'confirmed'], service: ['expectedDigest', 'provider', 'confirmed'],
|
|
26
|
+
control: ['runId', 'action', 'expectedRevision', 'confirmed', 'revokedBy'],
|
|
27
|
+
answer: ['runId', 'questionId', 'expectedRevision', 'answeredBy', 'answer', 'confirmed'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function safeAutonomyInterfaceError(error) {
|
|
31
|
+
const code = /^(?:autonomy|phase)-[a-z0-9-]{1,100}$/.test(error?.code ?? '') ? error.code : 'autonomy-invalid-input';
|
|
32
|
+
const statusCode = [400, 403, 404, 405, 409, 413, 415, 422].includes(error?.statusCode) ? error.statusCode : 400;
|
|
33
|
+
return { code, statusCode, error: `Autonomy cannot continue: ${code}. Refresh the preview or resolve the recorded prerequisite.` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateAutonomyInterfaceInput(action, input) {
|
|
37
|
+
const fields = AUTONOMY_INTERFACE_FIELDS[action];
|
|
38
|
+
if (!fields || !input || Object.getPrototypeOf(input) !== Object.prototype || Array.isArray(input)
|
|
39
|
+
|| Object.keys(input).some(key => !fields.includes(key))) autonomyError('autonomy-invalid-input', 400);
|
|
40
|
+
const checks = {
|
|
41
|
+
confirmed: value => typeof value === 'boolean', record: value => typeof value === 'boolean',
|
|
42
|
+
expectedDigest: value => typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value),
|
|
43
|
+
runId: value => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value),
|
|
44
|
+
expectedRevision: value => Number.isSafeInteger(value) && value > 0,
|
|
45
|
+
questionId: value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value),
|
|
46
|
+
provider: value => ['codex', 'claude', 'antigravity'].includes(value),
|
|
47
|
+
action: value => ['pause', 'resume', 'cancel', 'revoke', 'recover'].includes(value),
|
|
48
|
+
answer: value => typeof value === 'string' && !!value.trim() && Buffer.byteLength(value) <= 16384,
|
|
49
|
+
proposal: value => !!value && Object.getPrototypeOf(value) === Object.prototype,
|
|
50
|
+
};
|
|
51
|
+
const named = value => typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= 120 && !/[\x00-\x1f\x7f]/.test(value);
|
|
52
|
+
for (const key of ['approvedBy', 'revokedBy', 'answeredBy']) checks[key] = named;
|
|
53
|
+
for (const [key, value] of Object.entries(input)) if (!checks[key]?.(value)) autonomyError('autonomy-invalid-input', 400);
|
|
54
|
+
if (action !== 'status' && action !== 'preview' && input.confirmed !== true
|
|
55
|
+
|| action === 'preview' && input.record === true && input.confirmed !== true) autonomyError('autonomy-confirmation-required', 409);
|
|
56
|
+
const required = { approve: ['expectedDigest', 'approvedBy'], revoke: ['expectedDigest', 'revokedBy'],
|
|
57
|
+
run: ['expectedDigest', 'provider'], service: ['expectedDigest', 'provider'], control: ['runId', 'action', 'expectedRevision'],
|
|
58
|
+
answer: ['runId', 'questionId', 'expectedRevision', 'answeredBy', 'answer'] }[action] ?? [];
|
|
59
|
+
if (required.some(key => input[key] === undefined)) autonomyError('autonomy-invalid-input', 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function autonomyInterfaceAction(projectRoot, action, input = {}) {
|
|
63
|
+
try {
|
|
64
|
+
validateAutonomyInterfaceInput(action, input);
|
|
65
|
+
const { confirmed, ...data } = input;
|
|
66
|
+
if (action === 'status') {
|
|
67
|
+
if (input.runId) return readAutonomyRun(projectRoot, input.runId);
|
|
68
|
+
const policy = readAutonomyPolicy(projectRoot), runs = autonomyRunIds(projectRoot).map(id => readAutonomyRun(projectRoot, id));
|
|
69
|
+
return { ...policy, runs, serviceStarted: runs.some(run => run.mode === 'service' && run.lifetime.ownerProcessAlive
|
|
70
|
+
&& ['starting', 'running', 'cancel-requested'].includes(run.status)) };
|
|
71
|
+
}
|
|
72
|
+
if (action === 'preview') return previewAutonomy(projectRoot, data);
|
|
73
|
+
if (action === 'approve') return approveAutonomyGrant(projectRoot, input);
|
|
74
|
+
if (action === 'revoke') return revokeAutonomyGrant(projectRoot, input);
|
|
75
|
+
if (action === 'run') return await runAutonomyOnce(projectRoot, data);
|
|
76
|
+
if (action === 'service') return startAutonomyService(projectRoot, input);
|
|
77
|
+
if (action === 'control') return await controlAutonomyRun(projectRoot, input);
|
|
78
|
+
if (action === 'answer') return answerAutonomyQuestion(projectRoot, data);
|
|
79
|
+
autonomyError('autonomy-invalid-input', 400);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const safe = safeAutonomyInterfaceError(error);
|
|
82
|
+
autonomyError(safe.code, safe.statusCode);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
16
85
|
|
|
17
86
|
function requireConfirmation(input, action) {
|
|
18
87
|
if (input?.confirmed !== true) throw new Error(`${action} requires explicit confirmation.`);
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
safeContextManifest,
|
|
43
43
|
} from './context-assembly.mjs';
|
|
44
44
|
import {
|
|
45
|
+
autonomyInterfaceAction,
|
|
46
|
+
safeAutonomyInterfaceError,
|
|
45
47
|
approveBuildFromDashboard,
|
|
46
48
|
controlAfkFromDashboard,
|
|
47
49
|
dashboardAfkRuns,
|
|
@@ -420,6 +422,26 @@ const server = createServer(async (request, response) => {
|
|
|
420
422
|
|
|
421
423
|
const url = new URL(request.url ?? '/', `http://${host}`);
|
|
422
424
|
try {
|
|
425
|
+
if (url.pathname === '/api/autonomy' || url.pathname.startsWith('/api/autonomy/')) {
|
|
426
|
+
if (![ `127.0.0.1:${requestedPort}`, `localhost:${requestedPort}` ].includes(host)) {
|
|
427
|
+
return json(response, 403, safeAutonomyInterfaceError({ code: 'autonomy-host-invalid', statusCode: 403 }));
|
|
428
|
+
}
|
|
429
|
+
const action = url.pathname === '/api/autonomy' ? 'status' : url.pathname.slice('/api/autonomy/'.length);
|
|
430
|
+
if (!['status', 'preview', 'approve', 'revoke', 'run', 'service', 'control', 'answer'].includes(action)) {
|
|
431
|
+
return json(response, 404, safeAutonomyInterfaceError({ code: 'autonomy-route-not-found', statusCode: 404 }));
|
|
432
|
+
}
|
|
433
|
+
if (request.method !== (action === 'status' ? 'GET' : 'POST')) {
|
|
434
|
+
return json(response, 405, safeAutonomyInterfaceError({ code: 'autonomy-method-not-allowed', statusCode: 405 }));
|
|
435
|
+
}
|
|
436
|
+
if ([...url.searchParams.keys()].some(key => action !== 'status' || key !== 'runId') || url.searchParams.getAll('runId').length > 1) {
|
|
437
|
+
return json(response, 400, safeAutonomyInterfaceError({ code: 'autonomy-invalid-input', statusCode: 400 }));
|
|
438
|
+
}
|
|
439
|
+
if (action !== 'status' && String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
440
|
+
return json(response, 415, safeAutonomyInterfaceError({ code: 'autonomy-json-required', statusCode: 415 }));
|
|
441
|
+
}
|
|
442
|
+
const input = action === 'status' ? (url.searchParams.has('runId') ? { runId: url.searchParams.get('runId') } : {}) : await readJson(request);
|
|
443
|
+
return json(response, 200, await autonomyInterfaceAction(projectRoot, action, input));
|
|
444
|
+
}
|
|
423
445
|
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
424
446
|
return json(response, 200, {
|
|
425
447
|
ok: true,
|
|
@@ -1205,6 +1227,9 @@ const server = createServer(async (request, response) => {
|
|
|
1205
1227
|
if (request.method === 'GET' && serveStatic(response, url.pathname)) return;
|
|
1206
1228
|
return json(response, 404, { error: 'Not found' });
|
|
1207
1229
|
} catch (error) {
|
|
1230
|
+
if (url.pathname === '/api/autonomy' || url.pathname.startsWith('/api/autonomy/')) {
|
|
1231
|
+
const safe = safeAutonomyInterfaceError(error); return json(response, safe.statusCode, safe);
|
|
1232
|
+
}
|
|
1208
1233
|
if (!url.pathname.startsWith('/api/error-reports')) {
|
|
1209
1234
|
try {
|
|
1210
1235
|
captureErrorReportFailure(projectRoot, {
|
package/src/runtime/database.mjs
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { runtimePaths } from './paths.mjs';
|
|
5
6
|
|
|
6
7
|
export function openRuntimeDatabase(projectRoot) {
|
|
7
8
|
const { databasePath } = runtimePaths(projectRoot);
|
|
8
9
|
mkdirSync(dirname(databasePath), { recursive: true });
|
|
9
10
|
const database = new DatabaseSync(databasePath);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
try {
|
|
12
|
+
database.exec('PRAGMA foreign_keys = ON;');
|
|
13
|
+
database.exec('PRAGMA busy_timeout = 5000;');
|
|
14
|
+
database.exec('PRAGMA journal_mode = WAL;');
|
|
15
|
+
migrateRuntimeDatabase(database);
|
|
16
|
+
return database;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
database.close();
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
export function migrateRuntimeDatabase(database) {
|
|
@@ -22,6 +28,18 @@ export function migrateRuntimeDatabase(database) {
|
|
|
22
28
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
23
29
|
);
|
|
24
30
|
|
|
31
|
+
CREATE TABLE IF NOT EXISTS intent_ownership (
|
|
32
|
+
intent_id TEXT PRIMARY KEY,
|
|
33
|
+
epoch TEXT NOT NULL,
|
|
34
|
+
generation INTEGER NOT NULL,
|
|
35
|
+
owner_id TEXT NOT NULL,
|
|
36
|
+
token_hash TEXT NOT NULL,
|
|
37
|
+
expires_at INTEGER NOT NULL,
|
|
38
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'released')),
|
|
39
|
+
pid INTEGER NOT NULL,
|
|
40
|
+
process_instance TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
|
|
25
43
|
CREATE TABLE IF NOT EXISTS intents (
|
|
26
44
|
id TEXT PRIMARY KEY,
|
|
27
45
|
slug TEXT NOT NULL,
|
|
@@ -773,9 +791,10 @@ export function migrateRuntimeDatabase(database) {
|
|
|
773
791
|
|
|
774
792
|
database.prepare(`
|
|
775
793
|
INSERT INTO runtime_meta (key, value, updated_at)
|
|
776
|
-
VALUES ('schema_version', '
|
|
794
|
+
VALUES ('schema_version', '15', CURRENT_TIMESTAMP)
|
|
777
795
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
|
778
796
|
`).run();
|
|
797
|
+
database.prepare("INSERT INTO runtime_meta (key, value) VALUES ('intent_ownership_epoch', ?) ON CONFLICT(key) DO NOTHING").run(randomUUID());
|
|
779
798
|
}
|
|
780
799
|
|
|
781
800
|
export function initializeRuntime(projectRoot) {
|