@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.
Files changed (37) hide show
  1. package/Docs/README.md +2 -0
  2. package/Docs/autonomous-intent-delivery.md +37 -0
  3. package/Docs/guide-catalogue.md +2 -0
  4. package/Docs/quality/persona-driven-test-scenarios.md +6 -0
  5. package/README.md +17 -0
  6. package/config/autonomy-worker.schema.json +70 -0
  7. package/config/autonomy.schema.json +25 -0
  8. package/package.json +3 -3
  9. package/public/app.js +7 -0
  10. package/public/autonomy.js +470 -0
  11. package/public/index.html +26 -0
  12. package/public/styles.css +64 -0
  13. package/skills-src/ewai-autonomy/SKILL.md +18 -0
  14. package/skills-src/ewai-autonomy/agents/openai.yaml +4 -0
  15. package/skills-src/ewai-deliver/SKILL.md +2 -0
  16. package/skills-src/ewai-test-scenarios/references/scenario-contract.md +6 -0
  17. package/src/autonomy-phase-contracts.mjs +88 -0
  18. package/src/autonomy-worker.mjs +15 -0
  19. package/src/autonomy.mjs +199 -0
  20. package/src/checkin.mjs +30 -6
  21. package/src/cli.mjs +67 -0
  22. package/src/delivery-gates.mjs +8 -1
  23. package/src/delivery.mjs +74 -3
  24. package/src/design-system-application.mjs +28 -0
  25. package/src/runtime/afk-conductor.mjs +191 -71
  26. package/src/runtime/autonomy-operations.mjs +250 -0
  27. package/src/runtime/autonomy-supervisor.mjs +426 -0
  28. package/src/runtime/autonomy-workers.mjs +333 -0
  29. package/src/runtime/autonomy-workspace.mjs +310 -0
  30. package/src/runtime/dashboard-actions.mjs +69 -0
  31. package/src/runtime/dashboard-server.mjs +25 -0
  32. package/src/runtime/database.mjs +25 -6
  33. package/src/runtime/intent-ownership.mjs +272 -0
  34. package/src/runtime/mcp-server.mjs +38 -0
  35. package/src/runtime/provider-adapters.mjs +365 -7
  36. package/src/runtime/work.mjs +21 -5
  37. 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, {
@@ -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
- database.exec('PRAGMA foreign_keys = ON;');
11
- database.exec('PRAGMA busy_timeout = 5000;');
12
- database.exec('PRAGMA journal_mode = WAL;');
13
- migrateRuntimeDatabase(database);
14
- return database;
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', '14', CURRENT_TIMESTAMP)
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) {