specrails-core 6.1.0 → 6.2.1
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/change-scope.js +2 -0
- package/dist/agent-runtime/cli-executor.js +2 -1
- package/dist/agent-runtime/cli.js +1 -1
- package/dist/agent-runtime/engine/checkpoint/ledger.js +2 -2
- package/dist/agent-runtime/engine/cli.js +1 -1
- package/dist/agent-runtime/engine/compiler.js +9 -8
- package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-schema.js +2 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-types.js +2 -2
- package/dist/agent-runtime/engine/definition-validator.js +6 -2
- package/dist/agent-runtime/engine/expressions.d.ts +1 -1
- package/dist/agent-runtime/engine/expressions.js +16 -3
- package/dist/agent-runtime/engine/fork.js +51 -9
- package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
- package/dist/agent-runtime/engine/invocation-context.js +2 -0
- package/dist/agent-runtime/engine/piece-dependencies.d.ts +6 -2
- package/dist/agent-runtime/engine/piece-dependencies.js +54 -7
- package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
- package/dist/agent-runtime/engine/piece-registry.js +1 -1
- 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/control.js +7 -3
- package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +1 -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 +5 -1
- package/dist/agent-runtime/engine/pieces/implementation.js +8 -7
- package/dist/agent-runtime/engine/pieces/index.js +3 -1
- package/dist/agent-runtime/engine/pieces/openspec.js +8 -2
- package/dist/agent-runtime/engine/pieces/ports.d.ts +6 -1
- package/dist/agent-runtime/engine/pieces/prompt.js +2 -2
- package/dist/agent-runtime/engine/pieces/role-turn.js +1 -1
- package/dist/agent-runtime/engine/pieces/verify.js +79 -4
- package/dist/agent-runtime/graph/nodes.js +2 -1
- package/dist/agent-runtime/graph/roles.d.ts +2 -0
- package/dist/agent-runtime/graph/roles.js +15 -2
- package/dist/agent-runtime/graph/state.d.ts +2 -0
- package/dist/agent-runtime/openspec.d.ts +1 -1
- package/dist/agent-runtime/openspec.js +18 -10
- package/dist/agent-runtime/prompts.d.ts +1 -1
- package/dist/agent-runtime/prompts.js +18 -10
- package/dist/agent-runtime/verification-diagnostics.d.ts +4 -0
- package/dist/agent-runtime/verification-diagnostics.js +33 -0
- package/dist/pipeline/pipeline-state.js +2 -0
- package/integration-contract.json +33 -13
- package/package.json +2 -2
- package/schemas/workflow-definition.schema.json +2 -0
|
@@ -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
|
+
}
|
|
@@ -27,10 +27,14 @@ export function controlPieces(bindings) {
|
|
|
27
27
|
for (const kind of ['approval', 'question', 'gate']) {
|
|
28
28
|
const field = kind === 'question' ? 'text' : 'reason';
|
|
29
29
|
pieces.push({
|
|
30
|
-
descriptor: { kind, paramsSchema: paramsSchema({ [field]: { ...stringSchema, minLength: 1 } }, [field]), outcomes: ['next'], effect: 'read', requiresAI: false },
|
|
30
|
+
descriptor: { kind, paramsSchema: paramsSchema({ [field]: { ...stringSchema, minLength: 1 }, bindCandidate: { type: 'boolean' }, enabled: { type: 'boolean' } }, [field]), outcomes: ['next'], effect: 'read', requiresAI: false },
|
|
31
31
|
async execute(params, context) {
|
|
32
|
-
const
|
|
33
|
-
|
|
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)] } : {}) };
|
|
34
38
|
},
|
|
35
39
|
});
|
|
36
40
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PipelineContext } from '../../../pipeline/pipeline-state.js';
|
|
2
2
|
import { type PieceExecutionContext } from '../contracts.js';
|
|
3
3
|
export interface ImplementationBinding {
|
|
4
|
+
shared?: true;
|
|
4
5
|
context: PipelineContext;
|
|
5
6
|
change: string;
|
|
6
7
|
parentRunId: string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Piece } from '../contracts.js';
|
|
2
|
+
import type { PieceDependencyProvider } from './ports.js';
|
|
3
|
+
/** Outcomes are facts, not destinations. Only the host definition chooses the next node. */
|
|
4
|
+
export declare const IMPLEMENTATION_STEP_OUTCOMES: readonly ["next", "incomplete", "rejected", "replan", "reverify", "rereview", "failed"];
|
|
5
|
+
export declare function implementationStepPiece(bindings: PieceDependencyProvider): Piece;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CoreState } from '../../graph/state.js';
|
|
2
|
+
import { EngineError } from '../contracts.js';
|
|
3
|
+
import { createImplementationAdapter } from './implementation.js';
|
|
4
|
+
import { json, paramsSchema } from './shared.js';
|
|
5
|
+
/** Outcomes are facts, not destinations. Only the host definition chooses the next node. */
|
|
6
|
+
export const IMPLEMENTATION_STEP_OUTCOMES = ['next', 'incomplete', 'rejected', 'replan', 'reverify', 'rereview', 'failed'];
|
|
7
|
+
export function implementationStepPiece(bindings) {
|
|
8
|
+
return {
|
|
9
|
+
descriptor: { kind: 'implementation-step', paramsSchema: paramsSchema({
|
|
10
|
+
phase: { enum: ['architect', 'developer', 'fixer', 'verify', 'reviewer', 'archive'] },
|
|
11
|
+
}, ['phase']), outcomes: [...IMPLEMENTATION_STEP_OUTCOMES], effect: 'write', requiresAI: true },
|
|
12
|
+
async execute(params, context) {
|
|
13
|
+
const deps = bindings(), phase = params.phase;
|
|
14
|
+
const change = deps.change ?? context.state.$vars.changeId;
|
|
15
|
+
if (typeof change !== 'string')
|
|
16
|
+
throw new EngineError('invalid_arguments', 'Implementation operation requires a frozen change');
|
|
17
|
+
const saved = deps.operationState?.(context);
|
|
18
|
+
const resumed = [...deps.stepContext(context).checkpoint.events].reverse().find(event => event.type === 'workflow_resumed')?.timestamp ?? '';
|
|
19
|
+
const adapter = createImplementationAdapter(deps, { change, params: {}, independent: true, revalidate: saved?.operationResume !== resumed, archiveConsent: saved?.operationRunId === deps.context.runId && typeof saved.operationConsent === 'string' ? saved.operationConsent : undefined });
|
|
20
|
+
const initialized = await adapter.initialize(context);
|
|
21
|
+
const initial = { plan: [], unverifiedRepositories: [], architecture: null, development: null,
|
|
22
|
+
verifyResult: null, review: null, answers: [], deepenPasses: 0, archived: null, verifyHistory: [] };
|
|
23
|
+
const state = { ...initial, ...saved?.operationState,
|
|
24
|
+
...(saved?.operationRunId === deps.context.runId ? {} : initialized) };
|
|
25
|
+
const response = await adapter.runNode(phase, state, context);
|
|
26
|
+
const updated = { ...state };
|
|
27
|
+
for (const [key, value] of Object.entries(response.update ?? {})) {
|
|
28
|
+
const channel = CoreState.spec[key];
|
|
29
|
+
updated[key] = channel.operator ? channel.operator(state[key], value) : value;
|
|
30
|
+
}
|
|
31
|
+
const normal = { architect: 'developer', developer: 'verify', fixer: 'verify', verify: 'reviewer', reviewer: 'archive', archive: null }[phase];
|
|
32
|
+
const next = response.next === undefined ? normal : response.next;
|
|
33
|
+
const outcome = response.status !== 'succeeded' ? 'failed' : next === normal ? 'next'
|
|
34
|
+
: next === 'architect' ? 'replan' : next === 'verify' ? 'reverify' : next === 'reviewer' ? 'rereview'
|
|
35
|
+
: next === 'developer' ? 'incomplete' : next === 'fixer' ? 'rejected' : 'failed';
|
|
36
|
+
// The summary reads host verification receipts and acceptance, never an agent's claim.
|
|
37
|
+
const evidence = await adapter.summarize(updated, context);
|
|
38
|
+
return { outcome, status: response.status,
|
|
39
|
+
...(response.output === undefined ? {} : { output: json({ ...((response.output && typeof response.output === 'object' && !Array.isArray(response.output)) ? response.output : { value: response.output }), phase }) }),
|
|
40
|
+
...(response.error ? { error: { code: response.status === 'blocked' ? 'implementation_blocked' : 'implementation_failed', message: response.error } } : {}),
|
|
41
|
+
childUpdate: { operationState: json(updated), operationRunId: deps.context.runId, operationResume: resumed, operationConsent: adapter.consentSnapshot?.(context) ?? null, journal: json(adapter.snapshot(context)) },
|
|
42
|
+
...(evidence.receipt ? { receipt: evidence.receipt, verified: phase === 'archive' && outcome === 'next' ? evidence.verified : null } : {}),
|
|
43
|
+
...(phase === 'archive' && outcome === 'next' ? { completion: evidence.completion } : {}),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -11,13 +11,17 @@ export interface ImplementationAdapter {
|
|
|
11
11
|
initialize(context: PieceExecutionContext): Promise<CoreStateUpdate>;
|
|
12
12
|
runNode(id: CoreNodeId, state: CoreStateType, context: PieceExecutionContext): Promise<NodeResult<CoreStateType>>;
|
|
13
13
|
snapshot(context: PieceExecutionContext): ImplementationJournalSnapshot;
|
|
14
|
+
consentSnapshot?(context: PieceExecutionContext): string | undefined;
|
|
14
15
|
summarize(state: CoreStateType, context: PieceExecutionContext): Promise<PieceResult>;
|
|
15
16
|
validateCompleted(id: CoreNodeId, record: StepRecord, checkpoint: WorkflowState, context: PieceExecutionContext): Promise<boolean>;
|
|
16
17
|
}
|
|
17
18
|
/** Scope the legacy convergence/attempt view without changing durable event attribution. */
|
|
18
|
-
export declare function implementationStepContext(deps: PieceDependencies, context: PieceExecutionContext, id: CoreNodeId): WorkflowStepContext;
|
|
19
|
+
export declare function implementationStepContext(deps: PieceDependencies, context: PieceExecutionContext, id: CoreNodeId, independent?: boolean): WorkflowStepContext;
|
|
19
20
|
/** Reuse every legacy node and quality gate; this factory never runs the legacy JSON workflow host. */
|
|
20
21
|
export declare function createImplementationAdapter(deps: PieceDependencies, input: {
|
|
21
22
|
change: string;
|
|
22
23
|
params: JsonObject;
|
|
24
|
+
independent?: boolean;
|
|
25
|
+
revalidate?: boolean;
|
|
26
|
+
archiveConsent?: string;
|
|
23
27
|
}): ImplementationAdapter;
|
|
@@ -15,9 +15,9 @@ import { json } from './shared.js';
|
|
|
15
15
|
import { receiptEvidence } from './verify.js';
|
|
16
16
|
import { captureImplementationJournal } from './implementation-journal.js';
|
|
17
17
|
/** Scope the legacy convergence/attempt view without changing durable event attribution. */
|
|
18
|
-
export function implementationStepContext(deps, context, id) {
|
|
18
|
+
export function implementationStepContext(deps, context, id, independent = false) {
|
|
19
19
|
const original = deps.stepContext(context);
|
|
20
|
-
const prefix = context.frame.nodePath.slice(0, -id.length);
|
|
20
|
+
const prefix = independent ? '' : context.frame.nodePath.slice(0, -id.length);
|
|
21
21
|
const local = (value) => value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
22
22
|
const belongs = (value) => CORE_NODE_ORDER.includes(value) || (value.startsWith(prefix) && !value.slice(prefix.length).includes('/'));
|
|
23
23
|
const checkpoint = original.checkpoint;
|
|
@@ -46,7 +46,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
46
46
|
...(params.reviewPolicy ? { review: params.reviewPolicy } : {}) };
|
|
47
47
|
const attempts = params.attempts ?? config.limits?.maxAttempts ?? 3;
|
|
48
48
|
const bindings = new Map();
|
|
49
|
-
const instanceKey = (context) => context.frame.scope.id + '/' + context.frame.visitId;
|
|
49
|
+
const instanceKey = (context) => input.independent ? context.frame.scope.id : context.frame.scope.id + '/' + context.frame.visitId;
|
|
50
50
|
const metadata = { context: deps.context, config, change, attempts, policy: resolveReviewPolicy(config), openspec: {}, focusedCorrectionEvidence: true,
|
|
51
51
|
invoke: (() => { throw new EngineError('implementation_not_initialized', 'Implementation metadata nodes must run through the durable adapter'); }), note() { } };
|
|
52
52
|
const refresh = (binding, context) => {
|
|
@@ -64,7 +64,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
64
64
|
schema: CoreState, nodes: coreNodes(metadata), entry: 'architect',
|
|
65
65
|
async initialize(context) {
|
|
66
66
|
context.signal.throwIfAborted();
|
|
67
|
-
const binding = deps.bindImplementation(context, change);
|
|
67
|
+
const binding = deps.bindImplementation(context, change, input.independent);
|
|
68
68
|
const pipeline = binding.context;
|
|
69
69
|
const state = initializePipeline(pipeline, binding.change);
|
|
70
70
|
refresh(binding, context);
|
|
@@ -81,7 +81,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
81
81
|
}));
|
|
82
82
|
for (const role of Object.keys(openspec))
|
|
83
83
|
await deps.registry.get(resolveRoleDescriptor(config, role).provider).validateOpenSpec?.(openspec[role]);
|
|
84
|
-
bindings.set(instanceKey(context), { binding, openspec, resumeChecked: false });
|
|
84
|
+
bindings.set(instanceKey(context), { binding, openspec, resumeChecked: input.independent === true && input.revalidate === false, archiveConsent: input.archiveConsent });
|
|
85
85
|
if (existsSync(path.join(directory, 'implementation-fork.json'))) {
|
|
86
86
|
const plan = readVerificationPlan(pipeline);
|
|
87
87
|
return { ...(plan ? { plan: expandedPlanCommands(pipeline, plan) } : {}), verifyResult: null, review: null, archived: null, verifyHistory: [] };
|
|
@@ -94,7 +94,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
94
94
|
throw new EngineError('implementation_not_initialized', 'Initialize this implementation scope before executing its child graph');
|
|
95
95
|
const { binding, openspec } = prepared;
|
|
96
96
|
refresh(binding, context);
|
|
97
|
-
const step = implementationStepContext(deps, context, id);
|
|
97
|
+
const step = implementationStepContext(deps, context, id, input.independent);
|
|
98
98
|
if (!prepared.resumeChecked && (step.pending || step.checkpoint.events.some(event => event.type === 'workflow_resumed'))) {
|
|
99
99
|
// Collect archive consent first, then revalidate completed verification
|
|
100
100
|
// and review exactly as the legacy host does on explicit resume.
|
|
@@ -120,7 +120,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
const invoke = createRoleInvoker({ context: binding.context, config, registry: deps.registry, openspec, roleState: deps.roleState(context),
|
|
123
|
+
const invoke = createRoleInvoker({ context: binding.context, config, registry: deps.registry, openspec, roleState: deps.roleState(context), propagateInterruptions: input.independent,
|
|
124
124
|
onAgentEvent: (role, event) => context.progress({ type: 'agent-event', payload: json({ role, event }) }) });
|
|
125
125
|
const localConfig = { ...config, verification: config.verification.filter(command => binding.context.repositories.some(repository => repository.id === command.repositoryId)) };
|
|
126
126
|
const nodes = coreNodes({ ...metadata, context: binding.context, config: localConfig, change: binding.change, openspec, invoke,
|
|
@@ -130,6 +130,7 @@ export function createImplementationAdapter(deps, input) {
|
|
|
130
130
|
});
|
|
131
131
|
return nodes[id].run(state, step);
|
|
132
132
|
},
|
|
133
|
+
consentSnapshot(context) { return bindings.get(instanceKey(context))?.archiveConsent; },
|
|
133
134
|
snapshot(context) {
|
|
134
135
|
const prepared = bindings.get(context.frame.scope.id) ?? bindings.get(instanceKey(context));
|
|
135
136
|
if (!prepared)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { artifactContractPiece } from './artifact-contract.js';
|
|
1
2
|
import { PieceRegistry } from '../piece-registry.js';
|
|
2
3
|
import { controlPieces, compositionPieces } from './control.js';
|
|
3
4
|
import { deciderPiece } from './decider.js';
|
|
@@ -7,6 +8,7 @@ import { promptPiece } from './prompt.js';
|
|
|
7
8
|
import { roleTurnPiece } from './role-turn.js';
|
|
8
9
|
import { shellPiece } from './shell.js';
|
|
9
10
|
import { verifyPiece } from './verify.js';
|
|
11
|
+
import { implementationStepPiece } from './implementation-step.js';
|
|
10
12
|
import { assignPiece } from './assign.js';
|
|
11
13
|
/** The composition root binds effects once; definitions can only select this reviewed catalog. */
|
|
12
14
|
export function createPieceRegistry(deps) {
|
|
@@ -18,5 +20,5 @@ export function validationPieceRegistry() {
|
|
|
18
20
|
}
|
|
19
21
|
function registry(bindings) {
|
|
20
22
|
return new PieceRegistry([promptPiece(bindings), roleTurnPiece(bindings), deciderPiece(bindings), verifyPiece(bindings), shellPiece(bindings),
|
|
21
|
-
...openSpecPieces(bindings), ...controlPieces(bindings), assignPiece(), ...compositionPieces()]);
|
|
23
|
+
artifactContractPiece(bindings), ...openSpecPieces(bindings), ...controlPieces(bindings), assignPiece(), implementationStepPiece(bindings), ...compositionPieces()]);
|
|
22
24
|
}
|
|
@@ -42,12 +42,18 @@ export function openSpecPieces(bindings) {
|
|
|
42
42
|
return { outcome: exited && item.valid ? 'pass' : 'fail', output: json(report) };
|
|
43
43
|
},
|
|
44
44
|
}, {
|
|
45
|
-
descriptor: { kind: 'openspec-archive', paramsSchema: schema, outcomes: ['next', 'failed'], effect: 'write', requiresAI: false },
|
|
45
|
+
descriptor: { kind: 'openspec-archive', paramsSchema: { ...schema, properties: { ...schema.properties, requiresVerified: { type: 'boolean' }, reviewedCandidate: { type: 'string' }, approvedCandidate: { type: 'string' } } }, outcomes: ['next', 'failed'], effect: 'write', requiresAI: false },
|
|
46
46
|
async execute(params, context) {
|
|
47
47
|
const deps = bindings();
|
|
48
48
|
if (params.repositoryId !== undefined && params.repositoryId !== deps.context.artifactRepositoryId)
|
|
49
49
|
throw new EngineError('artifact_scope_mismatch', 'OpenSpec repository must match the frozen artifact repository');
|
|
50
|
-
const
|
|
50
|
+
const snapshot = deps.executionSnapshot(context);
|
|
51
|
+
const verified = snapshot.verified ?? deps.verifiedBeforeWrite?.(context);
|
|
52
|
+
if (params.requiresVerified && (!snapshot.candidate || !verified || verified.candidateHash !== snapshot.candidate.hash))
|
|
53
|
+
return { outcome: 'failed', output: { reason: 'unverified' } };
|
|
54
|
+
if ([params.reviewedCandidate, params.approvedCandidate].some(hash => hash !== undefined && hash !== snapshot.candidate?.hash))
|
|
55
|
+
return { outcome: 'failed', output: { reason: 'candidate_changed' } };
|
|
56
|
+
const change = changeId(text(params.change)), initial = snapshot.candidate?.hash;
|
|
51
57
|
const archived = params.allowArchived === true ? archivedOpenSpecChange(deps.context, change) : undefined;
|
|
52
58
|
if (archived)
|
|
53
59
|
return { outcome: 'next', output: { change, archived, skipped: true } };
|
|
@@ -15,15 +15,18 @@ export interface PieceStatePort {
|
|
|
15
15
|
}
|
|
16
16
|
export interface PieceDependencies {
|
|
17
17
|
context: PipelineContext;
|
|
18
|
+
readonly change?: string;
|
|
18
19
|
config: RuntimeConfig;
|
|
19
20
|
registry: ExecutorRegistry;
|
|
20
21
|
stepContext(context: PieceExecutionContext): WorkflowStepContext;
|
|
21
22
|
roleState(context: PieceExecutionContext): RoleStatePort;
|
|
23
|
+
artifactContracts?(context: PieceExecutionContext): PieceStatePort;
|
|
22
24
|
memo(context: PieceExecutionContext): PieceStatePort;
|
|
23
25
|
/** Narrow project memory is restricted by the declared piece and namespace capability. */
|
|
24
26
|
memory(context: PieceExecutionContext): ProjectMemory;
|
|
25
27
|
/** Provider settlement and the response needed for interrupt replay share one transaction. */
|
|
26
28
|
settleResult(context: PieceExecutionContext, key: string, value: JsonValue, invocation: ProviderInvocation): void;
|
|
29
|
+
verifiedBeforeWrite?(context: PieceExecutionContext): VerifiedState | null;
|
|
27
30
|
executionSnapshot(context: PieceExecutionContext): {
|
|
28
31
|
candidate: CandidateState | null;
|
|
29
32
|
verified: VerifiedState | null;
|
|
@@ -32,13 +35,15 @@ export interface PieceDependencies {
|
|
|
32
35
|
/** A scoped adapter directory for recoverable OpenSpec write sets, never state.json. */
|
|
33
36
|
artifactDirectory(context: PieceExecutionContext): string;
|
|
34
37
|
/** Registers parent-owned scoped journals for candidate inspection, recovery and fork. */
|
|
35
|
-
bindImplementation(context: PieceExecutionContext, change: string): ImplementationBinding;
|
|
38
|
+
bindImplementation(context: PieceExecutionContext, change: string, shared?: boolean): ImplementationBinding;
|
|
36
39
|
/** Current union of declared implementation artifacts and journals, including the parent run. */
|
|
37
40
|
implementationExclusions?(context: PieceExecutionContext): {
|
|
38
41
|
runtimeExclusions: readonly string[];
|
|
39
42
|
repositoryExclusions: Readonly<Record<string, readonly string[]>>;
|
|
40
43
|
};
|
|
41
44
|
openspec?(context: PieceExecutionContext): Record<string, OpenSpecRoleContext>;
|
|
45
|
+
/** Committed private operation state, inaccessible to workflow variables or fork patches. */
|
|
46
|
+
operationState?(context: PieceExecutionContext): JsonValue | undefined;
|
|
42
47
|
policies?: {
|
|
43
48
|
historyMaxChars?: number;
|
|
44
49
|
noProgress?: number;
|
|
@@ -5,7 +5,7 @@ import { contentDigest } from '../canonical-json.js';
|
|
|
5
5
|
import { EngineError } from '../contracts.js';
|
|
6
6
|
import { answerEntry, boundedText, captures, captureSchema, historyEntry, json, paramsSchema, invocationTimers, stringSchema, text } from './shared.js';
|
|
7
7
|
const sessionFailures = new Set(['session_not_found', 'session_expired', 'session_unsupported']);
|
|
8
|
-
const engineSchema = paramsSchema({ provider: { type: 'string', minLength: 1, maxLength: 128 }, model: { type: 'string', minLength: 1, maxLength: 256 }, effort: { type: 'string', minLength: 1, maxLength: 64 }, thinking: { enum: ['on', 'off'] } }, ['provider']);
|
|
8
|
+
const engineSchema = paramsSchema({ provider: { type: 'string', minLength: 1, maxLength: 128 }, model: { type: 'string', minLength: 1, maxLength: 256 }, effort: { type: 'string', minLength: 1, maxLength: 64 }, thinking: { enum: ['on', 'off'] }, maxTurns: { type: 'integer', minimum: 1 } }, ['provider']);
|
|
9
9
|
const promptSchema = {
|
|
10
10
|
...paramsSchema({ engine: engineSchema, text: { ...stringSchema, minLength: 1 }, nativeCommand: paramsSchema({ id: { type: 'string', pattern: '^[a-z][a-z0-9:_-]{0,63}$' }, args: stringSchema }, ['id']),
|
|
11
11
|
access: { enum: ['read', 'write'] }, sentinel: { enum: ['verification', 'blocked', 'none'] }, captureVars: captureSchema,
|
|
@@ -58,7 +58,7 @@ export function promptPiece(bindings) {
|
|
|
58
58
|
prompt: native ? '' : text(params.text) + suffix,
|
|
59
59
|
...(native ? { nativeCommand: { id: native.id, args: (native.args ?? '') + suffix } } : {}),
|
|
60
60
|
cwd: deps.context.artifactRoot, allowedRoots: deps.context.repositories.map(repo => repo.path),
|
|
61
|
-
model: engine.model, effort: engine.effort, thinking: engine.thinking,
|
|
61
|
+
model: engine.model, effort: engine.effort, thinking: engine.thinking, maxTurns: engine.maxTurns,
|
|
62
62
|
timeoutMs: params.timeoutMs ?? deps.config.limits?.timeoutMs,
|
|
63
63
|
idleTimeoutMs: params.idleTimeoutMs ?? deps.config.limits?.idleTimeoutMs,
|
|
64
64
|
signal: context.signal, resumeSessionId: sessionId,
|
|
@@ -68,6 +68,6 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
68
68
|
await memory.put(['review', 'notes'], finalKey, { roleId, candidateHash: before?.hash ?? null,
|
|
69
69
|
text: boundedText(result.text, 4000), lastAttemptId: context.frame.attemptId });
|
|
70
70
|
});
|
|
71
|
-
return { outcome: 'next', output: { text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
|
|
71
|
+
return { outcome: 'next', output: { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
|
|
72
72
|
history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
|
|
73
73
|
}
|
|
@@ -1,8 +1,59 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
1
2
|
import { contentDigest } from '../canonical-json.js';
|
|
2
3
|
import { advisoryMemory } from './project-memory.js';
|
|
3
4
|
import { executeVerification, validateVerificationRequest } from '../../../pipeline/pipeline-state.js';
|
|
4
5
|
import { withScopeDefault } from '../../change-scope.js';
|
|
5
|
-
import {
|
|
6
|
+
import { verificationFailureSummary } from '../../verification-diagnostics.js';
|
|
7
|
+
import { boundedText, json, paramsSchema, positiveInteger } from './shared.js';
|
|
8
|
+
/** Keep real subprocess diagnostics in committed outputs for subsequent agents.
|
|
9
|
+
* The full command evidence remains in the receipt; prioritize failed checks
|
|
10
|
+
* when the output budget is exhausted. */
|
|
11
|
+
function verificationDiagnostics(receipt) {
|
|
12
|
+
let remaining = 32_000;
|
|
13
|
+
const outputs = new Map();
|
|
14
|
+
const priority = receipt.commands.map((command, index) => ({ command, index }))
|
|
15
|
+
.sort((a, b) => Number(b.command.exitCode !== 0) - Number(a.command.exitCode !== 0));
|
|
16
|
+
for (const { command, index } of priority) {
|
|
17
|
+
const limit = Math.min(8_000, remaining);
|
|
18
|
+
const source = stripVTControlCharacters([command.stdout ?? '', command.stderr ?? '', command.output].join('\n'));
|
|
19
|
+
const lines = source.split('\n');
|
|
20
|
+
const failures = [];
|
|
21
|
+
let used = 0;
|
|
22
|
+
for (let i = 0; command.exitCode !== 0 && i < lines.length && used < 6_000; i++) {
|
|
23
|
+
if (!/^\s*(?:not ok\b|[✖✗]\s|test at\b|FAIL\b|Error:|AssertionError\b|error:)/.test(lines[i]))
|
|
24
|
+
continue;
|
|
25
|
+
const excerpt = lines.slice(Math.max(0, i - 2), i + 28).join('\n').slice(0, 6_000 - used);
|
|
26
|
+
failures.push(excerpt);
|
|
27
|
+
used += excerpt.length;
|
|
28
|
+
i += 27;
|
|
29
|
+
}
|
|
30
|
+
const failureText = failures.join('\n').slice(0, Math.min(6_000, limit));
|
|
31
|
+
const tailBudget = Math.max(0, limit - failureText.length - (failureText ? 1 : 0));
|
|
32
|
+
const output = failureText + (failureText && tailBudget ? '\n' : '') + (tailBudget ? command.output.slice(-tailBudget) : '');
|
|
33
|
+
// slice(-0) would include all the original text.
|
|
34
|
+
outputs.set(index, remaining > 0 ? output : '');
|
|
35
|
+
remaining -= remaining > 0 ? output.length : 0;
|
|
36
|
+
}
|
|
37
|
+
let budget = 64_000;
|
|
38
|
+
const commands = [];
|
|
39
|
+
for (const { index, command } of priority) {
|
|
40
|
+
const output = outputs.get(index) ?? '';
|
|
41
|
+
const args = command.args.slice(0, 16).map(arg => boundedText(arg, 128));
|
|
42
|
+
const diagnostic = { repositoryId: command.repositoryId, command: boundedText(command.command, 512),
|
|
43
|
+
args, cwd: boundedText(command.cwd, 512),
|
|
44
|
+
exitCode: command.exitCode, durationMs: command.durationMs, output,
|
|
45
|
+
...(command.evidenceId ? { evidenceId: boundedText(command.evidenceId, 128) } : {}),
|
|
46
|
+
...(command.exitCode !== 0 ? { failureSummary: verificationFailureSummary(command) } : {}),
|
|
47
|
+
truncated: output.length < command.output.length || command.outputTruncated === true
|
|
48
|
+
|| command.command.length > 512 || command.cwd.length > 512 || command.args.length > 16 || command.args.some(arg => arg.length > 128) };
|
|
49
|
+
const size = JSON.stringify(diagnostic).length;
|
|
50
|
+
if (size > budget)
|
|
51
|
+
break;
|
|
52
|
+
budget -= size;
|
|
53
|
+
commands.push(diagnostic);
|
|
54
|
+
}
|
|
55
|
+
return { commands, ...(commands.length < receipt.commands.length ? { omittedCommands: receipt.commands.length - commands.length } : {}) };
|
|
56
|
+
}
|
|
6
57
|
export const verificationCommandSchema = paramsSchema({
|
|
7
58
|
repositoryId: { type: 'string', minLength: 1, maxLength: 128 }, command: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
8
59
|
args: { type: 'array', maxItems: 1024, items: { type: 'string', maxLength: 32_000 } }, cwd: { type: 'string', maxLength: 4096 },
|
|
@@ -19,10 +70,28 @@ export function verificationDeadline(deps, context) {
|
|
|
19
70
|
export function verifyPiece(bindings) {
|
|
20
71
|
return {
|
|
21
72
|
descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' }, { type: 'array', maxItems: 100, items: verificationCommandSchema }] },
|
|
22
|
-
unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 } }, ['commands']), outcomes: ['pass', 'fail', 'failed'], effect: 'write', requiresAI: false, storeAccess: 'write' },
|
|
73
|
+
additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 } }, ['commands']), outcomes: ['pass', 'fail', 'failed'], effect: 'write', requiresAI: false, storeAccess: 'write' },
|
|
23
74
|
async execute(params, context) {
|
|
24
75
|
const deps = bindings();
|
|
25
76
|
const commands = (params.commands === 'configured' ? deps.config.verification : params.commands).map(command => withScopeDefault(deps.context, command));
|
|
77
|
+
const hostRepositories = new Set(commands.map(command => command.repositoryId));
|
|
78
|
+
if (typeof params.additionalCommandsFrom === 'string') {
|
|
79
|
+
const source = context.state.$outputs[params.additionalCommandsFrom];
|
|
80
|
+
const proposals = source?.structured?.verification;
|
|
81
|
+
if (!Array.isArray(proposals) || proposals.length > 20)
|
|
82
|
+
throw new Error('Verification proposals must be a bounded array from a committed agent output');
|
|
83
|
+
// Host checks always stay mandatory. The same scoped command validation
|
|
84
|
+
// applies to these actual subprocesses and to configured checks below.
|
|
85
|
+
for (const proposal of proposals) {
|
|
86
|
+
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
|
|
87
|
+
throw new Error('Invalid verification proposal');
|
|
88
|
+
const raw = proposal;
|
|
89
|
+
if (!deps.context.repositories.some(repository => repository.id === raw.repositoryId))
|
|
90
|
+
throw new Error('Unknown verification repository');
|
|
91
|
+
if (!hostRepositories.has(raw.repositoryId))
|
|
92
|
+
commands.push(withScopeDefault(deps.context, raw));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
26
95
|
const coversAll = deps.context.repositories.every(repository => commands.some(command => command.repositoryId === repository.id));
|
|
27
96
|
const request = { kind: coversAll || params.unverified === true ? 'full' : 'scoped', commands, ...(params.unverified === true ? { unverified: true } : {}) };
|
|
28
97
|
validateVerificationRequest(deps.context, request);
|
|
@@ -43,9 +112,15 @@ export function verifyPiece(bindings) {
|
|
|
43
112
|
}));
|
|
44
113
|
const infrastructure = receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
|
|
45
114
|
const outcome = infrastructure ? 'failed' : receipt.valid ? 'pass' : 'fail';
|
|
115
|
+
const progressKey = 'verification-' + contentDigest(context.frame.nodePath).slice(0, 20);
|
|
116
|
+
const fingerprint = contentDigest(json({ candidateHash: identity.candidateHash,
|
|
117
|
+
failures: receipt.commands.filter(command => command.exitCode !== 0).map(command => ({ repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, exitCode: command.exitCode })) }));
|
|
118
|
+
const previous = context.state.$vars[progressKey];
|
|
119
|
+
const count = receipt.valid ? 0 : previous?.fingerprint === fingerprint ? (previous.count ?? 0) + 1 : 1;
|
|
120
|
+
const stalled = !infrastructure && !receipt.valid && count >= 3;
|
|
46
121
|
const verified = receipt.valid && receipt.commands.length > 0 && !receipt.unverifiedRepositories?.length;
|
|
47
|
-
return { outcome, ...(infrastructure ? { status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } } : {}),
|
|
48
|
-
output: { receiptId: receipt.id, valid: receipt.valid, ...(receipt.reason ? { reason: receipt.reason } : {}),
|
|
122
|
+
return { outcome: stalled ? 'failed' : outcome, vars: { [progressKey]: { fingerprint, count } }, ...(stalled ? { status: 'failed', error: { code: 'verification_no_progress', message: 'Verification failed three times on the same unchanged candidate. Stopping automatic corrections; inspect the failed checks.' } } : {}), ...(infrastructure ? { status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } } : {}),
|
|
123
|
+
output: { receiptId: receipt.id, valid: receipt.valid, candidateHash: identity.candidateHash, noProgressCount: count, ...(receipt.reason ? { reason: receipt.reason } : {}), ...(stalled ? { reason: 'Verification failed three times without candidate changes' } : {}), ...verificationDiagnostics(receipt) },
|
|
49
124
|
receipt: receiptEvidence(receipt), ...(verified ? { verified: { receiptId: receipt.id, candidateHash: receipt.candidateHash, atTransition: context.frame.transition, revision: context.frame.transition } } : { verified: null }) };
|
|
50
125
|
},
|
|
51
126
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { guardrailEnabled } from '../guardrails.js';
|
|
2
|
+
import { verificationFailureSummary } from '../verification-diagnostics.js';
|
|
2
3
|
import { addDeveloperChecks, bindPlan, expandedPlanCommands, initializeVerificationPlan, readVerificationPlan, validateProposedChecks } from '../verification-plan.js';
|
|
3
4
|
import { existsSync } from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -410,7 +411,7 @@ function verifyNode(deps) {
|
|
|
410
411
|
}
|
|
411
412
|
const evidence = {
|
|
412
413
|
valid: receipt.valid, ...(receipt.reason ? { reason: receipt.reason } : {}), receiptId: receipt.id, unverifiedRepositories: uncovered,
|
|
413
|
-
commands: receipt.commands.map(
|
|
414
|
+
commands: receipt.commands.map(command => ({ ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}), repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, exitCode: command.exitCode, failureSummary: verificationFailureSummary(command), output: command.output.slice(-2000) })),
|
|
414
415
|
};
|
|
415
416
|
const outcome = (passed, finding) => ({ at: new Date().toISOString(), candidateHash: receipt.candidateHash, planHash: effective.planHash, passed, ...(passed ? {} : describeFailure(receipt, roots, finding)) });
|
|
416
417
|
/** Hands a failure to the fixer unless the correction loop has stopped converging. */
|
|
@@ -42,6 +42,8 @@ export interface RoleInvokerDeps {
|
|
|
42
42
|
openspec?: Record<AgentRole, import('../openspec.js').OpenSpecRoleContext>;
|
|
43
43
|
/** Engine hosts isolate sessions/routes by scope and node instead of a role-global file. */
|
|
44
44
|
roleState?: RoleStatePort;
|
|
45
|
+
/** Durable independent operations must recover uncertain effects explicitly. */
|
|
46
|
+
propagateInterruptions?: boolean;
|
|
45
47
|
/** `fixer` labels the developer step's events during a correction round on the fixer stance, so a log reader sees who is acting. */
|
|
46
48
|
onAgentEvent?: (role: AgentEventRole, event: AgentEvent) => void;
|
|
47
49
|
}
|
|
@@ -126,7 +126,7 @@ export function createRoleInvoker(deps) {
|
|
|
126
126
|
return async (role, step, options, accept) => {
|
|
127
127
|
const timers = { timeoutMs: options.timeoutMs, idleTimeoutMs: options.idleTimeoutMs };
|
|
128
128
|
const structured = options.structured === true;
|
|
129
|
-
const workflow = deps.openspec?.[role] ? new OpenSpecTools(deps.openspec[role]) : undefined;
|
|
129
|
+
const workflow = deps.openspec?.[role] ? new OpenSpecTools(deps.openspec[role], step.signal) : undefined;
|
|
130
130
|
const cursor = workflow?.participationCursor() ?? 0;
|
|
131
131
|
const evaluate = (result) => {
|
|
132
132
|
let output, parseError;
|
|
@@ -147,6 +147,17 @@ export function createRoleInvoker(deps) {
|
|
|
147
147
|
return { ok: true, value: accept(output, result.text, result), text: result.text, result };
|
|
148
148
|
};
|
|
149
149
|
try {
|
|
150
|
+
const skill = workflow ? openSpecSkill(workflow.context) : undefined;
|
|
151
|
+
if (workflow && (skill === 'openspec-apply-change' || skill === 'openspec-verify-change')) {
|
|
152
|
+
// Apply/verify read-only prerequisites are host-owned, not a probabilistic
|
|
153
|
+
// provider action. Deliver the actual pinned skill and CLI outputs; all
|
|
154
|
+
// artifact, task, verification and review gates still run normally.
|
|
155
|
+
const loaded = await workflow.execute({ action: 'load_skill' }, 'host');
|
|
156
|
+
const procedure = skill === 'openspec-verify-change' ? 'verify' : 'apply';
|
|
157
|
+
const context = `\n## Host-loaded official ${procedure} workflow\nCore executed load_skill, status and instructions apply for this turn. Follow the exact skill and planning context below, read its contextFiles, and carry out the remaining procedure. You may refresh these queries through the scoped tool. A pre-existing unrelated test failure remains a verification failure; report its evidence instead of claiming success.\n` + JSON.stringify(loaded);
|
|
158
|
+
options = { ...options, prompt: options.prompt + context, ...(options.fallbackPrompt ? { fallbackPrompt: options.fallbackPrompt + context } : {}) };
|
|
159
|
+
note(role, `Core loaded the pinned OpenSpec ${procedure} skill and current planning context for this turn.`);
|
|
160
|
+
}
|
|
150
161
|
let result;
|
|
151
162
|
if (options.resumeSessionId && options.fallbackPrompt) {
|
|
152
163
|
// A correction pass continues the role's own session: the work it did and
|
|
@@ -179,7 +190,7 @@ export function createRoleInvoker(deps) {
|
|
|
179
190
|
// complete original role prompt and existing artifacts, never another role.
|
|
180
191
|
const repair = omittedWorkflow ? openSpecRepairPrompt(workflow.context) : repairInstructions(role, problem);
|
|
181
192
|
note(role, omittedWorkflow
|
|
182
|
-
? `The ${role} omitted its required OpenSpec workflow; requesting one correction in the same role before accepting the result.`
|
|
193
|
+
? `The ${role} omitted its required OpenSpec workflow (${problem}); requesting one correction in the same role before accepting the result.`
|
|
183
194
|
: `The ${role} reply was not a valid result (${problem}); asking the same session to resend it.`);
|
|
184
195
|
const repaired = await execute(role, step, result.sessionId ? repair : (options.fallbackPrompt ?? options.prompt) + '\n' + repair, structured, { ...timers, kind: 'repair', resumeSessionId: result.sessionId, outputSchema: options.outputSchema,
|
|
185
196
|
fallbackPrompt: (options.fallbackPrompt ?? options.prompt) + '\nPrevious response (bounded):\n' + result.text.slice(-32_000) + '\n' + repair,
|
|
@@ -192,6 +203,8 @@ export function createRoleInvoker(deps) {
|
|
|
192
203
|
}
|
|
193
204
|
}
|
|
194
205
|
catch (error) {
|
|
206
|
+
if (deps.propagateInterruptions && error instanceof AgentExecutionError && ['aborted', 'timeout', 'idle_timeout', 'lease_lost'].includes(error.code))
|
|
207
|
+
throw error;
|
|
195
208
|
return { ok: false, error: error instanceof Error ? error.message : String(error), ...(error instanceof AgentExecutionError ? { code: error.code } : {}) };
|
|
196
209
|
}
|
|
197
210
|
};
|
|
@@ -106,7 +106,7 @@ export declare class OpenSpecTools {
|
|
|
106
106
|
path?: string;
|
|
107
107
|
content?: string;
|
|
108
108
|
progress?: unknown;
|
|
109
|
-
}): Promise<unknown>;
|
|
109
|
+
}, origin?: 'agent' | 'host'): Promise<unknown>;
|
|
110
110
|
private participationEvents;
|
|
111
111
|
participationCursor(): number;
|
|
112
112
|
assertParticipation(after?: number): void;
|
|
@@ -211,7 +211,7 @@ export class OpenSpecTools {
|
|
|
211
211
|
}
|
|
212
212
|
async apply() { await this.status(); return await this.call(['instructions', 'apply', '--change', this.context.change, '--json']); }
|
|
213
213
|
async validate() { await this.status(); return this.call(['validate', this.context.change, '--strict', '--json']); }
|
|
214
|
-
async execute(input) {
|
|
214
|
+
async execute(input, origin = 'agent') {
|
|
215
215
|
this.signal?.throwIfAborted();
|
|
216
216
|
const { action } = input;
|
|
217
217
|
const log = path.join(this.context.stateDirectory, `openspec-${this.context.role}.jsonl`);
|
|
@@ -225,17 +225,25 @@ export class OpenSpecTools {
|
|
|
225
225
|
if (hash(content) !== this.context.skillHash)
|
|
226
226
|
throw new Error('OpenSpec skill changed since admission');
|
|
227
227
|
// Both official apply and verify start with these read-only CLI queries.
|
|
228
|
-
// Execute them as part of the
|
|
228
|
+
// Execute them as part of the skill request and return their actual
|
|
229
229
|
// outputs, so loading a skill cannot omit its required planning context.
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
const fastForward = openSpecSkill(this.context) === 'openspec-ff-change';
|
|
231
|
+
let planning;
|
|
232
|
+
if (!fastForward)
|
|
233
|
+
planning = { status: await this.status(), apply: await this.call(['instructions', 'apply', '--change', this.context.change, '--json']) };
|
|
234
|
+
else if (existsSync(artifactPath(this.context.root, `openspec/changes/${this.context.change}`))) {
|
|
235
|
+
const status = await this.status();
|
|
236
|
+
// Reusing complete artifacts still requires the official task context.
|
|
237
|
+
// Fresh/incomplete changes continue through the ordinary dependency order.
|
|
238
|
+
if (status.isComplete)
|
|
239
|
+
planning = { status, tasks: await this.call(['instructions', 'tasks', '--change', this.context.change, '--json']) };
|
|
240
|
+
}
|
|
241
|
+
const artifact = fastForward ? 'tasks' : 'apply';
|
|
234
242
|
if (planning)
|
|
235
|
-
prerequisites.push({ action: 'status', via: 'load_skill' }, { action: 'instructions', artifact
|
|
243
|
+
prerequisites.push({ action: 'status', via: 'load_skill' }, { action: 'instructions', artifact, via: 'load_skill' });
|
|
236
244
|
result = { name: openSpecSkill(this.context), source: this.context.skillPath, version: OPENSPEC_VERSION, content,
|
|
237
|
-
...(planning ? { planning, next:
|
|
238
|
-
...(
|
|
245
|
+
...(planning ? { planning, next: `The official status and instructions ${artifact} queries have executed for this request. Read the returned context and current artifacts, reconcile them with the requested change, and perform the remaining role skill steps. Existing artifacts are not proof that the current request is satisfied.` } : {}),
|
|
246
|
+
...(!fastForward ? { savedProgress: readProgress(this.context) } : {}),
|
|
239
247
|
};
|
|
240
248
|
}
|
|
241
249
|
else if (action === 'new') {
|
|
@@ -290,7 +298,7 @@ export class OpenSpecTools {
|
|
|
290
298
|
this.signal?.throwIfAborted();
|
|
291
299
|
const timestamp = new Date().toISOString();
|
|
292
300
|
appendFileSync(log, [{ action, artifact: input.artifact, path: input.path }, ...prerequisites]
|
|
293
|
-
.map(event => JSON.stringify({ ...event, timestamp }) + '\n').join(''), { mode: 0o600 });
|
|
301
|
+
.map(event => JSON.stringify({ ...event, timestamp, ...(origin === 'host' ? { origin } : {}) }) + '\n').join(''), { mode: 0o600 });
|
|
294
302
|
return result;
|
|
295
303
|
}
|
|
296
304
|
participationEvents() {
|