@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,18 @@
1
+ ---
2
+ name: ewai-autonomy
3
+ description: Operate governed, bounded autonomous delivery for an exact pool of existing EWAI intents. Use for autonomy status, previews, named grants, local runs and services, human questions, recovery or revocation; keep phase delivery and approvals separate.
4
+ ---
5
+
6
+ # EWAI autonomy
7
+
8
+ Autonomy is **off by default** in a consumer project. Resolve the project and configured SPECS root from `.ewai-pipeline/project.json`; the npm engine repository is not the consumer's authority source. Read the project's current intents, dependencies, standards, delivery state, policy and `ewai autonomy status --project . --json` before proposing work. Do not choose new intent IDs on the owner's behalf or infer a grant from a request to continue.
9
+
10
+ Use the dashboard's Autonomy view or `ewai autonomy preview` to explain an **exact intent pool**, permitted actions and providers, expiry, runtime, per-operation time, attempt limit, executable candidates, human decisions and blockers. A preview is **not permission**. Recording it only makes that displayed revision approvable. A named owner must explicitly approve its current digest using the guarded dashboard or `ewai autonomy approve --expected-digest DIGEST --approved-by NAME --yes`. A changed pool, provider, limit or evidence requires a new preview and decision. Never issue a live grant merely because the host AI recommends one.
11
+
12
+ `ewai autonomy run` makes one bounded pass; `ewai autonomy service` starts a local continuing supervisor. Both require the exact approved grant digest and selected provider. Verify provider availability/version and the project's external-review policy; the orchestrator cannot independently review itself. Treat unknown execution, a stale digest, lost ownership, expired scope or an unavailable provider as a stop, not a retry invitation. Usage is provider-reported, not a hard money cap. Set bounded limits and direct the owner to provider-side spend controls.
13
+
14
+ Monitor status and questions. A worker stages source-bound drafts and host-produced checks, never gate authority. Use `$ewai-deliver` for the complete fourteen-stage phase workflow, canonical gate ledger, standards sweep, validation cycles and leased Build tasks. Existing human Build approval and Manual QA approval remain separate explicit decisions. Do not turn a run question, private answer, worker draft or passing test into either approval. Answer through the dashboard or a private project-relative input file, not chat text containing credentials or private evidence.
15
+
16
+ Pause, cancel, revoke and recover through guarded revision-bound controls. A cancel request does not confirm termination; inspect `executionStopped`, owner/process state and durable operation receipts. Recover only after inspecting whether the canonical effect already happened; never blindly replay it. Revocation removes future delegation, not historical evidence or an in-flight termination guarantee. Report blocked states honestly. Do not update premium personas, publish, release, deploy or accept on the owner's behalf.
17
+
18
+ The dashboard and CLI share the same project-local control plane. The npm package also includes `Docs/autonomous-intent-delivery.md` for a longer user guide; do not assume a relative path from this copied skill to that guide. The instructions above remain usable when only the skill subtree is installed.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "EWAI Autonomy"
3
+ short_description: "Govern exact-pool autonomous intent delivery"
4
+ default_prompt: "Use $ewai-autonomy to review this project's autonomous delivery status and bounded next action."
@@ -7,6 +7,8 @@ description: Deliver or resume an EWAI intent through the complete fourteen-stag
7
7
 
8
8
  This skill is the only supported route from an EWAI intent into planning, Build, delivery, and learning. A host AI's generic planning mode is not an EWAI Plan and cannot replace a pipeline phase.
9
9
 
10
+ When an owner asks for bounded delegation across an exact pool of existing intents, use `$ewai-autonomy` for the off-by-default preview, named grant, run controls and human questions. Keep `$ewai-deliver` as the phase-work route. An autonomy grant cannot replace existing human Build approval, the canonical gate ledger, standards sweep, or separate Manual QA approval.
11
+
10
12
  Resolve the workspace and SPECS root from `.ewai-pipeline/project.json`; all logical `SPECS/...` paths in this contract are relative to that configured root. Never assume the workspace itself or the repository being changed owns SPECS.
11
13
 
12
14
  ## Delivery contract
@@ -83,6 +83,12 @@ Status means:
83
83
 
84
84
  JSON is authoritative. Never parse or manually edit Markdown to repair scenario state.
85
85
 
86
+ New briefs and records carry `sourceDigestVersion: intent-content-v2`. Intent fingerprints cover the complete body and all parsed metadata except the five workflow fields `status`, `delivery_status`, `current_phase`, `delivery_state_path` and `updated_at`. Do not treat excerpts as the fingerprint boundary. Other evidence retains whole-file fingerprints, so genuine source changes still require review. Unsupported digest versions fail closed.
87
+
88
+ Require at least one recognised intent source (captured Problem, Desired outcome or Constraints, or a listed Journey or Acceptance criterion), even when planning documents exist. Without it, preparation and recording fail closed and historical packs cannot claim current intent coverage. Content fingerprints accept only plain mappings, arrays and JSON scalar metadata; reject typed YAML dates, sets, ordered maps, binary values, non-finite numbers and cyclic aliases instead of silently erasing their meaning. Ordinary quoted date strings and repeated non-cyclic aliases remain supported. Do not convert unsupported sources or rewrite accepted evidence without the appropriate owner review.
89
+
90
+ Legacy unversioned pairs remain readable with their original whole-file checks. An exact replay through `record`, while those sources are still current, may add the immutable `test-scenarios.source-baseline.json` compatibility receipt. It is bound to the original record digest and reviewed sources; both accepted evidence files remain byte-identical. Preparation and status remain read-only. Never bypass an already-stale legacy record, replace its pair, or edit the receipt manually. Keep the receipt alongside its pair; malformed, unsafe or mismatched receipt evidence is invalid. This is a fingerprint compatibility operation, not a revised-scenario approval or a way to accept changed requirements.
91
+
86
92
  ## Human evidence boundary
