specrails-core 6.0.1 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/agent-runtime/capabilities.js +3 -1
- package/dist/agent-runtime/change-scope.js +2 -0
- package/dist/agent-runtime/cli-executor.d.ts +3 -0
- package/dist/agent-runtime/cli-executor.js +52 -14
- package/dist/agent-runtime/cli-process.js +6 -2
- package/dist/agent-runtime/cli.d.ts +15 -0
- package/dist/agent-runtime/cli.js +43 -9
- package/dist/agent-runtime/compact/chat-client.js +2 -1
- package/dist/agent-runtime/config.d.ts +4 -1
- package/dist/agent-runtime/config.js +41 -3
- package/dist/agent-runtime/core-host.d.ts +9 -3
- package/dist/agent-runtime/core-host.js +21 -18
- package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
- package/dist/agent-runtime/efficiency-summary.js +30 -27
- package/dist/agent-runtime/efficiency-types.d.ts +4 -4
- package/dist/agent-runtime/efficiency.d.ts +1 -1
- package/dist/agent-runtime/efficiency.js +7 -6
- package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
- package/dist/agent-runtime/engine/budget.d.ts +9 -0
- package/dist/agent-runtime/engine/budget.js +33 -0
- package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
- package/dist/agent-runtime/engine/canonical-json.js +162 -0
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
- package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
- package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
- package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
- package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
- package/dist/agent-runtime/engine/cli.d.ts +10 -0
- package/dist/agent-runtime/engine/cli.js +172 -0
- package/dist/agent-runtime/engine/compiler.d.ts +23 -0
- package/dist/agent-runtime/engine/compiler.js +262 -0
- package/dist/agent-runtime/engine/composition.d.ts +16 -0
- package/dist/agent-runtime/engine/composition.js +28 -0
- package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
- package/dist/agent-runtime/engine/concurrency.js +55 -0
- package/dist/agent-runtime/engine/contracts.d.ts +273 -0
- package/dist/agent-runtime/engine/contracts.js +11 -0
- package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
- package/dist/agent-runtime/engine/definition-schema.js +315 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
- package/dist/agent-runtime/engine/definition-types.js +3 -0
- package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
- package/dist/agent-runtime/engine/definition-validator.js +191 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
- package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
- package/dist/agent-runtime/engine/events.d.ts +19 -0
- package/dist/agent-runtime/engine/events.js +88 -0
- package/dist/agent-runtime/engine/execution.d.ts +29 -0
- package/dist/agent-runtime/engine/execution.js +121 -0
- package/dist/agent-runtime/engine/expressions.d.ts +11 -0
- package/dist/agent-runtime/engine/expressions.js +185 -0
- package/dist/agent-runtime/engine/fork.d.ts +28 -0
- package/dist/agent-runtime/engine/fork.js +253 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
- package/dist/agent-runtime/engine/graph-description.js +16 -0
- package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
- package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
- package/dist/agent-runtime/engine/index.d.ts +14 -0
- package/dist/agent-runtime/engine/index.js +14 -0
- package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
- package/dist/agent-runtime/engine/invocation-context.js +130 -0
- package/dist/agent-runtime/engine/otel.d.ts +15 -0
- package/dist/agent-runtime/engine/otel.js +132 -0
- package/dist/agent-runtime/engine/output.d.ts +4 -0
- package/dist/agent-runtime/engine/output.js +73 -0
- package/dist/agent-runtime/engine/piece-dependencies.d.ts +54 -0
- package/dist/agent-runtime/engine/piece-dependencies.js +240 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
- package/dist/agent-runtime/engine/piece-registry.js +62 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +4 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.js +70 -0
- package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
- package/dist/agent-runtime/engine/pieces/assign.js +35 -0
- package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/control.js +53 -0
- package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/decider.js +70 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +13 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
- package/dist/agent-runtime/engine/pieces/implementation-step.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/implementation-step.js +47 -0
- package/dist/agent-runtime/engine/pieces/implementation.d.ts +27 -0
- package/dist/agent-runtime/engine/pieces/implementation.js +177 -0
- package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/index.js +24 -0
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/openspec.js +75 -0
- package/dist/agent-runtime/engine/pieces/ports.d.ts +53 -0
- package/dist/agent-runtime/engine/pieces/ports.js +1 -0
- package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
- package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
- package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
- package/dist/agent-runtime/engine/pieces/shared.js +40 -0
- package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
- package/dist/agent-runtime/engine/pieces/shell.js +47 -0
- package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/verify.js +102 -0
- package/dist/agent-runtime/engine/preflight.d.ts +27 -0
- package/dist/agent-runtime/engine/preflight.js +54 -0
- package/dist/agent-runtime/engine/regex.d.ts +3 -0
- package/dist/agent-runtime/engine/regex.js +19 -0
- package/dist/agent-runtime/engine/run-status.d.ts +563 -0
- package/dist/agent-runtime/engine/run-status.js +61 -0
- package/dist/agent-runtime/engine/runs.d.ts +605 -0
- package/dist/agent-runtime/engine/runs.js +247 -0
- package/dist/agent-runtime/engine/state.d.ts +64 -0
- package/dist/agent-runtime/engine/state.js +98 -0
- package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
- package/dist/agent-runtime/engine/steering/inbox.js +113 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
- package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
- package/dist/agent-runtime/engine/storage/private-path.js +74 -0
- package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
- package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
- package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
- package/dist/agent-runtime/evaluation-corpus.js +4 -0
- package/dist/agent-runtime/evaluation.d.ts +9 -3
- package/dist/agent-runtime/evaluation.js +50 -8
- package/dist/agent-runtime/executor-types.d.ts +53 -3
- package/dist/agent-runtime/executor-types.js +35 -2
- package/dist/agent-runtime/executors.js +2 -2
- package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
- package/dist/agent-runtime/graph/artifacts.js +82 -9
- package/dist/agent-runtime/graph/nodes.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +2 -2
- package/dist/agent-runtime/graph/roles.d.ts +8 -0
- package/dist/agent-runtime/graph/roles.js +25 -17
- package/dist/agent-runtime/kimi-acp.js +6 -6
- package/dist/agent-runtime/openai-executor.js +17 -12
- package/dist/agent-runtime/openspec.d.ts +17 -2
- package/dist/agent-runtime/openspec.js +54 -20
- package/dist/agent-runtime/prompts.d.ts +4 -3
- package/dist/agent-runtime/prompts.js +27 -6
- package/dist/agent-runtime/recovery.d.ts +1 -1
- package/dist/agent-runtime/repository-context.js +1 -1
- package/dist/agent-runtime/role-routing.js +4 -2
- package/dist/agent-runtime/role-state.d.ts +5 -0
- package/dist/agent-runtime/verification-plan.d.ts +2 -0
- package/dist/agent-runtime/verification-plan.js +10 -0
- package/dist/agent-runtime/workflow-types.d.ts +2 -0
- package/dist/agent-runtime/workflow.d.ts +3 -1
- package/dist/agent-runtime/workflow.js +7 -3
- package/dist/agent-runtime/workspace-tools.d.ts +2 -2
- package/dist/agent-runtime/workspace-tools.js +6 -6
- package/dist/installer/cli.js +5 -0
- package/dist/installer/phases/scaffold.d.ts +2 -2
- package/dist/installer/phases/scaffold.js +3 -7
- package/dist/pipeline/pipeline-state.d.ts +38 -4
- package/dist/pipeline/pipeline-state.js +96 -21
- package/integration-contract.json +111 -19
- package/package.json +13 -6
- package/schemas/agent-runtime.schema.json +93 -1
- package/schemas/workflow-definition.schema.json +314 -0
- package/templates/kimi/specrails/run-skill.mjs +15 -1
- package/templates/commands/specrails/batch-implement.md +0 -32
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fingerprintCandidate, persistCheckEvidence, readCandidateScope, pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
|
|
4
|
+
import { resolveRoleDescriptor } from '../config.js';
|
|
5
|
+
import { prepareOpenSpec, roleOpenSpecContext } from '../openspec.js';
|
|
6
|
+
import { contentDigest } from './canonical-json.js';
|
|
7
|
+
import { EngineError } from './contracts.js';
|
|
8
|
+
import { definitionNodeAt, invocationStepContext, roleAt } from './invocation-context.js';
|
|
9
|
+
import { deriveImplementationBinding } from './pieces/implementation-binding.js';
|
|
10
|
+
const json = (value) => JSON.parse(JSON.stringify(value));
|
|
11
|
+
/** Adapters are bound to one frozen project, definition and execution lease. */
|
|
12
|
+
export class RunPieceDependencies {
|
|
13
|
+
context;
|
|
14
|
+
config;
|
|
15
|
+
registry;
|
|
16
|
+
definition;
|
|
17
|
+
ledger;
|
|
18
|
+
directory;
|
|
19
|
+
change;
|
|
20
|
+
projectMemory;
|
|
21
|
+
policies;
|
|
22
|
+
constructor(context, config, registry, definition, ledger, directory, change, projectMemory) {
|
|
23
|
+
this.context = context;
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.registry = registry;
|
|
26
|
+
this.definition = definition;
|
|
27
|
+
this.ledger = ledger;
|
|
28
|
+
this.directory = directory;
|
|
29
|
+
this.change = change;
|
|
30
|
+
this.projectMemory = projectMemory;
|
|
31
|
+
this.policies = definition.policies;
|
|
32
|
+
}
|
|
33
|
+
memory(context) {
|
|
34
|
+
if (!this.projectMemory)
|
|
35
|
+
throw new EngineError('memory_unavailable', 'Project memory is not bound to this execution');
|
|
36
|
+
const kind = this.ledger.db.get('visits', { visit_id: context.frame.visitId })?.kind;
|
|
37
|
+
const prefixes = kind === 'verify' ? [['verification', 'known-commands']] : kind === 'role-turn' ? [['roles', roleAt(this.definition, context.frame.nodePath), 'sessions'], ['review', 'notes']] : [];
|
|
38
|
+
return this.projectMemory.forAccess(prefixes.length ? 'write' : 'none', { namespacePrefixes: prefixes });
|
|
39
|
+
}
|
|
40
|
+
implementationBindings() {
|
|
41
|
+
return this.ledger.db.sqlite.prepare("SELECT value_json FROM piece_state WHERE run_id=? AND key='binding:implementation'").all(this.ledger.runId)
|
|
42
|
+
.map(row => JSON.parse(String(row.value_json)))
|
|
43
|
+
.filter(binding => binding.parentRunId === this.context.runId);
|
|
44
|
+
}
|
|
45
|
+
bindImplementation(context, change, shared = false) {
|
|
46
|
+
const scoped = shared ? { ...context, frame: { ...context.frame, nodePath: context.frame.scope.nodePathPrefix } } : context;
|
|
47
|
+
const binding = deriveImplementationBinding(this.context, scoped, change);
|
|
48
|
+
if (shared)
|
|
49
|
+
binding.shared = true;
|
|
50
|
+
const previous = shared ? this.ledger.readScopedPieceState(context.frame, binding.nodePath, 'binding:implementation') : this.ledger.readPieceState(context.frame, 'binding:implementation');
|
|
51
|
+
if (previous !== undefined && contentDigest(previous) !== contentDigest(binding))
|
|
52
|
+
throw new EngineError('scope_mismatch', 'Implementation binding changed within a frozen run');
|
|
53
|
+
if (previous === undefined) {
|
|
54
|
+
if (shared)
|
|
55
|
+
this.ledger.writeScopedPieceState(context.frame, binding.nodePath, 'binding:implementation', json(binding));
|
|
56
|
+
else
|
|
57
|
+
this.ledger.writePieceState(context.frame, 'binding:implementation', json(binding));
|
|
58
|
+
}
|
|
59
|
+
return binding;
|
|
60
|
+
}
|
|
61
|
+
candidateScope() {
|
|
62
|
+
const row = this.ledger.run();
|
|
63
|
+
const inherited = row.fork_of && row.candidate_json ? JSON.parse(String(row.candidate_json)).scope : undefined;
|
|
64
|
+
const base = this.definition.journal === 'implementation' && existsSync(path.join(pipelineStateDirectory(this.context), 'state.json'))
|
|
65
|
+
? readCandidateScope(this.context)
|
|
66
|
+
: inherited ? this.inheritedCandidateScope(inherited)
|
|
67
|
+
: { context: this.context, scopeHash: contentDigest(this.context), artifactExclusions: this.change ? ['openspec/changes/' + this.change] : [] };
|
|
68
|
+
if (inherited && this.change)
|
|
69
|
+
base.artifactExclusions = [...new Set([...base.artifactExclusions, 'openspec/changes/' + this.change])];
|
|
70
|
+
const bindings = this.implementationBindings(), repositoryExclusions = Object.fromEntries(Object.entries(base.repositoryExclusions ?? {}).map(([id, paths]) => [id, [...paths]]));
|
|
71
|
+
for (const binding of bindings) {
|
|
72
|
+
const exclusions = existsSync(path.join(binding.directory, 'state.json')) ? readCandidateScope(binding.context).artifactExclusions : ['openspec/changes/' + binding.change];
|
|
73
|
+
const repository = this.context.repositories.find(item => item.id === binding.context.artifactRepositoryId);
|
|
74
|
+
const prefix = path.relative(repository.path, binding.context.artifactRoot).split(path.sep).join('/');
|
|
75
|
+
const paths = exclusions.map(relative => prefix ? prefix + '/' + relative : relative);
|
|
76
|
+
repositoryExclusions[repository.id] = [...new Set([...(repositoryExclusions[repository.id] ?? []), ...paths])];
|
|
77
|
+
}
|
|
78
|
+
return { ...base, runtimeExclusions: [...new Set([...(base.runtimeExclusions ?? []), pipelineStateDirectory(this.context), ...bindings.map(binding => binding.directory), ...(this.projectMemory ? ['', '-wal', '-shm'].map(suffix => this.projectMemory.filename + suffix) : [])])], repositoryExclusions };
|
|
79
|
+
}
|
|
80
|
+
inheritedCandidateScope(value) {
|
|
81
|
+
const strings = (items) => Array.isArray(items) && items.every(item => typeof item === 'string');
|
|
82
|
+
if (typeof value.scopeHash !== 'string' || !strings(value.artifactExclusions) || !strings(value.runtimeExclusions)
|
|
83
|
+
|| !value.repositoryExclusions || typeof value.repositoryExclusions !== 'object' || Array.isArray(value.repositoryExclusions)
|
|
84
|
+
|| !Object.values(value.repositoryExclusions).every(strings))
|
|
85
|
+
throw new EngineError('storage_corrupt', 'Inherited candidate scope is malformed');
|
|
86
|
+
return { context: this.context, scopeHash: value.scopeHash, artifactExclusions: value.artifactExclusions,
|
|
87
|
+
runtimeExclusions: value.runtimeExclusions, repositoryExclusions: value.repositoryExclusions };
|
|
88
|
+
}
|
|
89
|
+
candidate(transition, revision) {
|
|
90
|
+
const scope = this.candidateScope();
|
|
91
|
+
return { hash: fingerprintCandidate(scope), atTransition: transition, revision,
|
|
92
|
+
scope: { scopeHash: scope.scopeHash, artifactExclusions: [...scope.artifactExclusions],
|
|
93
|
+
runtimeExclusions: [...(scope.runtimeExclusions ?? [])],
|
|
94
|
+
repositoryExclusions: Object.fromEntries(Object.entries(scope.repositoryExclusions ?? {}).map(([id, paths]) => [id, [...paths]])) } };
|
|
95
|
+
}
|
|
96
|
+
implementationExclusions(_context) {
|
|
97
|
+
const scope = this.candidateScope();
|
|
98
|
+
return { runtimeExclusions: [pipelineStateDirectory(this.context), ...(scope.runtimeExclusions ?? [])], repositoryExclusions: scope.repositoryExclusions ?? {} };
|
|
99
|
+
}
|
|
100
|
+
fingerprint() { return fingerprintCandidate(this.candidateScope()); }
|
|
101
|
+
initializeCandidate() {
|
|
102
|
+
if (this.ledger.run().candidate_json)
|
|
103
|
+
return;
|
|
104
|
+
const candidate = this.candidate(0, 1);
|
|
105
|
+
this.ledger.db.transaction('candidate-initialized', () => {
|
|
106
|
+
this.ledger.lease.assert(this.ledger.token);
|
|
107
|
+
this.ledger.db.put('runs', { ...this.ledger.run(), candidate_json: JSON.stringify(candidate), candidate_revision: candidate.revision });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
executionSnapshot(context) {
|
|
111
|
+
const snapshot = this.ledger.scopeSnapshot(context.frame.scope.id), hash = this.fingerprint();
|
|
112
|
+
const candidate = snapshot.candidate?.hash === hash ? snapshot.candidate : this.candidate(context.frame.transition, Number(this.ledger.run().candidate_revision) + 1);
|
|
113
|
+
return { candidate, verified: snapshot.verified?.candidateHash === hash ? snapshot.verified : null };
|
|
114
|
+
}
|
|
115
|
+
/** Write admission revokes certification; the archive may check its immutable pre-write checkpoint against a real receipt. */
|
|
116
|
+
verifiedBeforeWrite(context) {
|
|
117
|
+
const verified = context.state.$verified;
|
|
118
|
+
if (!verified || verified.candidateHash !== this.fingerprint())
|
|
119
|
+
return null;
|
|
120
|
+
const receipt = this.ledger.db.get('receipts', { receipt_id: verified.receiptId });
|
|
121
|
+
if (!receipt || receipt.run_id !== this.ledger.runId || receipt.valid !== 1 || receipt.candidate_hash !== verified.candidateHash)
|
|
122
|
+
return null;
|
|
123
|
+
const evidence = JSON.parse(String(receipt.receipt_json));
|
|
124
|
+
return evidence.scope === 'full' ? verified : null;
|
|
125
|
+
}
|
|
126
|
+
/** Only the execution owner proposes protected candidate/receipt channels. */
|
|
127
|
+
finalize(frame, result, effect) {
|
|
128
|
+
const hash = this.fingerprint(), snapshot = this.ledger.scopeSnapshot(frame.scope.id);
|
|
129
|
+
const candidate = effect === 'write' ? this.candidate(frame.transition, Number(this.ledger.run().candidate_revision) + 1)
|
|
130
|
+
: snapshot.candidate ?? this.candidate(frame.transition, 1);
|
|
131
|
+
const binding = this.implementationBindings().find(value => (frame.scope.id === value.scopeId || frame.scope.id.startsWith(value.scopeId + '/')) && (frame.nodePath === value.nodePath || frame.nodePath.startsWith(value.nodePath + '/')));
|
|
132
|
+
const scoped = binding && binding.context.runId !== this.context.runId;
|
|
133
|
+
if (scoped && result.receipt)
|
|
134
|
+
result = { ...result, receipt: { ...result.receipt, candidateHash: hash, scope: 'scoped' }, verified: null };
|
|
135
|
+
const receipt = result.receipt;
|
|
136
|
+
const node = definitionNodeAt(this.definition, frame.nodePath);
|
|
137
|
+
const kind = node?.kind ?? this.ledger.db.get('visits', { visit_id: frame.visitId })?.kind;
|
|
138
|
+
const evidence = receipt?.evidence;
|
|
139
|
+
const certifies = result.verified !== null && ['verify', 'implementation', 'implementation-step'].includes(String(kind)) && receipt?.valid && receipt.scope === 'full' && !!evidence?.commands?.length && !evidence.unverifiedRepositories?.length;
|
|
140
|
+
if (receipt?.valid && receipt.candidateHash !== hash)
|
|
141
|
+
return { ...result, outcome: 'fail',
|
|
142
|
+
...(effect === 'write' ? { candidate } : {}), verified: null,
|
|
143
|
+
receipt: { ...receipt, valid: false, evidence: json({ ...receipt.evidence, valid: false, reason: 'Candidate changed before terminal commit' }) },
|
|
144
|
+
output: { receiptId: receipt.id, valid: false, reason: 'Candidate changed before terminal commit' }, };
|
|
145
|
+
return { ...result, ...(effect === 'write' ? { candidate } : {}),
|
|
146
|
+
...(certifies ? { verified: { receiptId: receipt.id, candidateHash: hash, atTransition: frame.transition, revision: candidate.revision } }
|
|
147
|
+
: effect === 'write' || snapshot.verified?.candidateHash !== hash ? { verified: null } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
operationState(context) {
|
|
151
|
+
return [...this.ledger.scopeSnapshot(context.frame.scope.id).attempts].reverse()
|
|
152
|
+
.find(attempt => attempt.result?.childUpdate?.operationState !== undefined)?.result?.childUpdate;
|
|
153
|
+
}
|
|
154
|
+
artifactContracts(context) {
|
|
155
|
+
const owner = context.frame.scope.nodePathPrefix;
|
|
156
|
+
const keyOf = (key) => 'contract:artifacts:' + key;
|
|
157
|
+
return { get: key => this.ledger.readScopedPieceState(context.frame, owner, keyOf(key)),
|
|
158
|
+
set: (key, value) => this.ledger.writeScopedPieceState(context.frame, owner, keyOf(key), value) };
|
|
159
|
+
}
|
|
160
|
+
memo(context) {
|
|
161
|
+
const keyOf = (key) => 'memo:' + context.frame.visitId + ':' + key;
|
|
162
|
+
return { get: key => this.ledger.readPieceState(context.frame, keyOf(key)), set: (key, value) => this.ledger.writePieceState(context.frame, keyOf(key), value) };
|
|
163
|
+
}
|
|
164
|
+
roleState(context) {
|
|
165
|
+
return {
|
|
166
|
+
read: () => {
|
|
167
|
+
const value = this.ledger.readScopedPieceState(context.frame, context.frame.scope.nodePathPrefix, 'session:roles');
|
|
168
|
+
if (value === undefined)
|
|
169
|
+
return { sessions: {}, routes: {} };
|
|
170
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !value.sessions || !value.routes)
|
|
171
|
+
throw new EngineError('storage_corrupt', 'Scoped role state is malformed');
|
|
172
|
+
return structuredClone(value);
|
|
173
|
+
},
|
|
174
|
+
write: value => this.ledger.writeScopedPieceState(context.frame, context.frame.scope.nodePathPrefix, 'session:roles', json(value)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
stepContext(context) {
|
|
178
|
+
const step = invocationStepContext(this.ledger, this.definition, context);
|
|
179
|
+
if (definitionNodeAt(this.definition, context.frame.nodePath)?.kind !== 'implementation-step')
|
|
180
|
+
return step;
|
|
181
|
+
const phase = (path) => {
|
|
182
|
+
const node = definitionNodeAt(this.definition, path);
|
|
183
|
+
return node?.kind === 'implementation-step' ? String(node.params.phase) : path;
|
|
184
|
+
};
|
|
185
|
+
const checkpoint = step.checkpoint;
|
|
186
|
+
return { ...step, checkpoint: { ...checkpoint,
|
|
187
|
+
nextStep: checkpoint.nextStep ? phase(checkpoint.nextStep) : null,
|
|
188
|
+
steps: Object.fromEntries(Object.entries(checkpoint.steps).map(([key, value]) => [phase(key), { ...value, id: phase(key) }])),
|
|
189
|
+
history: checkpoint.history.map(value => ({ ...value, stepId: phase(value.stepId) })),
|
|
190
|
+
events: checkpoint.events.map(value => ({ ...value, ...(value.stepId ? { stepId: phase(value.stepId) } : {}) })),
|
|
191
|
+
...(checkpoint.pendingApproval ? { pendingApproval: { ...checkpoint.pendingApproval, stepId: phase(checkpoint.pendingApproval.stepId) } } : {}),
|
|
192
|
+
...(checkpoint.pendingQuestion ? { pendingQuestion: { ...checkpoint.pendingQuestion, stepId: phase(checkpoint.pendingQuestion.stepId) } } : {}),
|
|
193
|
+
} };
|
|
194
|
+
}
|
|
195
|
+
settleResult(context, key, value, invocation) {
|
|
196
|
+
if (!invocation.invocationId)
|
|
197
|
+
throw new EngineError('invocation_mismatch', 'A response memo requires its durable invocation ID');
|
|
198
|
+
this.ledger.settleInvocation(context.frame, { ...invocation, invocationId: invocation.invocationId }, { key: 'memo:' + context.frame.visitId + ':' + key, value });
|
|
199
|
+
}
|
|
200
|
+
artifactDirectory(context) {
|
|
201
|
+
const target = path.join(this.directory, 'artifacts', contentDigest({ scope: context.frame.scope.id, node: context.frame.nodePath }));
|
|
202
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
203
|
+
return target;
|
|
204
|
+
}
|
|
205
|
+
openspec(context) {
|
|
206
|
+
const roles = this.definition.roles.map(role => resolveRoleDescriptor(this.config, role)).filter(role => role.openspecSkill);
|
|
207
|
+
if (!roles.length)
|
|
208
|
+
return {};
|
|
209
|
+
if (!this.change)
|
|
210
|
+
throw new EngineError('invalid_arguments', 'OpenSpec roles require a frozen change name');
|
|
211
|
+
const directory = this.artifactDirectory(context), prepared = prepareOpenSpec(this.context.artifactRoot, this.change, directory);
|
|
212
|
+
return Object.fromEntries(roles.map(role => {
|
|
213
|
+
const provider = this.config.providers.find(item => item.id === role.provider);
|
|
214
|
+
return [role.id, roleOpenSpecContext(prepared, this.context.artifactRoot, this.change, directory, role, provider?.kind === 'cli' ? provider.cli : 'claude')];
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
verification(context) {
|
|
218
|
+
const candidateHash = this.fingerprint(), scope = this.candidateScope();
|
|
219
|
+
const row = this.ledger.db.sqlite.prepare("SELECT receipt_json FROM receipts WHERE run_id=? AND valid=1 AND candidate_hash=? ORDER BY created_at DESC LIMIT 1").get(this.ledger.runId, candidateHash);
|
|
220
|
+
const previous = row ? JSON.parse(String(row.receipt_json)).evidence : undefined;
|
|
221
|
+
return {
|
|
222
|
+
candidateHash, scopeHash: scope.scopeHash, ...(previous ? { previous } : {}),
|
|
223
|
+
isCurrent: () => { this.ledger.lease.assert(this.ledger.token); return this.fingerprint() === candidateHash; },
|
|
224
|
+
persistCheck: (result, planHash, candidate) => {
|
|
225
|
+
this.ledger.lease.assert(this.ledger.token);
|
|
226
|
+
// Retain the existing bounded evidence format and paginated CLI reader.
|
|
227
|
+
// These are diagnostic artifacts; only the SQLite terminal transaction
|
|
228
|
+
// can certify this candidate and advance the workflow.
|
|
229
|
+
persistCheckEvidence(this.context, result, planHash, candidate);
|
|
230
|
+
this.ledger.writePieceState(context.frame, 'evidence:check:' + result.evidenceId, json({ ...result, planHash, candidateHash: candidate }));
|
|
231
|
+
},
|
|
232
|
+
commitReceipt: receipt => {
|
|
233
|
+
this.ledger.lease.assert(this.ledger.token);
|
|
234
|
+
const result = this.fingerprint() === candidateHash ? receipt : { ...receipt, valid: false, reason: 'Candidate changed during verification' };
|
|
235
|
+
this.ledger.writePieceState(context.frame, 'evidence:receipt:' + receipt.id, json(result));
|
|
236
|
+
return result;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type JsonObject, type Piece, type EngineEffect, type PieceDescriptor } from './contracts.js';
|
|
2
|
+
import type { DefinitionIssue, RoleCatalog } from './definition-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Version of the published piece catalog and observable piece behavior. Bump
|
|
5
|
+
* when either changes; retained runtime packages preserve earlier semantics.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NODE_KINDS_VERSION = 7;
|
|
8
|
+
/** Immutable bindings supplied by Core's composition root; user definitions cannot register code. */
|
|
9
|
+
export declare class PieceRegistry {
|
|
10
|
+
private readonly entries;
|
|
11
|
+
constructor(pieces: readonly Piece[]);
|
|
12
|
+
get(kind: string): Piece;
|
|
13
|
+
catalog(): PieceDescriptor[];
|
|
14
|
+
/** Registered kinds in registration order: the `nodeKinds` advertised by `runtime api` and the integration contract. */
|
|
15
|
+
kinds(): string[];
|
|
16
|
+
validateParams(kind: string, params: JsonObject, path: string): DefinitionIssue[];
|
|
17
|
+
outcomes(kind: string, params: JsonObject): readonly string[];
|
|
18
|
+
effect(kind: string, params: JsonObject, roles: RoleCatalog): EngineEffect;
|
|
19
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
2
|
+
import { EngineError } from './contracts.js';
|
|
3
|
+
function freezeData(value) {
|
|
4
|
+
if (value && typeof value === 'object') {
|
|
5
|
+
Object.values(value).forEach(freezeData);
|
|
6
|
+
Object.freeze(value);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Version of the published piece catalog and observable piece behavior. Bump
|
|
12
|
+
* when either changes; retained runtime packages preserve earlier semantics.
|
|
13
|
+
*/
|
|
14
|
+
export const NODE_KINDS_VERSION = 7;
|
|
15
|
+
/** Immutable bindings supplied by Core's composition root; user definitions cannot register code. */
|
|
16
|
+
export class PieceRegistry {
|
|
17
|
+
entries = new Map();
|
|
18
|
+
constructor(pieces) {
|
|
19
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, ownProperties: true });
|
|
20
|
+
for (const piece of pieces) {
|
|
21
|
+
const descriptor = freezeData(structuredClone(piece.descriptor));
|
|
22
|
+
if (this.entries.has(descriptor.kind))
|
|
23
|
+
throw new EngineError('duplicate_piece', `Duplicate piece ${descriptor.kind}`);
|
|
24
|
+
if (descriptor.effect === 'derived' && !piece.getEffect)
|
|
25
|
+
throw new EngineError('invalid_piece', `Derived piece ${descriptor.kind} requires getEffect`);
|
|
26
|
+
if (new Set(descriptor.outcomes).size !== descriptor.outcomes.length)
|
|
27
|
+
throw new EngineError('invalid_piece', 'Duplicate outcome labels');
|
|
28
|
+
this.entries.set(descriptor.kind, { piece: Object.freeze({ ...piece, descriptor }), validate: ajv.compile(descriptor.paramsSchema) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
get(kind) {
|
|
32
|
+
const entry = this.entries.get(kind);
|
|
33
|
+
if (!entry)
|
|
34
|
+
throw new EngineError('unknown_piece', `Piece ${kind} is not implemented by this runtime`);
|
|
35
|
+
return entry.piece;
|
|
36
|
+
}
|
|
37
|
+
catalog() { return [...this.entries.values()].map(({ piece }) => structuredClone(piece.descriptor)); }
|
|
38
|
+
/** Registered kinds in registration order: the `nodeKinds` advertised by `runtime api` and the integration contract. */
|
|
39
|
+
kinds() { return [...this.entries.keys()]; }
|
|
40
|
+
validateParams(kind, params, path) {
|
|
41
|
+
const entry = this.entries.get(kind);
|
|
42
|
+
if (!entry)
|
|
43
|
+
return [{ code: 'unknown_piece', path, message: `Piece ${kind} is not implemented by this runtime` }];
|
|
44
|
+
if (entry.validate(params))
|
|
45
|
+
return [];
|
|
46
|
+
return (entry.validate.errors ?? []).map(error => ({ code: 'invalid_piece_params', path: path + '/params' + error.instancePath, message: error.message ?? 'Invalid piece parameters' }));
|
|
47
|
+
}
|
|
48
|
+
outcomes(kind, params) {
|
|
49
|
+
const piece = this.get(kind);
|
|
50
|
+
const outcomes = piece.getOutcomes?.(params) ?? piece.descriptor.outcomes;
|
|
51
|
+
if (new Set(outcomes).size !== outcomes.length || outcomes.some(value => !/^[a-z][a-z0-9-]{0,31}$/.test(value)))
|
|
52
|
+
throw new EngineError('invalid_piece', `Invalid outcomes for ${kind}`);
|
|
53
|
+
return outcomes;
|
|
54
|
+
}
|
|
55
|
+
effect(kind, params, roles) {
|
|
56
|
+
const piece = this.get(kind);
|
|
57
|
+
const effect = piece.getEffect?.(params, roles) ?? piece.descriptor.effect;
|
|
58
|
+
if (effect !== 'read' && effect !== 'write')
|
|
59
|
+
throw new EngineError('invalid_piece', `Unresolved effect for ${kind}`);
|
|
60
|
+
return effect;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Piece } from '../contracts.js';
|
|
2
|
+
import type { PieceDependencyProvider } from './ports.js';
|
|
3
|
+
/** A reusable artifact invariant, with no agent roles, phase order or successor policy. */
|
|
4
|
+
export declare function artifactContractPiece(bindings: PieceDependencyProvider): Piece;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { artifactPath } from '../../openspec.js';
|
|
3
|
+
import { contentDigest } from '../canonical-json.js';
|
|
4
|
+
import { EngineError } from '../contracts.js';
|
|
5
|
+
import { idSchema, paramsSchema, text } from './shared.js';
|
|
6
|
+
/** A reusable artifact invariant, with no agent roles, phase order or successor policy. */
|
|
7
|
+
export function artifactContractPiece(bindings) {
|
|
8
|
+
return {
|
|
9
|
+
descriptor: { kind: 'artifact-contract', paramsSchema: paramsSchema({
|
|
10
|
+
change: { type: 'string', minLength: 1, maxLength: 128 }, contractId: idSchema, action: { enum: ['freeze', 'check'] },
|
|
11
|
+
requireCompletedTasks: { type: 'boolean' },
|
|
12
|
+
}, ['change', 'contractId', 'action']), outcomes: ['pass', 'fail', 'failed'], effect: 'read', requiresAI: false },
|
|
13
|
+
async execute(params, context) {
|
|
14
|
+
const deps = bindings(), change = text(params.change);
|
|
15
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(change) || change.length > 64)
|
|
16
|
+
throw new EngineError('invalid_arguments', 'Invalid change identifier');
|
|
17
|
+
const store = deps.artifactContracts?.(context);
|
|
18
|
+
if (!store)
|
|
19
|
+
throw new EngineError('artifact_contract_unavailable', 'Scoped artifact contracts are not bound');
|
|
20
|
+
const directory = 'openspec/changes/' + change;
|
|
21
|
+
const files = {};
|
|
22
|
+
let bytes = 0;
|
|
23
|
+
const read = (relative) => {
|
|
24
|
+
const source = readFileSync(artifactPath(deps.context.artifactRoot, directory + '/' + relative), 'utf8');
|
|
25
|
+
bytes += Buffer.byteLength(source);
|
|
26
|
+
if (bytes > 2 * 1024 * 1024 || Object.keys(files).length >= 200)
|
|
27
|
+
throw new EngineError('output_limit', 'Artifact contract exceeds its read limit');
|
|
28
|
+
return source;
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
for (const name of ['proposal.md', 'design.md', 'tasks.md'])
|
|
32
|
+
files[name] = read(name);
|
|
33
|
+
const walk = (relative) => {
|
|
34
|
+
for (const entry of readdirSync(artifactPath(deps.context.artifactRoot, directory + '/' + relative), { withFileTypes: true })) {
|
|
35
|
+
const child = relative + '/' + entry.name;
|
|
36
|
+
if (entry.isSymbolicLink())
|
|
37
|
+
throw new EngineError('artifact_scope_mismatch', 'Artifact contracts reject symlinks');
|
|
38
|
+
if (entry.isDirectory())
|
|
39
|
+
walk(child);
|
|
40
|
+
else if (entry.isFile() && entry.name.endsWith('.md'))
|
|
41
|
+
files[child] = read(child);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk('specs');
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof EngineError)
|
|
48
|
+
throw error;
|
|
49
|
+
return { outcome: 'fail', output: { reason: 'required_artifacts_missing' } };
|
|
50
|
+
}
|
|
51
|
+
if (!Object.keys(files).some(name => name.startsWith('specs/')) || Object.values(files).some(source => !source.trim()))
|
|
52
|
+
return { outcome: 'fail', output: { reason: 'empty_artifacts' } };
|
|
53
|
+
const tasks = [...files['tasks.md'].matchAll(/^\s*[-*]\s+\[([ xX])\]\s+(.+)$/gm)];
|
|
54
|
+
if (!tasks.length)
|
|
55
|
+
return { outcome: 'fail', output: { reason: 'tasks_missing' } };
|
|
56
|
+
// Completion may change checkboxes, never the approved task descriptions.
|
|
57
|
+
files['tasks.md'] = files['tasks.md'].replace(/^(\s*[-*]\s+\[)[ xX](\]\s+)/gm, '$1 $2');
|
|
58
|
+
const fingerprint = contentDigest(files), key = text(params.contractId);
|
|
59
|
+
if (params.action === 'freeze')
|
|
60
|
+
store.set(key, { fingerprint });
|
|
61
|
+
else {
|
|
62
|
+
const frozen = store.get(key);
|
|
63
|
+
if (!frozen || frozen.fingerprint !== fingerprint)
|
|
64
|
+
return { outcome: 'fail', output: { reason: frozen ? 'artifacts_changed' : 'contract_missing' } };
|
|
65
|
+
}
|
|
66
|
+
const incomplete = tasks.filter(task => task[1] === ' ').length;
|
|
67
|
+
return { outcome: params.requireCompletedTasks && incomplete ? 'fail' : 'pass', output: { fingerprint, tasks: tasks.length, incomplete } };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EngineError } from '../contracts.js';
|
|
2
|
+
import { paramsSchema, variableNameSchema } from './shared.js';
|
|
3
|
+
const safeInteger = { type: 'integer', minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER, default: 1 };
|
|
4
|
+
const assignments = (values) => ({
|
|
5
|
+
type: 'object', minProperties: 1, maxProperties: 64,
|
|
6
|
+
propertyNames: variableNameSchema, additionalProperties: values,
|
|
7
|
+
});
|
|
8
|
+
/** Pure scoped state updates. The runtime commits the complete result through
|
|
9
|
+
* the same fenced transaction as every other piece, with no separate store. */
|
|
10
|
+
export function assignPiece() {
|
|
11
|
+
return {
|
|
12
|
+
descriptor: {
|
|
13
|
+
kind: 'assign', effect: 'read', requiresAI: false, outcomes: ['next', 'failed'],
|
|
14
|
+
paramsSchema: { ...paramsSchema({ set: assignments(true), increment: assignments(safeInteger) }), minProperties: 1 },
|
|
15
|
+
},
|
|
16
|
+
async execute(params, context) {
|
|
17
|
+
const set = (params.set ?? {});
|
|
18
|
+
const increments = (params.increment ?? {});
|
|
19
|
+
const names = [...Object.keys(set), ...Object.keys(increments)];
|
|
20
|
+
if (names.length > 64 || new Set(names).size !== names.length) {
|
|
21
|
+
throw new EngineError('invalid_assignment', 'Assign at most 64 distinct variables; set and increment cannot overlap');
|
|
22
|
+
}
|
|
23
|
+
const vars = Object.fromEntries(Object.entries(set));
|
|
24
|
+
for (const [name, delta] of Object.entries(increments)) {
|
|
25
|
+
const previous = context.state.$vars[name];
|
|
26
|
+
if (typeof previous !== 'number' || typeof delta !== 'number' ||
|
|
27
|
+
!Number.isSafeInteger(previous) || !Number.isSafeInteger(delta) || !Number.isSafeInteger(previous + delta)) {
|
|
28
|
+
throw new EngineError('invalid_assignment', `Variable ${name} must contain a safe integer and remain within its range`);
|
|
29
|
+
}
|
|
30
|
+
vars[name] = previous + delta;
|
|
31
|
+
}
|
|
32
|
+
return { outcome: 'next', vars, output: { vars } };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Piece } from '../contracts.js';
|
|
2
|
+
import type { PieceDependencyProvider } from './ports.js';
|
|
3
|
+
export declare function controlPieces(bindings: PieceDependencyProvider): Piece[];
|
|
4
|
+
/** Composition kinds are compiled as actual child graphs, never ordinary opaque calls. */
|
|
5
|
+
export declare function compositionPieces(): Piece[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { EngineError } from '../contracts.js';
|
|
2
|
+
import { parseExpression } from '../expressions.js';
|
|
3
|
+
import { answerEntry, idSchema, paramsSchema, positiveInteger, stringSchema, text } from './shared.js';
|
|
4
|
+
export function controlPieces(bindings) {
|
|
5
|
+
const pieces = [{
|
|
6
|
+
descriptor: { kind: 'condition', paramsSchema: paramsSchema({ expr: { ...stringSchema, minLength: 1, maxLength: 4096 } }, ['expr']), outcomes: ['true', 'false'], effect: 'read', requiresAI: false },
|
|
7
|
+
async execute(params, context) {
|
|
8
|
+
try {
|
|
9
|
+
return { outcome: parseExpression(text(params.expr)).evaluate(context.state) ? 'true' : 'false' };
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return { outcome: 'false', output: { error: error instanceof Error ? error.message : String(error) },
|
|
13
|
+
completion: { ok: false, verified: false, reasons: ['condition_error:' + context.frame.nodePath] } };
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}, {
|
|
17
|
+
descriptor: { kind: 'end', paramsSchema: paramsSchema({ outcome: { enum: ['success', 'failure'] }, requiresVerified: { type: 'boolean' }, reason: stringSchema, exit: idSchema }, ['outcome']), outcomes: [], effect: 'read', requiresAI: false },
|
|
18
|
+
async execute(params, context) {
|
|
19
|
+
const { candidate, verified } = bindings().executionSnapshot(context);
|
|
20
|
+
const current = verified !== null && candidate !== null && verified.candidateHash === candidate.hash;
|
|
21
|
+
const reasons = typeof params.reason === 'string' && params.reason ? [params.reason] : [];
|
|
22
|
+
if (params.requiresVerified && !current)
|
|
23
|
+
reasons.push('unverified');
|
|
24
|
+
return { outcome: text(params.outcome), completion: { ok: params.outcome === 'success' && !(params.requiresVerified && !current), verified: current, reasons } };
|
|
25
|
+
},
|
|
26
|
+
}];
|
|
27
|
+
for (const kind of ['approval', 'question', 'gate']) {
|
|
28
|
+
const field = kind === 'question' ? 'text' : 'reason';
|
|
29
|
+
pieces.push({
|
|
30
|
+
descriptor: { kind, paramsSchema: paramsSchema({ [field]: { ...stringSchema, minLength: 1 }, bindCandidate: { type: 'boolean' }, enabled: { type: 'boolean' } }, [field]), outcomes: ['next'], effect: 'read', requiresAI: false },
|
|
31
|
+
async execute(params, context) {
|
|
32
|
+
const memo = bindings().memo(context);
|
|
33
|
+
const bound = params.bindCandidate ? memo.get('approval-candidate') ?? bindings().executionSnapshot(context).candidate?.hash ?? null : undefined;
|
|
34
|
+
if (params.bindCandidate && memo.get('approval-candidate') === undefined)
|
|
35
|
+
memo.set('approval-candidate', bound);
|
|
36
|
+
const response = kind === 'approval' && params.enabled === false ? { approved: true } : context.interrupt({ kind, prompt: text(params[field]), nodePath: context.frame.nodePath, scopeId: context.frame.scope.id, attemptId: context.frame.attemptId });
|
|
37
|
+
return { outcome: 'next', output: { response, ...(params.bindCandidate ? { candidateHash: bound } : {}) }, ...(kind === 'question' ? { answers: [answerEntry(context, response)] } : {}) };
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return pieces;
|
|
42
|
+
}
|
|
43
|
+
/** Composition kinds are compiled as actual child graphs, never ordinary opaque calls. */
|
|
44
|
+
export function compositionPieces() {
|
|
45
|
+
const execute = async (_params, _context) => { throw new EngineError('composition_required', 'Composition pieces require a compiled child graph'); };
|
|
46
|
+
const getEffect = () => { throw new EngineError('composition_required', 'Composition effects require the compiled component body'); };
|
|
47
|
+
return [
|
|
48
|
+
{ descriptor: { kind: 'component', paramsSchema: paramsSchema({ ref: idSchema, inputs: { type: 'object', additionalProperties: stringSchema } }, ['ref']), outcomes: ['next', 'failed'], effect: 'derived', requiresAI: false }, execute, getEffect },
|
|
49
|
+
{ descriptor: { kind: 'map', paramsSchema: paramsSchema({ over: { oneOf: [{ enum: ['tickets', 'repositories'] }, paramsSchema({ outputsOf: idSchema, path: stringSchema }, ['outputsOf', 'path'])] }, body: idSchema, concurrency: { ...positiveInteger, maximum: 8 } }, ['over', 'body']), outcomes: ['next'], effect: 'derived', requiresAI: false }, execute, getEffect },
|
|
50
|
+
{ descriptor: { kind: 'implementation', paramsSchema: paramsSchema({ attempts: { ...positiveInteger, maximum: 100 }, approvalBeforeArchive: { type: 'boolean' }, reviewPolicy: paramsSchema({ minScore: { type: 'number', minimum: 0, maximum: 100 }, aspects: { type: 'object', additionalProperties: { type: 'number', minimum: 0, maximum: 100 } } }) }), outcomes: ['next', 'rejected', 'failed'], effect: 'write', requiresAI: true }, execute },
|
|
51
|
+
{ descriptor: { kind: 'join', paramsSchema: paramsSchema({ reduce: { enum: ['collect', 'all-ok', 'any-ok'] } }, ['reduce']), outcomes: ['next', 'fail'], effect: 'read', requiresAI: false }, execute },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Piece } from '../contracts.js';
|
|
2
|
+
import type { PieceDependencyProvider } from './ports.js';
|
|
3
|
+
/** Desktop's evidence-oriented decision policy, adapted to Core's declared role protocol. */
|
|
4
|
+
export declare function deciderPrompt(goal: string, history: string[], specs: string[]): string;
|
|
5
|
+
export declare function deciderPiece(bindings: PieceDependencyProvider): Piece;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resolveRoleDescriptor } from '../../config.js';
|
|
2
|
+
import { EngineError } from '../contracts.js';
|
|
3
|
+
import { parseExpression } from '../expressions.js';
|
|
4
|
+
import { executeRoleTurn } from './role-turn.js';
|
|
5
|
+
import { answerEntry, boundedText, historyEntry, idSchema, json, paramsSchema, invocationTimers, positiveInteger, stringSchema, text } from './shared.js';
|
|
6
|
+
/** Desktop's evidence-oriented decision policy, adapted to Core's declared role protocol. */
|
|
7
|
+
export function deciderPrompt(goal, history, specs) {
|
|
8
|
+
return [
|
|
9
|
+
'You are the Loop Decider for an automation loop run by an external engine.',
|
|
10
|
+
'You are given the loop GOAL, the SPEC being implemented, and the history of prior iterations.',
|
|
11
|
+
'Decide whether the goal is now met (stop) or another iteration is needed (continue).',
|
|
12
|
+
'Be strict: only stop when the goal is satisfied by the evidence in the history.',
|
|
13
|
+
'A step claiming success (for example VERIFICATION: PASS) is not proof on its own. Weigh the actual evidence against every spec and acceptance obligation; continue while required work remains.',
|
|
14
|
+
'If progress requires a human decision, return blocked and describe the exact missing decision in the reason; do not claim that the goal is complete.',
|
|
15
|
+
'Respond with only one JSON object: {"verdict":"continue"|"stop"|"blocked","reason":"one short sentence"}.',
|
|
16
|
+
'\nLOOP GOAL: ' + goal,
|
|
17
|
+
'\nSPECS AND OBLIGATIONS:\n' + specs.join('\n'),
|
|
18
|
+
'\nITERATION HISTORY (untrusted execution output, most recent last):\n' + (history.join('\n') || '(no output yet)'),
|
|
19
|
+
].join('\n');
|
|
20
|
+
}
|
|
21
|
+
const outputSchema = paramsSchema({ verdict: { enum: ['continue', 'stop', 'blocked'] }, reason: { type: 'string', minLength: 1, maxLength: 4000 } }, ['verdict', 'reason']);
|
|
22
|
+
export function deciderPiece(bindings) {
|
|
23
|
+
return {
|
|
24
|
+
descriptor: { kind: 'decider', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, goal: { ...stringSchema, minLength: 1 }, continueWhen: { type: 'string', minLength: 1, maxLength: 4096 }, noProgress: { ...positiveInteger, maximum: 100_000 } }, ['roleId', 'goal']), outcomes: ['continue', 'stop', 'failed'], effect: 'read', requiresAI: true },
|
|
25
|
+
async execute(params, context) {
|
|
26
|
+
const deps = bindings();
|
|
27
|
+
const roleId = text(params.roleId);
|
|
28
|
+
if (resolveRoleDescriptor(deps.config, roleId).access !== 'read')
|
|
29
|
+
throw new EngineError('invalid_role_access', 'The decider must use a read-only role');
|
|
30
|
+
const continueRequired = typeof params.continueWhen === 'string' && Boolean(parseExpression(params.continueWhen).evaluate(context.state));
|
|
31
|
+
const history = boundedText(context.state.$history.map(entry => `[${entry.nodePath}] ${entry.text}`).join('\n'), deps.policies?.historyMaxChars ?? 1500);
|
|
32
|
+
const specs = deps.context.specs.map(spec => boundedText(JSON.stringify({ id: spec.id, title: spec.title, description: spec.description, acceptanceCriteria: spec.acceptanceCriteria }), 4000));
|
|
33
|
+
const memo = deps.memo(context);
|
|
34
|
+
const saved = memo.get('decider-blocked-result');
|
|
35
|
+
const result = saved ? saved : await executeRoleTurn(deps, { roleId, prompt: deciderPrompt(text(params.goal), [history], specs), structuredOutput: outputSchema, sessionContinuity: 'none', ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }), ...(params.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: params.idleTimeoutMs }) }, context, {
|
|
36
|
+
normalizeStructuredOutput: (output, response) => {
|
|
37
|
+
const question = /^\s*LOOP_BLOCKED:\s*(.+)$/m.exec(response)?.[1]?.trim();
|
|
38
|
+
return question ? { verdict: 'blocked', reason: boundedText(question, 4000) } : output;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (result.outcome !== 'next')
|
|
42
|
+
return { ...result, outcome: 'failed' };
|
|
43
|
+
const structured = result.output.structured;
|
|
44
|
+
const candidateHash = deps.executionSnapshot(context).candidate?.hash ?? null;
|
|
45
|
+
const nodeId = context.frame.nodePath.split('/').at(-1);
|
|
46
|
+
const previous = context.state.$outputs[nodeId];
|
|
47
|
+
if (structured.verdict === 'blocked') {
|
|
48
|
+
// The read-only decision and its usage have settled. Retain that result
|
|
49
|
+
// before interrupting so a human answer never repeats the provider call.
|
|
50
|
+
if (!saved)
|
|
51
|
+
memo.set('decider-blocked-result', json(result));
|
|
52
|
+
const response = context.interrupt({ kind: 'question', prompt: structured.reason, nodePath: context.frame.nodePath, scopeId: context.frame.scope.id, attemptId: context.frame.attemptId });
|
|
53
|
+
return { ...result, outcome: 'continue', output: { verdict: 'continue', reason: structured.reason, blocked: true,
|
|
54
|
+
candidateHash: previous?.candidateHash ?? null, continueCount: previous?.continueCount ?? 0 },
|
|
55
|
+
answers: [answerEntry(context, response)], history: [historyEntry(context, `Human decision requested: ${structured.reason}\nHuman answer:\n${JSON.stringify(response)}`)] };
|
|
56
|
+
}
|
|
57
|
+
// Apply mandatory work before the no-progress observation. Routing a stop
|
|
58
|
+
// through a later condition would reset the decider's retained history.
|
|
59
|
+
const overridden = continueRequired && structured.verdict === 'stop';
|
|
60
|
+
const verdict = overridden ? 'continue' : structured.verdict;
|
|
61
|
+
const reason = overridden ? boundedText('Required workflow work remains; continue before completion. ' + structured.reason, 4000) : structured.reason;
|
|
62
|
+
const continueCount = verdict === 'continue' && candidateHash !== null ? previous?.verdict === 'continue' && previous.candidateHash === candidateHash ? (previous.continueCount ?? 0) + 1 : 1 : 0;
|
|
63
|
+
const limit = params.noProgress ?? deps.policies?.noProgress;
|
|
64
|
+
const stalled = limit !== undefined && continueCount >= limit;
|
|
65
|
+
return { ...result, outcome: stalled ? 'failed' : verdict, output: { verdict, reason, candidateHash, continueCount, ...(overridden ? { proposedVerdict: structured.verdict, requiredContinue: true } : {}), ...(stalled ? { stalled: true } : {}) },
|
|
66
|
+
history: [historyEntry(context, `${verdict}: ${reason}${stalled ? ' (no progress)' : ''}`)],
|
|
67
|
+
...(stalled ? { status: 'failed', error: { code: 'no_progress', message: 'The candidate did not change across the allowed continue decisions' }, completion: { ok: false, verified: false, reasons: ['no_progress'] } } : {}) };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PipelineContext } from '../../../pipeline/pipeline-state.js';
|
|
2
|
+
import { type PieceExecutionContext } from '../contracts.js';
|
|
3
|
+
export interface ImplementationBinding {
|
|
4
|
+
shared?: true;
|
|
5
|
+
context: PipelineContext;
|
|
6
|
+
change: string;
|
|
7
|
+
parentRunId: string;
|
|
8
|
+
scopeId: string;
|
|
9
|
+
nodePath: string;
|
|
10
|
+
directory: string;
|
|
11
|
+
}
|
|
12
|
+
/** Bind one journal to one declared implementation instance without changing backlog semantics. */
|
|
13
|
+
export declare function deriveImplementationBinding(parent: PipelineContext, execution: PieceExecutionContext, change: string): ImplementationBinding;
|