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.
Files changed (49) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-runtime/change-scope.js +2 -0
  3. package/dist/agent-runtime/cli-executor.js +2 -1
  4. package/dist/agent-runtime/cli.js +1 -1
  5. package/dist/agent-runtime/engine/checkpoint/ledger.js +2 -2
  6. package/dist/agent-runtime/engine/cli.js +1 -1
  7. package/dist/agent-runtime/engine/compiler.js +9 -8
  8. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  9. package/dist/agent-runtime/engine/definition-schema.js +2 -0
  10. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  11. package/dist/agent-runtime/engine/definition-types.js +2 -2
  12. package/dist/agent-runtime/engine/definition-validator.js +6 -2
  13. package/dist/agent-runtime/engine/expressions.d.ts +1 -1
  14. package/dist/agent-runtime/engine/expressions.js +16 -3
  15. package/dist/agent-runtime/engine/fork.js +51 -9
  16. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  17. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  18. package/dist/agent-runtime/engine/piece-dependencies.d.ts +6 -2
  19. package/dist/agent-runtime/engine/piece-dependencies.js +54 -7
  20. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  21. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  22. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +4 -0
  23. package/dist/agent-runtime/engine/pieces/artifact-contract.js +70 -0
  24. package/dist/agent-runtime/engine/pieces/control.js +7 -3
  25. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +1 -0
  26. package/dist/agent-runtime/engine/pieces/implementation-step.d.ts +5 -0
  27. package/dist/agent-runtime/engine/pieces/implementation-step.js +47 -0
  28. package/dist/agent-runtime/engine/pieces/implementation.d.ts +5 -1
  29. package/dist/agent-runtime/engine/pieces/implementation.js +8 -7
  30. package/dist/agent-runtime/engine/pieces/index.js +3 -1
  31. package/dist/agent-runtime/engine/pieces/openspec.js +8 -2
  32. package/dist/agent-runtime/engine/pieces/ports.d.ts +6 -1
  33. package/dist/agent-runtime/engine/pieces/prompt.js +2 -2
  34. package/dist/agent-runtime/engine/pieces/role-turn.js +1 -1
  35. package/dist/agent-runtime/engine/pieces/verify.js +79 -4
  36. package/dist/agent-runtime/graph/nodes.js +2 -1
  37. package/dist/agent-runtime/graph/roles.d.ts +2 -0
  38. package/dist/agent-runtime/graph/roles.js +15 -2
  39. package/dist/agent-runtime/graph/state.d.ts +2 -0
  40. package/dist/agent-runtime/openspec.d.ts +1 -1
  41. package/dist/agent-runtime/openspec.js +18 -10
  42. package/dist/agent-runtime/prompts.d.ts +1 -1
  43. package/dist/agent-runtime/prompts.js +18 -10
  44. package/dist/agent-runtime/verification-diagnostics.d.ts +4 -0
  45. package/dist/agent-runtime/verification-diagnostics.js +33 -0
  46. package/dist/pipeline/pipeline-state.js +2 -0
  47. package/integration-contract.json +33 -13
  48. package/package.json +2 -2
  49. 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 response = context.interrupt({ kind, prompt: text(params[field]), nodePath: context.frame.nodePath, scopeId: context.frame.scope.id, attemptId: context.frame.attemptId });
33
- return { outcome: 'next', output: { response }, ...(kind === 'question' ? { answers: [answerEntry(context, response)] } : {}) };
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 change = changeId(text(params.change)), initial = deps.executionSnapshot(context).candidate?.hash;
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 { json, paramsSchema, positiveInteger } from './shared.js';
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 } : {}), commands: receipt.commands.map(command => ({ repositoryId: command.repositoryId, command: command.command, exitCode: command.exitCode })) },
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(({ evidenceId, repositoryId, command, args, exitCode, output }) => ({ ...(evidenceId ? { evidenceId } : {}), repositoryId, command, args, exitCode, output: output.slice(-2000) })),
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
  };
@@ -43,7 +43,9 @@ export interface VerificationRecord {
43
43
  repositoryId: string;
44
44
  command: string;
45
45
  args: string[];
46
+ cwd?: string;
46
47
  exitCode: number | null;
48
+ failureSummary?: string[];
47
49
  output: string;
48
50
  }>;
49
51
  }
@@ -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 agent's tool request and return their actual
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 planning = openSpecSkill(this.context) === 'openspec-ff-change' ? undefined : {
231
- status: await this.status(),
232
- apply: await this.call(['instructions', 'apply', '--change', this.context.change, '--json']),
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: 'apply', via: 'load_skill' });
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: 'The official status and instructions apply queries have executed for this request. Read every planning.apply.contextFiles path, then perform the remaining role skill steps. This is planning context, not proof that implementation or review is complete.' } : {}),
238
- ...(openSpecSkill(this.context) !== 'openspec-ff-change' ? { savedProgress: readProgress(this.context) } : {}),
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() {