@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
@@ -26,6 +26,9 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
26
26
  const workerPath = resolve(moduleDir, '../afk-worker.mjs');
27
27
  const runSchema = 'ewai.afk-run/v1';
28
28
  const terminalStates = new Set(['completed', 'cancelled']);
29
+ const runningControls = new Map();
30
+ const authorityChecks = new WeakMap();
31
+ const controlKey = (root, id) => `${resolve(root)}:${id}`;
29
32
 
30
33
  function git(root, args, options = {}) {
31
34
  return execFileSync('git', args, {
@@ -68,12 +71,13 @@ function requireRun(projectRoot, runId) {
68
71
  return { paths, run };
69
72
  }
70
73
 
71
- function saveRun(paths, run) {
74
+ function saveRun(paths, run, resumeFrom = null) {
72
75
  if (existsSync(paths.runPath)) {
73
76
  const current = JSON.parse(readFileSync(paths.runPath, 'utf8'));
74
- if (current.id === run.id && current.desiredState !== 'running' && run.desiredState === 'running') {
77
+ if (resumeFrom !== null && JSON.stringify(current) !== resumeFrom) throw new Error('AFK control changed before resume; refresh its current state.');
78
+ if (resumeFrom === null && current.id === run.id && current.desiredState !== 'running' && run.desiredState === 'running') {
75
79
  run.desiredState = current.desiredState;
76
- if (current.desiredState === 'cancelled') run.status = 'cancelled';
80
+ if (current.desiredState === 'cancelled') run.status = 'cancel-requested';
77
81
  }
78
82
  }
79
83
  run.updatedAt = now();
@@ -321,6 +325,15 @@ function spawnWorker(projectRoot, runId) {
321
325
  }
322
326
 
323
327
  export function startAfkRun(projectRoot, slug, options = {}) {
328
+ if (options.autonomy !== undefined) {
329
+ const value = options.autonomy;
330
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).length !== 2
331
+ || !/^[a-f0-9-]{36}$/.test(value.runId ?? '') || !/^sha256:[a-f0-9]{64}$/.test(value.grantDigest ?? '')
332
+ || options.foreground !== true || typeof options.dependencies?.authority !== 'function') {
333
+ throw new Error('Autonomous AFK requires its bounded foreground authority context.');
334
+ }
335
+ options.dependencies.authority({ stage: 'prepare', mode: 'command' });
336
+ }
324
337
  const root = resolve(projectRoot);
325
338
  const lockParent = resolve(root, '.ewai-pipeline/afk/locks');
326
339
  const lockPath = resolve(lockParent, `${slug}.lock`);
@@ -335,6 +348,7 @@ export function startAfkRun(projectRoot, slug, options = {}) {
335
348
  if (preflight.status !== 'ready') throw new Error(`AFK preflight blocked: ${preflight.failures.join(' ')}`);
336
349
  const indexed = syncIntentIndex(preflight.projectRoot).intents.find((intent) => intent.slug === slug);
337
350
  if (!indexed) throw new Error(`Unknown intent slug: ${slug}`);
351
+ options.dependencies?.authority?.({ stage: 'prepare', mode: 'command' });
338
352
  prepareParentBranches(preflight.topology);
339
353
  const runId = randomUUID();
340
354
  const paths = pathsFor(preflight.projectRoot, runId);
@@ -359,8 +373,11 @@ export function startAfkRun(projectRoot, slug, options = {}) {
359
373
  activeWorkers: [],
360
374
  tasks: {},
361
375
  messages: [],
376
+ executionStopped: true,
377
+ ...(options.autonomy ? { autonomy: { ...options.autonomy } } : {}),
362
378
  };
363
379
  saveRun(paths, run);
380
+ options.dependencies?.onRun?.(run);
364
381
  event(preflight.projectRoot, run, 'Preparing unattended Build execution.', `${preflight.readyTasks.length} task(s) ready; maximum parallelism ${run.maxParallel}.`);
365
382
  if (options.foreground === true) return executeAfkRun(preflight.projectRoot, runId, options.dependencies);
366
383
  run.pid = spawnWorker(preflight.projectRoot, runId);
@@ -396,29 +413,28 @@ export function pauseAfkRun(projectRoot, runId) {
396
413
  return saveRun(paths, run);
397
414
  }
398
415
 
399
- function stopWorkers(run) {
400
- for (const worker of run.activeWorkers ?? []) {
401
- if (processAlive(worker.pid)) {
402
- try { process.kill(worker.pid, 'SIGTERM'); } catch { /* already gone */ }
403
- }
404
- }
405
- }
406
-
407
416
  export function cancelAfkRun(projectRoot, runId) {
408
417
  const { paths, run } = requireRun(projectRoot, runId);
409
418
  if (terminalStates.has(run.status)) return run;
410
419
  run.desiredState = 'cancelled';
411
- run.status = 'cancelled';
412
- stopWorkers(run);
413
- abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-cancelled' });
414
- run.activeWorkers = [];
415
- event(paths.projectRoot, run, 'Unattended Build execution cancelled.', 'Active task leases were safely abandoned.', 'blocked');
416
- return saveRun(paths, run);
420
+ run.status = processAlive(run.pid) ? 'cancel-requested' : 'cancel-unknown';
421
+ run.cancellation = { status: run.status === 'cancel-requested' ? 'requested' : 'unknown', requestedAt: now() };
422
+ // Only the process that owns the child handle may signal it. Another local
423
+ // caller persists the request for that owner's bounded control poll.
424
+ const saved = saveRun(paths, run);
425
+ for (const controller of runningControls.get(controlKey(projectRoot, runId)) ?? []) controller.abort();
426
+ event(paths.projectRoot, run, 'Unattended Build cancellation requested.', 'Termination is not yet confirmed; work and leases are retained.', 'blocked');
427
+ return saved;
417
428
  }
418
429
 
419
- export function resumeAfkRun(projectRoot, runId) {
430
+ export function resumeAfkRun(projectRoot, runId, options = {}) {
420
431
  const { paths, run } = requireRun(projectRoot, runId);
432
+ const resumeFrom = JSON.stringify(run);
421
433
  if (run.status === 'completed' || run.status === 'cancelled') throw new Error(`AFK run is ${run.status} and cannot resume.`);
434
+ if (run.desiredState === 'cancelled' || run.executionStopped !== true) throw new Error('AFK execution needs a verified recovery decision before resuming.');
435
+ if (run.autonomy && (run.status !== 'paused' || run.activeWorkers?.length || options.foreground !== true
436
+ || options.autonomy?.runId !== run.autonomy.runId || options.autonomy?.grantDigest !== run.autonomy.grantDigest
437
+ || typeof options.dependencies?.authority !== 'function')) throw new Error('Autonomous AFK resume requires the same bounded foreground authority context.');
422
438
  if (processAlive(run.pid)) throw new Error('AFK conductor is already running.');
423
439
  const preflight = preflightAfkRun(paths.projectRoot, run.slug, {
424
440
  provider: run.providers.join(','),
@@ -426,12 +442,19 @@ export function resumeAfkRun(projectRoot, runId) {
426
442
  ignoreRunId: run.id,
427
443
  });
428
444
  if (preflight.status !== 'ready') throw new Error(`AFK recovery preflight blocked: ${preflight.failures.join(' ')}`);
429
- stopWorkers(run);
430
- abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-recovered' });
445
+ if (run.autonomy) options.dependencies.authority({ stage: 'prepare', mode: 'command' });
446
+ else abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-recovered' });
431
447
  run.desiredState = 'running';
432
448
  run.status = 'running';
433
449
  run.topology = preflight.topology;
434
450
  run.activeWorkers = [];
451
+ if (run.autonomy) {
452
+ run.timeoutMs = Math.min(run.timeoutMs, options.timeoutMs);
453
+ if (!Number.isFinite(run.timeoutMs) || run.timeoutMs < 1) throw new Error('Autonomous AFK resume requires a bounded timeout.');
454
+ run.pid = null; saveRun(paths, run, resumeFrom); options.dependencies.onRun?.(run);
455
+ return executeAfkRun(paths.projectRoot, run.id, options.dependencies);
456
+ }
457
+ run.pid = null; saveRun(paths, run, resumeFrom);
435
458
  run.pid = spawnWorker(paths.projectRoot, run.id);
436
459
  event(paths.projectRoot, run, 'Recovering unattended Build execution.', 'Abandoned leases were released; task evidence will be revalidated before continuing.');
437
460
  return saveRun(paths, run);
@@ -596,20 +619,52 @@ function taskState(run, task, update = {}) {
596
619
  run.tasks[task.id] = { ...(run.tasks[task.id] ?? {}), id: task.id, ...update, updatedAt: now() };
597
620
  }
598
621
 
599
- function executeCommand(command, cwd, outputPath, timeoutMs) {
600
- let exitCode = 0;
601
- let output = '';
622
+ async function invokeControlled(projectRoot, run, task, invocation, invoke = invokeProvider) {
623
+ const paths = pathsFor(projectRoot, run.id), controller = new AbortController();
624
+ const key = controlKey(projectRoot, run.id), controls = runningControls.get(key) ?? new Set();
625
+ controls.add(controller); runningControls.set(key, controls);
626
+ let authorityError = null;
627
+ const guard = () => {
628
+ try {
629
+ if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') controller.abort();
630
+ authorityChecks.get(run)?.({ stage: 'check', mode: invocation.mode ?? 'command' });
631
+ }
632
+ catch (error) { authorityError = error; controller.abort(); }
633
+ };
634
+ let timer;
602
635
  try {
603
- output = execFileSync('/bin/sh', ['-lc', command], {
604
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: timeoutMs,
605
- });
636
+ guard();
637
+ if (authorityError) throw authorityError;
638
+ authorityChecks.get(run)?.({ stage: 'dispatch', mode: invocation.mode ?? 'command' });
639
+ // Persist uncertainty before the launch. A dead conductor must not leave
640
+ // an old true flag that lets resume abandon a still-running worker's lease.
641
+ run.executionStopped = false;
642
+ saveRun(paths, run);
643
+ timer = setInterval(guard, 50);
644
+ const result = await invoke(invocation, { signal: controller.signal, onStart(pid) {
645
+ taskState(run, task, { pid });
646
+ run.activeWorkers = [...(run.activeWorkers ?? []).filter(worker => worker.taskId !== task.id), { taskId: task.id, pid }];
647
+ saveRun(paths, run);
648
+ } });
649
+ guard();
650
+ if (result.executionStopped === true) run.activeWorkers = (run.activeWorkers ?? []).filter(worker => worker.taskId !== task.id);
651
+ else run.unconfirmedExecution = true;
652
+ if (authorityError) throw authorityError;
653
+ if (run.unconfirmedExecution) throw new Error('AFK execution is not confirmed stopped; recovery is required.');
654
+ run.executionStopped = (run.activeWorkers ?? []).length === 0;
655
+ saveRun(paths, run);
656
+ return result;
606
657
  } catch (error) {
607
- exitCode = Number(error.status ?? 1);
608
- output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
609
- }
610
- mkdirSync(dirname(outputPath), { recursive: true });
611
- writeFileSync(outputPath, output, 'utf8');
612
- return { command, exitCode, outputPath };
658
+ if ((run.activeWorkers ?? []).some(worker => worker.taskId === task.id)) run.unconfirmedExecution = true;
659
+ throw error;
660
+ } finally { clearInterval(timer); controls.delete(controller); if (!controls.size) runningControls.delete(key); }
661
+ }
662
+
663
+ async function executeCommand(command, cwd, outputPath, timeoutMs, projectRoot, run, task) {
664
+ const result = await invokeControlled(projectRoot, run, task, { provider: 'command', command: '/bin/sh',
665
+ args: ['-lc', command], cwd, prompt: '', timeoutMs, logPath: outputPath });
666
+ if (result.cancelled || result.timedOut || requireRun(projectRoot, run.id).run.desiredState === 'cancelled') throw new Error('AFK command stopped before completion.');
667
+ return { command, exitCode: result.exitCode, outputPath };
613
668
  }
614
669
 
615
670
  function writeTaskEvidence(projectRoot, run, result, postMerge) {
@@ -695,6 +750,7 @@ No declared stop condition occurred.
695
750
  Automated task and integration evidence passed. Human QA remains governed by the later EWAI delivery gate.
696
751
  `;
697
752
  writeFileSync(reportPath, report, 'utf8');
753
+ return [reportPath, evidencePath, reviewPath, ...commands.map(command => resolve(delivery.deliveryRoot, command.output_path))];
698
754
  }
699
755
 
700
756
  async function implementTask(projectRoot, run, task, provider, dependencies = {}) {
@@ -720,14 +776,15 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
720
776
  });
721
777
  saveRun(paths, run);
722
778
  event(projectRoot, run, `Preparing isolated worktree for ${task.id}.`, task.name);
779
+ authorityChecks.get(run)?.({ stage: 'worktree', mode: 'command' });
723
780
  mkdirSync(dirname(worktree), { recursive: true });
724
781
  git(repository.root, ['worktree', 'add', '-b', actualBranch, worktree, repository.parentBranch]);
725
782
  const baseHead = git(worktree, ['rev-parse', 'HEAD']);
726
- const red = executeCommand(
783
+ const red = await executeCommand(
727
784
  task.red_green_refactor.red_command,
728
785
  worktree,
729
786
  resolve(logRoot, 'red.txt'),
730
- run.timeoutMs,
787
+ run.timeoutMs, projectRoot, run, task,
731
788
  );
732
789
  red.stage = 'red';
733
790
  if (red.exitCode === 0) throw new Error(`${task.id} red command already passes; the task contract is stale and must be reconciled.`);
@@ -742,14 +799,7 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
742
799
  mode: 'implementation',
743
800
  });
744
801
  invocation.logPath = resolve(logRoot, 'implementation.log');
745
- const result = await invoke(invocation, {
746
- onStart(pid) {
747
- taskState(run, task, { pid });
748
- run.activeWorkers = [...(run.activeWorkers ?? []).filter((item) => item.taskId !== task.id), { taskId: task.id, pid }];
749
- saveRun(paths, run);
750
- },
751
- });
752
- run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
802
+ const result = await invokeControlled(projectRoot, run, task, invocation, invoke);
753
803
  if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') {
754
804
  throw new Error('AFK run was cancelled while the task agent was active.');
755
805
  }
@@ -762,19 +812,19 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
762
812
  token: lease.token,
763
813
  durationMs: Math.min(86_400_000, run.timeoutMs + 10 * 60 * 1000),
764
814
  });
765
- const green = executeCommand(
815
+ const green = await executeCommand(
766
816
  task.red_green_refactor.green_command,
767
817
  worktree,
768
818
  resolve(logRoot, 'green.txt'),
769
- run.timeoutMs,
819
+ run.timeoutMs, projectRoot, run, task,
770
820
  );
771
821
  green.stage = 'green';
772
822
  if (green.exitCode !== 0) throw new Error(`${task.id} green command failed after implementation.`);
773
- const refactor = executeCommand(
823
+ const refactor = await executeCommand(
774
824
  task.red_green_refactor.green_command,
775
825
  worktree,
776
826
  resolve(logRoot, 'refactor.txt'),
777
- run.timeoutMs,
827
+ run.timeoutMs, projectRoot, run, task,
778
828
  );
779
829
  refactor.stage = 'refactor';
780
830
  if (refactor.exitCode !== 0) throw new Error(`${task.id} refactor verification failed.`);
@@ -785,7 +835,8 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
785
835
  taskState(run, task, { status: 'checking-scope', changedFiles: changed });
786
836
  saveRun(paths, run);
787
837
  event(projectRoot, run, `Checking implementation scope for ${task.id}.`, `${changed.length} changed path(s) in ${repository.name}.`);
788
- git(worktree, ['add', '-A']);
838
+ authorityChecks.get(run)?.({ stage: 'accept', mode: 'implementation' });
839
+ git(worktree, ['add', '--', ...changed]);
789
840
  git(worktree, ['commit', '-m', `feat(${run.slug}): implement ${task.id}`]);
790
841
  const implementationCommit = git(worktree, ['rev-parse', 'HEAD']);
791
842
 
@@ -798,20 +849,17 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
798
849
  mode: 'review',
799
850
  });
800
851
  reviewInvocation.logPath = resolve(logRoot, 'review.log');
801
- const review = await invoke(reviewInvocation, {
802
- onStart(pid) {
803
- taskState(run, task, { pid, status: 'reviewing' });
804
- run.activeWorkers = [{ taskId: task.id, pid }];
805
- saveRun(paths, run);
806
- },
807
- });
808
- run.activeWorkers = [];
852
+ const review = await invokeControlled(projectRoot, run, task, reviewInvocation, invoke);
809
853
  if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') {
810
854
  throw new Error('AFK run was cancelled during fresh-context review.');
811
855
  }
812
856
  if (review.timedOut || review.exitCode !== 0 || !/^VERDICT:\s*PASS\s*$/im.test(review.output)) {
813
857
  throw new Error(`Fresh-context review did not pass; see ${relative(projectRoot, review.logPath)}.`);
814
858
  }
859
+ authorityChecks.get(run)?.({ stage: 'accept', mode: 'review' });
860
+ if (git(worktree, ['rev-parse', 'HEAD']) !== implementationCommit || gitDirty(worktree)) {
861
+ throw new Error('Fresh-context review changed the inspected implementation.');
862
+ }
815
863
  heartbeatExecutionLease(projectRoot, lease.id, {
816
864
  token: lease.token,
817
865
  durationMs: Math.min(86_400_000, run.timeoutMs + 10 * 60 * 1000),
@@ -830,18 +878,19 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
830
878
  if (control.desiredState === 'cancelled') {
831
879
  return { task, cancelled: true, error };
832
880
  }
833
- if (lease) {
881
+ if (lease && !run.unconfirmedExecution) {
834
882
  try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
835
883
  }
836
884
  taskState(run, task, { status: 'blocked', error: error.message });
837
- run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
885
+ if (!run.unconfirmedExecution) run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
838
886
  saveRun(paths, run);
839
887
  event(projectRoot, run, `${task.id} needs attention.`, error.message, 'blocked', true);
840
888
  return { task, error };
841
889
  }
842
890
  }
843
891
 
844
- function integrateTask(projectRoot, run, result) {
892
+ async function integrateTask(projectRoot, run, result) {
893
+ authorityChecks.get(run)?.({ stage: 'integrate', mode: 'command' });
845
894
  const { task, lease } = result;
846
895
  const repository = result.repository;
847
896
  const specsRepository = run.topology.repositories.find((candidate) => candidate.name === run.topology.specsRepository);
@@ -858,7 +907,24 @@ function integrateTask(projectRoot, run, result) {
858
907
  rmSync(evidenceBackup, { recursive: true, force: true });
859
908
  cpSync(taskEvidenceRoot, evidenceBackup, { recursive: true });
860
909
  }
861
- let codeCommitted = false;
910
+ let codeCommitted = false, integrationConflict = false, mergeStarted = false;
911
+ const baselines = new Map(), ownedEvidence = new Map();
912
+ const assertIntegrationState = (staged = false) => {
913
+ for (const [root, baseline] of baselines) {
914
+ const owned = path => ownedEvidence.has(resolve(root, path))
915
+ && sha256(readFileSync(resolve(root, path))) === ownedEvidence.get(resolve(root, path));
916
+ const stagedDelta = git(root, ['diff', '--cached', '--name-only', '-z', baseline.tree]).split('\0').filter(Boolean);
917
+ const workingDelta = git(root, ['diff', '--name-only', '-z']).split('\0').filter(Boolean);
918
+ const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
919
+ if (git(root, ['rev-parse', 'HEAD']) !== baseline.head || git(root, ['branch', '--show-current']) !== baseline.branch
920
+ || stagedDelta.some(path => !staged || !owned(path) || sha256(execFileSync('git', ['show', `:${path}`],
921
+ { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })) !== ownedEvidence.get(resolve(root, path)))
922
+ || [...workingDelta, ...untracked].some(path => !owned(path))) {
923
+ integrationConflict = true;
924
+ throw new Error('Repository changed outside the owned integration; preserve the checkout for review.');
925
+ }
926
+ }
927
+ };
862
928
  try {
863
929
  heartbeatExecutionLease(projectRoot, lease.id, {
864
930
  token: lease.token,
@@ -868,27 +934,61 @@ function integrateTask(projectRoot, run, result) {
868
934
  if (currentBranch !== repository.parentBranch) {
869
935
  throw new Error(`Repository ${repository.name} moved to ${currentBranch || '<detached>'}; expected ${repository.parentBranch}.`);
870
936
  }
937
+ for (const root of new Set([repository.root, specsRepository.root])) {
938
+ if (gitDirty(root)) { integrationConflict = true; throw new Error('Repository must still be clean before integration.'); }
939
+ baselines.set(root, { head: git(root, ['rev-parse', 'HEAD']), branch: git(root, ['branch', '--show-current']), tree: git(root, ['write-tree']) });
940
+ }
871
941
  git(repository.root, ['merge', '--no-ff', '--no-commit', result.actualBranch]);
942
+ mergeStarted = true;
943
+ baselines.get(repository.root).tree = git(repository.root, ['write-tree']);
872
944
  const postMerge = [];
873
945
  for (const [index, command] of (task.merge?.post_merge_checks ?? []).entries()) {
874
946
  const outputPath = resolve(delivery.deliveryRoot, 'tasks', task.id, 'evidence', `post-merge-${index + 1}.txt`);
875
- const check = executeCommand(command, repository.root, outputPath, run.timeoutMs);
947
+ const check = await executeCommand(command, repository.root, outputPath, run.timeoutMs, projectRoot, run, task);
876
948
  const evidencePath = relative(delivery.deliveryRoot, outputPath).replaceAll('\\', '/');
877
949
  postMerge.push({ command, exit_code: check.exitCode, output_path: evidencePath, output_sha256: sha256(readFileSync(outputPath)) });
950
+ ownedEvidence.set(realpathSync(outputPath), postMerge.at(-1).output_sha256);
878
951
  if (check.exitCode !== 0) throw new Error(`Post-merge check failed: ${command}`);
879
952
  }
880
- writeTaskEvidence(projectRoot, run, result, postMerge);
953
+ authorityChecks.get(run)?.({ stage: 'accept', mode: 'command' });
954
+ assertIntegrationState();
955
+ for (const path of writeTaskEvidence(projectRoot, run, result, postMerge)) ownedEvidence.set(realpathSync(path), sha256(readFileSync(path)));
881
956
  const validated = validateTaskReport(deliveryPaths(projectRoot, run.slug).deliveryRoot, task);
882
957
  if (validated.status !== 'complete') throw new Error(`Merged task evidence is invalid: ${validated.errors.join('; ')}`);
883
- git(repository.root, ['add', '-A']);
958
+ assertIntegrationState();
959
+ const evidencePaths = [...ownedEvidence.keys()].map(path => relative(specsRepository.root, path));
960
+ git(specsRepository.root, ['add', '--', ...evidencePaths]);
961
+ assertIntegrationState(true);
962
+ const expectedTrees = new Map([...baselines.keys()].map(root => [root, git(root, ['write-tree'])]));
963
+ const taskCommit = git(repository.root, ['rev-parse', result.actualBranch]);
884
964
  git(repository.root, ['commit', '-m', `merge(${run.slug}): integrate ${task.id}`]);
885
965
  const codeMergeCommit = git(repository.root, ['rev-parse', 'HEAD']);
886
966
  codeCommitted = true;
887
967
  if (repository.root !== specsRepository.root) {
888
- git(specsRepository.root, ['add', '-A']);
968
+ assertIntegrationStateForEvidence();
889
969
  git(specsRepository.root, ['commit', '-m', `docs(${run.slug}): record ${task.id} delivery evidence`]);
890
970
  }
971
+ function assertIntegrationStateForEvidence() {
972
+ const baseline = baselines.get(specsRepository.root);
973
+ if (git(specsRepository.root, ['rev-parse', 'HEAD']) !== baseline.head
974
+ || git(specsRepository.root, ['branch', '--show-current']) !== baseline.branch
975
+ || git(specsRepository.root, ['write-tree']) !== expectedTrees.get(specsRepository.root)
976
+ || git(specsRepository.root, ['diff', '--name-only']) || git(specsRepository.root, ['ls-files', '--others', '--exclude-standard'])) {
977
+ throw new Error('Evidence repository changed during owned integration; preserve it for review.');
978
+ }
979
+ }
891
980
  const evidenceCommit = git(specsRepository.root, ['rev-parse', 'HEAD']);
981
+ const transitions = [...baselines].map(([root, baseline]) => ({ root, before: baseline.head, branch: baseline.branch,
982
+ after: root === repository.root ? codeMergeCommit : evidenceCommit }));
983
+ for (const transition of transitions) {
984
+ if (git(transition.root, ['rev-parse', 'HEAD']) !== transition.after || git(transition.root, ['rev-parse', 'HEAD^1']) !== transition.before
985
+ || git(transition.root, ['rev-parse', 'HEAD^{tree}']) !== expectedTrees.get(transition.root)
986
+ || git(transition.root, ['branch', '--show-current']) !== transition.branch || gitDirty(transition.root)
987
+ || transition.root === repository.root && git(transition.root, ['rev-parse', 'HEAD^2']) !== taskCommit) {
988
+ throw new Error('Owned integration commit changed unexpectedly; preserve the checkout for review.');
989
+ }
990
+ }
991
+ authorityChecks.get(run)?.({ stage: 'integrated', mode: 'command', transitions });
892
992
  releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'completed' });
893
993
  taskState(run, task, {
894
994
  status: 'completed', mergeCommit: codeMergeCommit, evidenceCommit,
@@ -899,12 +999,13 @@ function integrateTask(projectRoot, run, result) {
899
999
  rmSync(evidenceBackup, { recursive: true, force: true });
900
1000
  try { git(repository.root, ['worktree', 'remove', '--force', result.worktree]); } catch { /* retain for diagnosis */ }
901
1001
  } catch (error) {
902
- if (!codeCommitted) {
1002
+ try { if (!codeCommitted) assertIntegrationState(true); } catch { integrationConflict = true; }
1003
+ if (mergeStarted && !codeCommitted && !run.unconfirmedExecution && !integrationConflict) {
903
1004
  try { git(repository.root, ['merge', '--abort']); } catch { /* no merge to abort */ }
904
1005
  rmSync(taskEvidenceRoot, { recursive: true, force: true });
905
1006
  if (hadTaskEvidence) cpSync(evidenceBackup, taskEvidenceRoot, { recursive: true });
906
1007
  }
907
- try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
1008
+ if (!run.unconfirmedExecution) try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
908
1009
  taskState(run, task, { status: 'blocked', error: error.message });
909
1010
  saveRun(paths, run);
910
1011
  event(projectRoot, run, `${task.id} integration blocked.`, error.message, 'blocked', true);
@@ -914,16 +1015,24 @@ function integrateTask(projectRoot, run, result) {
914
1015
 
915
1016
  export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
916
1017
  const { paths, run } = requireRun(projectRoot, runId);
1018
+ if (run.desiredState !== 'running' && run.executionStopped === true && !(run.activeWorkers ?? []).length) {
1019
+ run.status = run.desiredState === 'paused' ? 'paused' : 'cancelled'; run.pid = null;
1020
+ if (run.desiredState === 'cancelled') run.cancellation = { ...run.cancellation, status: 'confirmed', settledAt: now() };
1021
+ return saveRun(paths, run);
1022
+ }
917
1023
  run.pid = process.pid;
918
1024
  run.status = 'running';
919
1025
  saveRun(paths, run);
1026
+ if (dependencies.authority) authorityChecks.set(run, dependencies.authority);
920
1027
  try {
1028
+ if (run.autonomy && !authorityChecks.has(run)) throw new Error('Autonomous AFK run requires its original live authority context.');
921
1029
  while (run.desiredState === 'running') {
922
1030
  const graph = readGraph(paths.projectRoot, run.slug);
923
1031
  const check = validateTaskGraph(deliveryPaths(paths.projectRoot, run.slug).deliveryRoot, { slug: run.slug });
924
1032
  if (check.status !== 'pass') throw new Error(`Task graph became invalid: ${check.errors.map((item) => item.message).join('; ')}`);
925
1033
  if (check.completedTasks.length === graph.tasks.length) {
926
1034
  run.status = 'completed';
1035
+ run.executionStopped = !run.unconfirmedExecution;
927
1036
  run.pid = null;
928
1037
  event(paths.projectRoot, run, 'All AFK Build tasks are integrated.', 'EWAI Build remains open for its canonical phase gate and subsequent lifecycle stages.');
929
1038
  return saveRun(paths, run);
@@ -939,9 +1048,14 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
939
1048
  const control = requireRun(paths.projectRoot, run.id).run;
940
1049
  run.desiredState = control.desiredState;
941
1050
  if (run.desiredState === 'cancelled') {
942
- run.status = 'cancelled';
1051
+ run.status = run.unconfirmedExecution ? 'cancel-unknown' : 'cancelled';
1052
+ run.cancellation = { ...control.cancellation, status: run.unconfirmedExecution ? 'unknown' : 'confirmed', settledAt: now() };
1053
+ run.executionStopped = !run.unconfirmedExecution;
943
1054
  run.pid = null;
944
- run.activeWorkers = [];
1055
+ if (!run.unconfirmedExecution) {
1056
+ abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-cancelled' });
1057
+ run.activeWorkers = [];
1058
+ }
945
1059
  return saveRun(paths, run);
946
1060
  }
947
1061
  const failed = results.find((result) => result.error);
@@ -949,12 +1063,13 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
949
1063
  const order = waveDefinition?.merge_order ?? wave.map((task) => task.id);
950
1064
  for (const id of order) {
951
1065
  const result = results.find((candidate) => candidate.task.id === id);
952
- if (result) integrateTask(paths.projectRoot, run, result);
1066
+ if (result) await integrateTask(paths.projectRoot, run, result);
953
1067
  }
954
1068
  const refreshed = requireRun(paths.projectRoot, run.id).run;
955
1069
  run.desiredState = refreshed.desiredState;
956
1070
  if (run.desiredState === 'paused') {
957
1071
  run.status = 'paused';
1072
+ run.executionStopped = !run.unconfirmedExecution;
958
1073
  run.pid = null;
959
1074
  event(paths.projectRoot, run, 'Unattended Build safely paused.', 'The active wave finished and no new task was leased.');
960
1075
  return saveRun(paths, run);
@@ -962,12 +1077,17 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
962
1077
  }
963
1078
  return saveRun(paths, run);
964
1079
  } catch (error) {
965
- run.status = 'blocked';
1080
+ const control = requireRun(paths.projectRoot, run.id).run;
1081
+ run.desiredState = control.desiredState;
1082
+ run.status = control.desiredState === 'cancelled' ? run.unconfirmedExecution ? 'cancel-unknown' : 'cancelled' : 'blocked';
1083
+ if (control.desiredState === 'cancelled') run.cancellation = { ...control.cancellation,
1084
+ status: run.unconfirmedExecution ? 'unknown' : 'confirmed', settledAt: now() };
966
1085
  run.pid = null;
967
1086
  run.messages.push({ at: now(), message: error.message });
968
- abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-blocked' });
1087
+ run.executionStopped = !run.unconfirmedExecution;
1088
+ if (!run.unconfirmedExecution) abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-blocked' });
969
1089
  event(paths.projectRoot, run, 'Unattended Build is blocked.', error.message, 'blocked', true);
970
1090
  saveRun(paths, run);
971
1091
  return run;
972
- }
1092
+ } finally { authorityChecks.delete(run); }
973
1093
  }