87
93
 
88
94
  Manual QA remains an explicit named human gate. Specialist assurance requires an accountable practitioner. Representative-user validation requires real representative participants. Persona simulation may improve the question set but may not complete any of those routes.
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ // This registry describes draft content, not gate evidence. A worker never
5
+ // chooses a checker, command, canonical approval path or destination root.
6
+ const definitions = {
7
+ intent: { paths: ['intent-summary.md'], checks: ['intent-dependency-map-check'],
8
+ humanQuestions: ['intent-evidence-review'] },
9
+ reconcile: { paths: ['reconcile.md'], checks: ['intent-dependency-map-check'],
10
+ humanQuestions: ['repository-truth-review', 'reconcile-disagreements'] },
11
+ plan: { paths: ['build-plan.md', 'destination.md', 'gates/plan/discovery-notes.md'],
12
+ checks: ['intent-dependency-map-check', 'task-graph-check'], humanQuestions: ['plan-contract-review'] },
13
+ 'pattern-validation': { paths: ['pattern-validation.md'],
14
+ checks: ['intent-dependency-map-check', 'task-graph-check'], humanQuestions: ['repository-pattern-review'] },
15
+ 'test-plan': { paths: ['test-plan.md', 'gates/test-plan/task-to-test-traceability.md', 'gates/test-plan/red-green-refactor-traceability.md'],
16
+ checks: ['task-graph-check'], humanQuestions: ['test-oracle-review'] },
17
+ };
18
+ const maxBytes = 128 * 1024;
19
+ const maxArtifactBytes = 32 * 1024;
20
+ const digestPattern = /^sha256:[a-f0-9]{64}$/;
21
+ const ownKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
22
+ && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
23
+ const safeFailure = code => ({ schema: 'ewai.autonomy-proposal-validation/v1', status: 'rejected', code, requiresHuman: true });
24
+ const immutable = value => {
25
+ if (value && typeof value === 'object') { Object.values(value).forEach(immutable); Object.freeze(value); }
26
+ return value;
27
+ };
28
+ export const autonomyWorkerSchema = immutable(JSON.parse(readFileSync(new URL('../config/autonomy-worker.schema.json', import.meta.url), 'utf8')));
29
+
30
+ export function getAutonomyPhaseContract(phase) {
31
+ if (typeof phase !== 'string' || !Object.hasOwn(definitions, phase)) {
32
+ return { status: 'unavailable', code: 'phase-contract-unavailable', requiresHuman: true };
33
+ }
34
+ return { schema: 'ewai.autonomy-phase-definition/v1', status: 'available', phase,
35
+ ...structuredClone(definitions[phase]), maxBytes, maxArtifactBytes, maxArtifacts: 8,
36
+ authority: 'draft-only' };
37
+ }
38
+
39
+ // Validate the wire bytes, not a provider-declared status or success summary.
40
+ // Parsing errors deliberately do not quote input. Validated content remains
41
+ // private to the host; the returned safe result contains references only.
42
+ export function validatePhaseProposal(contract, result) {
43
+ const definition = getAutonomyPhaseContract(contract?.phase);
44
+ if (definition.status !== 'available' || !digestPattern.test(contract?.digest ?? '')
45
+ || !Array.isArray(contract?.sources) || !contract.sources.length
46
+ || contract.sources.some(source => !/^[a-z][a-z0-9_.-]{0,79}$/.test(source?.id ?? '')
47
+ || !digestPattern.test(source?.digest ?? ''))
48
+ || new Set(contract.sources.map(source => source.id)).size !== contract.sources.length) return safeFailure('proposal-contract-invalid');
49
+ if (!result || result.status !== 'complete' || result.exitCode !== 0 || result.executionStopped !== true) {
50
+ return safeFailure(['truncated', 'timed-out', 'unavailable', 'cancelled', 'failed'].includes(result?.status)
51
+ ? `proposal-${result.status}` : 'proposal-incomplete');
52
+ }
53
+ if (typeof result.output !== 'string') return safeFailure('proposal-malformed');
54
+ if (Buffer.byteLength(result.output, 'utf8') > maxBytes) return safeFailure('proposal-too-large');
55
+ let proposal;
56
+ try { proposal = JSON.parse(result.output); } catch { return safeFailure('proposal-malformed'); }
57
+ if (!ownKeys(proposal, ['schema', 'contractDigest', 'phase', 'artifacts'])
58
+ || proposal.schema !== 'ewai.autonomy-phase-proposal/v1'
59
+ || proposal.contractDigest !== contract.digest || proposal.phase !== contract.phase
60
+ || !Array.isArray(proposal.artifacts) || proposal.artifacts.length < 1 || proposal.artifacts.length > 8) return safeFailure('proposal-schema-invalid');
61
+ const sources = new Set(contract.sources.map(source => source.id)), seen = new Set(), artifacts = [];
62
+ for (const artifact of proposal.artifacts) {
63
+ if (!ownKeys(artifact, ['path', 'content', 'sourceRefs'])) return safeFailure('proposal-schema-invalid');
64
+ if (!definition.paths.includes(artifact.path) || seen.has(artifact.path)) return safeFailure('proposal-destination-invalid');
65
+ seen.add(artifact.path);
66
+ if (typeof artifact.content !== 'string' || !artifact.content.trim()
67
+ || Buffer.byteLength(artifact.content, 'utf8') > maxArtifactBytes
68
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(artifact.content)) return safeFailure('proposal-content-invalid');
69
+ if (!Array.isArray(artifact.sourceRefs) || artifact.sourceRefs.length < 1 || artifact.sourceRefs.length > 32
70
+ || new Set(artifact.sourceRefs).size !== artifact.sourceRefs.length
71
+ || artifact.sourceRefs.some(ref => typeof ref !== 'string' || !sources.has(ref))) return safeFailure('proposal-source-invalid');
72
+ // This is defence in depth for obvious forged verdicts, not a semantic
73
+ // truth detector. Nothing in a draft is ever consumed as gate authority.
74
+ if (artifact.content.split('\n').some(line => {
75
+ const words = line.trim().toLowerCase().split(/[\s:=|*#-]+/).filter(Boolean);
76
+ if (words[0] === 'manual' && words[1] === 'qa') words.splice(0, 2, 'manual-qa');
77
+ if (!['gate', 'checker', 'verdict', 'approval', 'manual-qa'].includes(words[0])) return false;
78
+ return ['pass', 'passed', 'approved', 'complete', 'completed'].includes(words.slice(1).find(word => !['is', 'status'].includes(word)));
79
+ })
80
+ || /\b(?:approve-build|approve-manual-qa|record-phase-gate|recordPhaseGate|recordBuildApproval)\b/.test(artifact.content)) return safeFailure('proposal-authority-forbidden');
81
+ if (/```\s*(?:bash|sh|zsh|shell|powershell|cmd)\b|\$\(/.test(artifact.content)) return safeFailure('proposal-command-forbidden');
82
+ artifacts.push({ path: artifact.path, bytes: Buffer.byteLength(artifact.content, 'utf8'),
83
+ digest: `sha256:${createHash('sha256').update(artifact.content).digest('hex')}`, sourceRefs: [...artifact.sourceRefs] });
84
+ }
85
+ return { schema: 'ewai.autonomy-proposal-validation/v1', status: 'valid-draft', code: 'proposal-valid-draft',
86
+ phase: contract.phase, contractDigest: contract.digest, artifacts,
87
+ requiresHuman: true, authority: 'none', humanQuestions: definition.humanQuestions };
88
+ }
@@ -0,0 +1,15 @@
1
+ import { executeAutonomyRun, controlAutonomyRun, readAutonomyRun } from './runtime/autonomy-supervisor.mjs';
2
+
3
+ // Only explicit service creation starts this entry point, via an IPC handshake.
4
+ const args = process.argv.slice(2);
5
+ if (args.length !== 4 || args[0] !== '--project' || args[2] !== '--run' || !process.send) throw new Error('Explicit local service launch required.');
6
+ const root = args[1], id = args[3];
7
+ const stop = () => {
8
+ try { const run = readAutonomyRun(root, id); controlAutonomyRun(root, { runId: id, action: 'cancel', expectedRevision: run.revision }); } catch {}
9
+ };
10
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
11
+ process.once('message', async message => {
12
+ if (message?.start !== true || Object.keys(message).length !== 1) { process.exitCode = 1; return; }
13
+ try { await executeAutonomyRun(root, id); } catch { process.exitCode = 1; }
14
+ finally { process.removeListener('SIGTERM', stop); process.removeListener('SIGINT', stop); }
15
+ });
@@ -0,0 +1,199 @@
1
+ import { resolve } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { getAutonomyPhaseContract } from './autonomy-phase-contracts.mjs';
4
+ import { autonomyError, autonomyGuard, autonomyDigest, autonomyPaths, autonomyFiles, readAutonomyFile,
5
+ writeAutonomyRecord, withAutonomyLock, readAutonomySnapshot } from './runtime/autonomy-workspace.mjs';
6
+
7
+ export const AUTONOMY_ACTIONS = Object.freeze(['begin-harness', 'prepare-phase', 'afk-build']);
8
+ export const AUTONOMY_PROVIDERS = Object.freeze(['codex', 'claude', 'antigravity']);
9
+ export const AUTONOMY_HUMAN_EXCLUSIONS = Object.freeze(['approve-build', 'select-prototype', 'manual-qa', 'accept-risk', 'policy-exception', 'destructive-action', 'deploy', 'release']);
10
+ const intentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ const digestPattern = /^sha256:[a-f0-9]{64}$/;
12
+ function keys(value, allowed) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype
14
+ || Object.keys(value).some(key => !allowed.includes(key))) autonomyError('autonomy-invalid-input', 400);
15
+ }
16
+ function boundedList(value, valid, maximum = 100) {
17
+ if (!Array.isArray(value) || !value.length || value.length > maximum || new Set(value).size !== value.length
18
+ || value.some(item => typeof item !== 'string' || item.length > 160 || !valid(item))) autonomyError('autonomy-invalid-scope', 400);
19
+ return [...value].sort();
20
+ }
21
+ export function validateAutonomyScope(input) {
22
+ keys(input, ['intentIds', 'actions', 'providers', 'expiresAt', 'limits']);
23
+ keys(input.limits, ['maxConcurrentIntents', 'maxRuntimeMs', 'maxOperationMs', 'maxAttempts']);
24
+ const limits = input.limits;
25
+ if (limits.maxConcurrentIntents !== 1 || !Number.isSafeInteger(limits.maxRuntimeMs) || limits.maxRuntimeMs < 1 || limits.maxRuntimeMs > 86400000
26
+ || !Number.isSafeInteger(limits.maxOperationMs) || limits.maxOperationMs < 1 || limits.maxOperationMs > limits.maxRuntimeMs
27
+ || !Number.isSafeInteger(limits.maxAttempts) || limits.maxAttempts < 1 || limits.maxAttempts > 100) autonomyError('autonomy-invalid-limits', 400);
28
+ if (typeof input.expiresAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(input.expiresAt)
29
+ || !Number.isFinite(Date.parse(input.expiresAt)) || new Date(input.expiresAt).toISOString() !== input.expiresAt) autonomyError('autonomy-invalid-expiry', 400);
30
+ return { intentIds: boundedList(input.intentIds, id => intentPattern.test(id)),
31
+ actions: boundedList(input.actions, action => AUTONOMY_ACTIONS.includes(action), AUTONOMY_ACTIONS.length),
32
+ providers: boundedList(input.providers, provider => AUTONOMY_PROVIDERS.includes(provider), AUTONOMY_PROVIDERS.length),
33
+ expiresAt: input.expiresAt, limits: { ...limits } };
34
+ }
35
+ function readPolicy(root) {
36
+ const paths = autonomyPaths(root);
37
+ const files = autonomyFiles(paths.projectRoot, resolve(paths.authorityRoot, 'grants'));
38
+ const records = files.filter(path => path.includes('/revisions/')).map(path => {
39
+ const record = JSON.parse(readAutonomyFile(paths.projectRoot, path));
40
+ keys(record, ['schema', 'id', 'revision', 'previousDigest', 'digest', 'proposalDigest', 'approvedBy', 'approvedAt', 'scope', 'humanExclusions', 'configDigest', 'projectIdentity']);
41
+ const { digest, ...body } = record;
42
+ validateAutonomyScope(record.scope);
43
+ if (record.schema !== 'ewai.autonomy-grant/v1' || !Number.isSafeInteger(record.revision) || record.revision < 1
44
+ || !/^[a-f0-9-]{36}$/.test(record.id) || !digestPattern.test(record.proposalDigest) || !digestPattern.test(record.configDigest)
45
+ || digest !== autonomyDigest(body) || !path.endsWith(`/grants/${record.id}/revisions/${record.revision}-${digest.slice(7)}.json`)
46
+ || record.projectIdentity !== autonomyDigest(paths.projectRoot) || !validApprover(record.approvedBy)
47
+ || !Number.isFinite(Date.parse(record.approvedAt)) || JSON.stringify(record.humanExclusions) !== JSON.stringify(AUTONOMY_HUMAN_EXCLUSIONS)) autonomyError('autonomy-grant-invalid', 422);
48
+ return record;
49
+ }).sort((a, b) => a.revision - b.revision);
50
+ records.forEach((record, index) => {
51
+ if (record.revision !== index + 1 || record.previousDigest !== (records[index - 1]?.digest ?? null)
52
+ || index > 0 && record.id !== records[0].id) autonomyError('autonomy-grant-history-invalid', 422);
53
+ });
54
+ const grant = records.at(-1) ?? null;
55
+ const revokedRevisions = new Set();
56
+ const revocations = files.filter(path => path.includes('/revocations/')).map(path => {
57
+ const record = JSON.parse(readAutonomyFile(paths.projectRoot, path));
58
+ keys(record, ['schema', 'id', 'grantRevision', 'grantDigest', 'revokedBy', 'revokedAt', 'projectIdentity', 'digest']);
59
+ const { digest, ...body } = record;
60
+ const source = records.find(candidate => candidate.revision === record.grantRevision);
61
+ if (record.schema !== 'ewai.autonomy-revocation/v1' || !source || record.id !== source.id
62
+ || record.grantDigest !== source.digest || record.projectIdentity !== source.projectIdentity
63
+ || !validApprover(record.revokedBy) || !Number.isFinite(Date.parse(record.revokedAt))
64
+ || Date.parse(record.revokedAt) < Date.parse(source.approvedAt)
65
+ || digest !== autonomyDigest(body) || revokedRevisions.has(record.grantRevision)
66
+ || !path.endsWith(`/grants/${record.id}/revocations/${record.grantRevision}-${digest.slice(7)}.json`)) autonomyError('autonomy-revocation-invalid', 422);
67
+ revokedRevisions.add(record.grantRevision); return record;
68
+ });
69
+ const revocation = revocations.find(record => record.grantDigest === grant?.digest) ?? null;
70
+ const status = !grant ? 'absent' : revocation ? 'revoked' : Date.parse(grant.scope.expiresAt) <= Date.now() ? 'expired' : 'current';
71
+ return { schema: 'ewai.autonomy-policy/v1', mode: status === 'current' ? 'delegated' : 'off', status, grant,
72
+ revocation, digest: revocation ? autonomyDigest({ grant: grant.digest, revocation: revocation.digest })
73
+ : grant?.digest ?? autonomyDigest({ mode: 'off', project: paths.projectRoot }),
74
+ humanExclusions: [...AUTONOMY_HUMAN_EXCLUSIONS], serviceStarted: false };
75
+ }
76
+ export function readAutonomyPolicy(root) { return autonomyGuard(() => readPolicy(root)); }
77
+
78
+ function classify(item, scope, policy, snapshot, isProposal) {
79
+ const reasons = [], add = code => reasons.push({ code });
80
+ const state = item.execution, actions = state.actions;
81
+ let phase = state.lifecycle.nextPhase, action = null, human = false;
82
+ if (!scope?.intentIds.includes(item.intentId)) add('outside-approved-pool');
83
+ if (!item.accepted) add('intent-not-accepted');
84
+ if (!state.valid) state.blockers.forEach(blocker => add(/^[a-z0-9-]{1,100}$/.test(blocker.code) ? blocker.code : 'canonical-evidence-invalid'));
85
+ if (!scope) add('autonomy-off');
86
+ else if (Date.parse(scope.expiresAt) <= Date.now()) add('grant-expired');
87
+ if (!isProposal && policy.status === 'revoked') add('grant-revoked');
88
+ if (!isProposal && policy.grant && policy.grant.configDigest !== snapshot.configDigest) add('grant-policy-changed');
89
+ if (phase === 'manual-qa') { add('manual-qa-required'); human = true; }
90
+ else if (phase === 'ui-design') { add('prototype-selection-required'); human = true; }
91
+ else if (actions.approveBuild.permitted) { add('build-approval-required'); human = true; }
92
+ else if (actions.beginHarness.permitted && state.valid) { action = 'begin-harness'; phase = 'intent'; }
93
+ else if (phase === 'build') {
94
+ action = 'afk-build';
95
+ for (const blocker of actions.enterBuild.blockers ?? []) if (/^intent-dependency-/.test(blocker.code)) add(blocker.code);
96
+ if (!actions.acquireTask.permitted) add('afk-preflight-required');
97
+ if (!actions.enterBuild.permitted) add('build-approval-required');
98
+ } else if (actions.continueHarness.permitted && ['intent', 'reconcile', 'plan', 'pattern-validation', 'test-plan'].includes(phase)) {
99
+ action = 'prepare-phase';
100
+ if (getAutonomyPhaseContract(phase).status !== 'available') add('restricted-phase-contract-unavailable');
101
+ } else add('autonomy-phase-unavailable');
102
+ if (action && !scope?.actions.includes(action)) add('action-not-approved');
103
+ if (item.repositoryBusy) add('repository-ownership-conflict');
104
+ return { intentId: item.intentId, phase, action, sortKey: [item.priority, item.intentId],
105
+ reasons: [...new Map(reasons.map(reason => [reason.code, reason])).values()],
106
+ bucket: human ? 'humanDecisions' : reasons.length ? 'blocked' : 'executable' };
107
+ }
108
+ function latestProposalPreview(root) {
109
+ const paths = autonomyPaths(root);
110
+ const records = autonomyFiles(paths.projectRoot, resolve(paths.controlRoot, 'previews'))
111
+ .filter(path => /\/\d+-[a-f0-9]{64}\.json$/.test(path))
112
+ .map(path => {
113
+ const value = JSON.parse(readAutonomyFile(paths.projectRoot, path));
114
+ if (!Number.isSafeInteger(value.previewRevision) || value.previewRevision < 1 || !digestPattern.test(value.digest)
115
+ || !path.endsWith(`/${value.previewRevision}-${value.digest.slice(7)}.json`)) autonomyError('autonomy-preview-invalid');
116
+ return value;
117
+ }).sort((a, b) => a.previewRevision - b.previewRevision);
118
+ if (records.some((record, index) => record.previewRevision !== index + 1)) autonomyError('autonomy-preview-history-invalid');
119
+ return records.at(-1) ?? null;
120
+ }
121
+ function createPreview(root, input, recordedRevision = null) {
122
+ keys(input, ['proposal', 'record']);
123
+ if (input.record !== undefined && typeof input.record !== 'boolean') autonomyError('autonomy-invalid-input', 400);
124
+ const snapshot = readAutonomySnapshot(root), policy = readPolicy(root);
125
+ const proposal = input.proposal === undefined ? null : validateAutonomyScope(input.proposal);
126
+ if (proposal && Date.parse(proposal.expiresAt) <= Date.now()) autonomyError('autonomy-expired-proposal', 400);
127
+ if (proposal && proposal.intentIds.some(id => !snapshot.items.some(item => item.intentId === id))) autonomyError('autonomy-intent-unknown', 400);
128
+ const scope = proposal ?? policy.grant?.scope ?? null;
129
+ const basis = { schema: 'ewai.autonomy-preview/v1', evidenceRevision: snapshot.revision, policyDigest: policy.digest,
130
+ configDigest: snapshot.configDigest, projectIdentity: snapshot.projectIdentity, proposal,
131
+ previewRevision: proposal ? recordedRevision ?? (latestProposalPreview(root)?.previewRevision ?? 0) + 1 : null };
132
+ const result = { ...basis, digest: autonomyDigest(basis), authority: 'none', mode: 'shadow',
133
+ executable: [], humanDecisions: [], blocked: [], humanExclusions: [...AUTONOMY_HUMAN_EXCLUSIONS],
134
+ ordering: 'Ascending explicit numeric priority; missing or non-numeric priority is neutral zero; then intent ID.' };
135
+ for (const item of snapshot.items) {
136
+ const { bucket, ...candidate } = classify(item, scope, policy, snapshot, Boolean(proposal)); result[bucket].push(candidate);
137
+ }
138
+ for (const bucket of ['executable', 'humanDecisions', 'blocked']) result[bucket].sort((a, b) => a.sortKey[0] - b.sortKey[0] || (a.intentId < b.intentId ? -1 : a.intentId > b.intentId ? 1 : 0));
139
+ return result;
140
+ }
141
+ export function previewAutonomy(root, input = {}) {
142
+ return autonomyGuard(() => {
143
+ if (!input.record) return createPreview(root, input);
144
+ const paths = autonomyPaths(root);
145
+ return withAutonomyLock(paths, () => {
146
+ const result = createPreview(root, input);
147
+ writeAutonomyRecord(paths.projectRoot, resolve(paths.controlRoot, 'previews', `${result.proposal ? result.previewRevision + '-' : ''}${result.digest.slice(7)}.json`), result);
148
+ return result;
149
+ });
150
+ });
151
+ }
152
+ function validApprover(value) { return typeof value === 'string' && value === value.trim() && value.length > 0 && value.length <= 120 && !/[\x00-\x1f\x7f]/.test(value); }
153
+ export function approveAutonomyGrant(root, input) {
154
+ return autonomyGuard(() => {
155
+ keys(input, ['expectedDigest', 'approvedBy', 'confirmed']);
156
+ if (input.confirmed !== true || !validApprover(input.approvedBy) || !digestPattern.test(input.expectedDigest)) autonomyError('autonomy-approval-required', 400);
157
+ const paths = autonomyPaths(root);
158
+ return withAutonomyLock(paths, () => {
159
+ const displayed = latestProposalPreview(root);
160
+ if (!displayed) autonomyError('autonomy-preview-required');
161
+ if (displayed.digest !== input.expectedDigest) autonomyError('autonomy-preview-stale');
162
+ const current = createPreview(root, { proposal: displayed.proposal }, displayed.previewRevision);
163
+ if (current.digest !== input.expectedDigest || autonomyDigest(current) !== autonomyDigest(displayed)) autonomyError('autonomy-preview-stale');
164
+ if ([...current.blocked, ...current.humanDecisions].some(item => current.proposal.intentIds.includes(item.intentId)
165
+ && item.reasons.some(reason => ['intent-not-accepted', 'intent-state-drift'].includes(reason.code)))) autonomyError('autonomy-intent-not-accepted');
166
+ const previous = readPolicy(root).grant;
167
+ const body = { schema: 'ewai.autonomy-grant/v1', id: previous?.id ?? randomUUID(), revision: (previous?.revision ?? 0) + 1,
168
+ previousDigest: previous?.digest ?? null, proposalDigest: current.digest, approvedBy: input.approvedBy, approvedAt: new Date().toISOString(),
169
+ scope: current.proposal, humanExclusions: [...AUTONOMY_HUMAN_EXCLUSIONS], configDigest: current.configDigest, projectIdentity: current.projectIdentity };
170
+ const grant = { ...body, digest: autonomyDigest(body) };
171
+ if (createPreview(root, { proposal: displayed.proposal }, displayed.previewRevision).digest !== current.digest) autonomyError('autonomy-preview-stale');
172
+ writeAutonomyRecord(paths.projectRoot, resolve(paths.authorityRoot, 'grants', grant.id, 'revisions', `${grant.revision}-${grant.digest.slice(7)}.json`), grant);
173
+ return grant;
174
+ });
175
+ });
176
+ }
177
+
178
+ // Revocation is canonical evidence bound to one exact grant revision. Runtime
179
+ // rebuilds cannot restore it, and a successor needs a fresh named approval.
180
+ export function revokeAutonomyGrant(root, input) {
181
+ return autonomyGuard(() => {
182
+ keys(input, ['expectedDigest', 'revokedBy', 'confirmed']);
183
+ if (input.confirmed !== true || !validApprover(input.revokedBy) || !digestPattern.test(input.expectedDigest)) autonomyError('autonomy-revocation-required', 400);
184
+ const paths = autonomyPaths(root);
185
+ return withAutonomyLock(paths, () => {
186
+ const policy = readPolicy(root), grant = policy.grant;
187
+ if (policy.digest !== input.expectedDigest) autonomyError('autonomy-policy-stale');
188
+ if (!grant) autonomyError('autonomy-grant-required');
189
+ if (policy.revocation) return policy.revocation;
190
+ const body = { schema: 'ewai.autonomy-revocation/v1', id: grant.id, grantRevision: grant.revision,
191
+ grantDigest: grant.digest, revokedBy: input.revokedBy, revokedAt: new Date().toISOString(), projectIdentity: grant.projectIdentity };
192
+ if (Date.parse(body.revokedAt) < Date.parse(grant.approvedAt)) autonomyError('autonomy-clock-rollback');
193
+ const record = { ...body, digest: autonomyDigest(body) };
194
+ writeAutonomyRecord(paths.projectRoot, resolve(paths.authorityRoot, 'grants', grant.id,
195
+ 'revocations', `${grant.revision}-${record.digest.slice(7)}.json`), record);
196
+ return record;
197
+ });
198
+ });
199
+ }
package/src/checkin.mjs CHANGED
@@ -45,6 +45,24 @@ export {
45
45
 
46
46
  const execFileAsync = promisify(execFile);
47
47
  const moduleDir = dirname(fileURLToPath(import.meta.url));
48
+ const betaVersionPattern = /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/;
49
+
50
+ function betaVersionParts(version) {
51
+ const match = betaVersionPattern.exec(String(version ?? ''));
52
+ if (!match) return null;
53
+ const parts = match.slice(1).map(Number);
54
+ return parts.every(Number.isSafeInteger) ? parts : null;
55
+ }
56
+
57
+ function newerBetaVersion(current, candidate) {
58
+ const currentParts = betaVersionParts(current);
59
+ const candidateParts = betaVersionParts(candidate);
60
+ if (!currentParts || !candidateParts) return null;
61
+ for (let index = 0; index < currentParts.length; index++) {
62
+ if (candidateParts[index] !== currentParts[index]) return candidateParts[index] > currentParts[index];
63
+ }
64
+ return false;
65
+ }
48
66
 
49
67
  export async function frameworkStatus(installRoot, options = {}) {
50
68
  if (!installRoot || !existsSync(resolve(installRoot, 'package.json'))) {
@@ -55,16 +73,17 @@ export async function frameworkStatus(installRoot, options = {}) {
55
73
  if (!metadata.name) {
56
74
  return { status: 'unknown', source: 'npm', current: currentVersion, reason: 'The npm registry cannot be checked from this runtime.' };
57
75
  }
76
+ const channel = betaVersionParts(currentVersion) ? 'beta' : 'latest';
58
77
  let latest = null;
59
78
  try {
60
79
  if (options.fetchImpl) {
61
- const response = await options.fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(metadata.name)}/latest`);
80
+ const response = await options.fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(metadata.name)}/${channel}`);
62
81
  if (!response.ok) {
63
82
  return { status: 'unknown', source: 'npm', current: currentVersion, reason: `The npm registry returned HTTP ${response.status}.` };
64
83
  }
65
84
  latest = (await response.json()).version ?? null;
66
85
  } else {
67
- const npm = await execFileAsync('npm', ['view', `${metadata.name}@latest`, 'version', '--json'], {
86
+ const npm = await execFileAsync('npm', ['view', `${metadata.name}@${channel}`, 'version', '--json'], {
68
87
  timeout: 8000,
69
88
  maxBuffer: 1024 * 1024,
70
89
  env: process.env
@@ -73,16 +92,21 @@ export async function frameworkStatus(installRoot, options = {}) {
73
92
  latest = Array.isArray(parsed) ? parsed.at(-1) : parsed;
74
93
  }
75
94
  if (!latest) return { status: 'unknown', source: 'npm', current: currentVersion, reason: 'The npm registry did not return a latest version.' };
95
+ const betaAvailable = channel === 'beta' ? newerBetaVersion(currentVersion, latest) : null;
96
+ if (channel === 'beta' && betaAvailable === null) {
97
+ return { status: 'unknown', source: 'npm', current: currentVersion, reason: 'The npm beta tag did not resolve to a beta version.' };
98
+ }
99
+ const updateAvailable = channel === 'beta' ? betaAvailable : currentVersion !== latest;
76
100
  return {
77
- status: currentVersion === latest ? 'current' : 'update-available',
101
+ status: updateAvailable ? 'update-available' : 'current',
78
102
  source: 'npm',
79
103
  current: currentVersion,
80
104
  latest,
81
- action: currentVersion === latest ? null : {
105
+ action: updateAvailable ? {
82
106
  kind: 'offer-npm-update',
83
107
  prompt: `EWAI ${latest} is available from npm. Would you like me to update the global package?`,
84
- command: `npm install --global ${metadata.name}@latest`
85
- }
108
+ command: `npm install --global ${metadata.name}@${channel}`
109
+ } : null
86
110
  };
87
111
  } catch {
88
112
  return { status: 'unknown', source: 'npm', current: currentVersion, reason: 'The npm update check did not finish. Check your connection and try again.' };
package/src/cli.mjs CHANGED
@@ -3,6 +3,8 @@ import { fileURLToPath } from 'node:url';
3
3
  import { readFileSync, realpathSync } from 'node:fs';
4
4
  import YAML from 'yaml';
5
5
  import {DASHBOARD_VIEWS,readDashboardPreferences,saveDashboardPreferences} from './dashboard-preferences.mjs';
6
+ import { autonomyInterfaceAction, safeAutonomyInterfaceError } from './runtime/dashboard-actions.mjs';
7
+ import { readAutonomyFile } from './runtime/autonomy-workspace.mjs';
6
8
  import { createInterface } from 'node:readline/promises';
7
9
  import { Writable } from 'node:stream';
8
10
  import { configureAndInstallPremiumPersonas } from './checkin.mjs';
@@ -496,6 +498,14 @@ Commands:
496
498
  ewai checkin [--project PATH]
497
499
  ewai dashboard [--project PATH]
498
500
  ewai dashboard preferences [--project PATH] [--json]
501
+ ewai autonomy status [--run ID] [--project PATH] [--json]
502
+ ewai autonomy preview [--intent DOMAIN/SLUG --action ACTION --provider PROVIDER --expires-at ISO --max-runtime-ms N --max-operation-ms N --max-attempts N --record] [--project PATH] [--json]
503
+ ewai autonomy approve --expected-digest DIGEST --approved-by NAME --yes [--project PATH] [--json]
504
+ ewai autonomy revoke --expected-digest DIGEST --revoked-by NAME --yes [--project PATH] [--json]
505
+ ewai autonomy run|service --expected-digest DIGEST --provider PROVIDER --yes [--project PATH] [--json]
506
+ ewai autonomy pause|resume|cancel|recover --run ID --expected-revision N --yes [--project PATH] [--json]
507
+ ewai autonomy control --action pause|resume|cancel|revoke|recover --run ID --expected-revision N --yes [--revoked-by NAME] [--project PATH] [--json]
508
+ ewai autonomy answer --input PROJECT_RELATIVE_JSON --yes [--project PATH] [--json]
499
509
  ewai dashboard configure --enable VIEW | --disable VIEW [--collapse | --expand] --expected-digest DIGEST --yes [--project PATH] [--json]
500
510
  ewai server start|status|stop [--project PATH]
501
511
  ewai mcp [--project PATH]
@@ -839,6 +849,59 @@ export async function run(args) {
839
849
  return;
840
850
  }
841
851
 
852
+ if (command === 'autonomy') {
853
+ const controlAlias = ['pause', 'resume', 'cancel', 'recover'].includes(subcommand);
854
+ const action = controlAlias ? 'control' : subcommand;
855
+ const flags = {
856
+ status: { values: ['--project', '--run'], booleans: ['--json'] },
857
+ preview: { values: ['--project', '--intent', '--action', '--provider', '--expires-at', '--max-runtime-ms', '--max-operation-ms', '--max-attempts'], booleans: ['--json', '--record', '--yes'] },
858
+ approve: { values: ['--project', '--expected-digest', '--approved-by'], booleans: ['--json', '--yes'] },
859
+ revoke: { values: ['--project', '--expected-digest', '--revoked-by'], booleans: ['--json', '--yes'] },
860
+ run: { values: ['--project', '--expected-digest', '--provider'], booleans: ['--json', '--yes'] },
861
+ service: { values: ['--project', '--expected-digest', '--provider'], booleans: ['--json', '--yes'] },
862
+ control: { values: ['--project', '--run', '--expected-revision', '--revoked-by', ...(controlAlias ? [] : ['--action'])], booleans: ['--json', '--yes'] },
863
+ answer: { values: ['--project', '--input'], booleans: ['--json', '--yes'] },
864
+ }[action];
865
+ if (!flags) throw new Error('Unsupported autonomy operation.');
866
+ const seen = new Set();
867
+ for (let index = 2; index < args.length; index += 1) {
868
+ const flag = args[index];
869
+ if (seen.has(flag) && !(action === 'preview' && ['--intent', '--action', '--provider'].includes(flag))) throw new Error('Duplicate autonomy option.');
870
+ seen.add(flag);
871
+ if (flags.values.includes(flag)) {
872
+ if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error('An autonomy option is missing its value.');
873
+ index += 1;
874
+ } else if (!flags.booleans.includes(flag)) throw new Error('Unsupported autonomy option.');
875
+ }
876
+ const root = selectedProject(args);
877
+ let input = {};
878
+ if (action === 'preview') {
879
+ const scopeFlags = flags.values.filter(flag => flag !== '--project');
880
+ const proposal = scopeFlags.some(flag => has(args, flag)) ? {
881
+ intentIds: options(args, '--intent'), actions: options(args, '--action'), providers: options(args, '--provider'),
882
+ expiresAt: option(args, '--expires-at'), limits: { maxConcurrentIntents: 1,
883
+ maxRuntimeMs: Number(option(args, '--max-runtime-ms')), maxOperationMs: Number(option(args, '--max-operation-ms')),
884
+ maxAttempts: Number(option(args, '--max-attempts')) },
885
+ } : undefined;
886
+ // --record is existing explicit consent to persist a preview, never a grant.
887
+ input = { ...(proposal ? { proposal } : {}), record: has(args, '--record'), confirmed: has(args, '--record') || has(args, '--yes') };
888
+ } else if (action === 'answer') {
889
+ const path = option(args, '--input'); if (!path) throw new Error('Answer input is required.');
890
+ const parsed = JSON.parse(readAutonomyFile(root, resolve(root, path)));
891
+ if (!parsed || Object.getPrototypeOf(parsed) !== Object.prototype) throw new Error('Answer input must be an object.');
892
+ input = { ...parsed, confirmed: has(args, '--yes') };
893
+ } else {
894
+ for (const [flag, key] of [['--expected-digest', 'expectedDigest'], ['--approved-by', 'approvedBy'], ['--revoked-by', 'revokedBy'],
895
+ ['--provider', 'provider'], ['--run', 'runId'], ['--expected-revision', 'expectedRevision'], ['--action', 'action']]) {
896
+ if (has(args, flag)) input[key] = key === 'expectedRevision' ? Number(option(args, flag)) : option(args, flag);
897
+ }
898
+ if (action !== 'status') input.confirmed = has(args, '--yes');
899
+ if (controlAlias) input.action = subcommand;
900
+ }
901
+ const result = await autonomyInterfaceAction(root, action, input);
902
+ print(json ? result : JSON.stringify(result, null, 2), json); return;
903
+ }
904
+
842
905
  if (command === 'dashboard' && ['preferences','configure'].includes(subcommand)) {
843
906
  const valueFlags=['--project','--enable','--disable','--expected-digest'];
844
907
  const booleanFlags=['--json','--yes','--collapse','--expand'];
@@ -2416,6 +2479,10 @@ export async function run(args) {
2416
2479
  // Reporting must never replace or mask the original command failure.
2417
2480
  }
2418
2481
  }
2482
+ if (command === 'autonomy') {
2483
+ const safe = safeAutonomyInterfaceError(error);
2484
+ console.error(json ? JSON.stringify(safe) : safe.error); process.exitCode = 1; return;
2485
+ }
2419
2486
  const suppliedRolloutProject = option(args, '--project');
2420
2487
  const rolloutError = command === 'rollout'
2421
2488
  ? { error: suppliedRolloutProject ? String(error.message).replaceAll(suppliedRolloutProject, '<project>') : String(error.message), notices: { advisory: ROLLOUT_ADVISORY_NOTICE, security: ROLLOUT_ASSURANCE_NOTICE } }
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
3
  import { atomicJson, isWithin, now, sha256 } from './delivery-documents.mjs';
4
4
  import { writeTaskGraphCheck } from './task-graph.mjs';
5
5
  import { policyGateRequirementsForPhase, readPolicyEvidenceFreshness } from './policy-gate-integration.mjs';
6
+ import { withIntentMutation, assertIntentMutation } from './runtime/intent-ownership.mjs';
6
7
 
7
8
  const taskGraphPhases = new Set([
8
9
  'plan', 'pattern-validation', 'test-plan', 'build', 'delivery',
@@ -236,6 +237,10 @@ export function assertCompletedEvidenceFresh(paths, state) {
236
237
  }
237
238
 
238
239
  export function recordPhaseGateAtPaths(paths, slug, phaseId, input = {}, options = {}) {
240
+ return withIntentMutation(paths.projectRoot, slug, { action: 'record-gate', phase: phaseId, input: { input, options }, ownership: input.ownership }, () => recordOwnedPhaseGateAtPaths(paths, slug, phaseId, input, options));
241
+ }
242
+
243
+ function recordOwnedPhaseGateAtPaths(paths, slug, phaseId, input, options) {
239
244
  let taskGraphCheck = null;
240
245
  if (taskGraphPhases.has(phaseId)) {
241
246
  taskGraphCheck = writeTaskGraphCheck(paths.deliveryRoot, phaseId, { slug });
@@ -288,8 +293,10 @@ export function recordPhaseGateAtPaths(paths, slug, phaseId, input = {}, options
288
293
  deterministic_gate_failures_found_by_external_review: input.deterministicGateFailures ?? [],
289
294
  generated_at: now(),
290
295
  };
291
- atomicJson(gatePathFor(paths, phaseId), gate);
296
+ assertIntentMutation(paths.projectRoot, slug);
292
297
  if (gate.status === 'pass') validateGate(paths, phaseId, gate, options.providers ?? []);
298
+ assertIntentMutation(paths.projectRoot, slug);
299
+ atomicJson(gatePathFor(paths, phaseId), gate);
293
300
  return gate;
294
301
  }
295
302